Merge branch 'main' of https://github.com/fnvtk/MBTI_wang
# Conflicts: # api/app/controller/api/Analyze.php # api/app/controller/api/AppConfig.php # api/app/controller/api/Payment.php # api/app/controller/api/Test.php # miniprogram/app.js # miniprogram/pages/enterprise/index.js # miniprogram/pages/enterprise/resume-history.js # miniprogram/pages/index/result.js # miniprogram/pages/index/result.wxml # miniprogram/pages/index/result.wxss # miniprogram/pages/promo/poster.js # miniprogram/pages/recharge/index.js # miniprogram/pages/result/disc.wxss # miniprogram/pages/result/mbti.wxss # miniprogram/pages/result/pdp.wxss # miniprogram/pages/result/resume.js # miniprogram/utils/payment.js # miniprogram/utils/share.js
This commit is contained in:
@@ -1,121 +1,121 @@
|
||||
<?php
|
||||
namespace app;
|
||||
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
use think\Validate;
|
||||
use app\common\service\JwtService;
|
||||
|
||||
/**
|
||||
* 控制器基础类
|
||||
*/
|
||||
abstract class BaseController
|
||||
{
|
||||
/**
|
||||
* Request实例
|
||||
* @var \think\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* 应用实例
|
||||
* @var \think\App
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
/**
|
||||
* 是否批量验证
|
||||
* @var bool
|
||||
*/
|
||||
protected $batchValidate = false;
|
||||
|
||||
/**
|
||||
* 控制器中间件
|
||||
* @var array
|
||||
*/
|
||||
protected $middleware = [];
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
* @access public
|
||||
* @param App $app 应用对象
|
||||
*/
|
||||
public function __construct(App $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
$this->request = $this->app->request;
|
||||
|
||||
// 控制器初始化
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 验证数据
|
||||
* @access protected
|
||||
* @param array $data 数据
|
||||
* @param string|array $validate 验证器名或者验证规则数组
|
||||
* @param array $message 提示信息
|
||||
* @param bool $batch 是否批量验证
|
||||
* @return array|string|true
|
||||
* @throws ValidateException
|
||||
*/
|
||||
/**
|
||||
* 从请求中解析当前登录用户(兼容中间件注入和 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,
|
||||
];
|
||||
}
|
||||
|
||||
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
|
||||
{
|
||||
if (is_array($validate)) {
|
||||
$v = new Validate();
|
||||
$v->rule($validate);
|
||||
} else {
|
||||
if (strpos($validate, '.')) {
|
||||
// 支持场景
|
||||
[$validate, $scene] = explode('.', $validate);
|
||||
}
|
||||
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
|
||||
$v = new $class();
|
||||
if (!empty($scene)) {
|
||||
$v->scene($scene);
|
||||
}
|
||||
}
|
||||
|
||||
$v->message($message);
|
||||
|
||||
// 是否批量验证
|
||||
if ($batch || $this->batchValidate) {
|
||||
$v->batch(true);
|
||||
}
|
||||
|
||||
return $v->failException(true)->check($data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<?php
|
||||
namespace app;
|
||||
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
use think\Validate;
|
||||
use app\common\service\JwtService;
|
||||
|
||||
/**
|
||||
* 控制器基础类
|
||||
*/
|
||||
abstract class BaseController
|
||||
{
|
||||
/**
|
||||
* Request实例
|
||||
* @var \think\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* 应用实例
|
||||
* @var \think\App
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
/**
|
||||
* 是否批量验证
|
||||
* @var bool
|
||||
*/
|
||||
protected $batchValidate = false;
|
||||
|
||||
/**
|
||||
* 控制器中间件
|
||||
* @var array
|
||||
*/
|
||||
protected $middleware = [];
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
* @access public
|
||||
* @param App $app 应用对象
|
||||
*/
|
||||
public function __construct(App $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
$this->request = $this->app->request;
|
||||
|
||||
// 控制器初始化
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 验证数据
|
||||
* @access protected
|
||||
* @param array $data 数据
|
||||
* @param string|array $validate 验证器名或者验证规则数组
|
||||
* @param array $message 提示信息
|
||||
* @param bool $batch 是否批量验证
|
||||
* @return array|string|true
|
||||
* @throws ValidateException
|
||||
*/
|
||||
/**
|
||||
* 从请求中解析当前登录用户(兼容中间件注入和 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,
|
||||
];
|
||||
}
|
||||
|
||||
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
|
||||
{
|
||||
if (is_array($validate)) {
|
||||
$v = new Validate();
|
||||
$v->rule($validate);
|
||||
} else {
|
||||
if (strpos($validate, '.')) {
|
||||
// 支持场景
|
||||
[$validate, $scene] = explode('.', $validate);
|
||||
}
|
||||
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
|
||||
$v = new $class();
|
||||
if (!empty($scene)) {
|
||||
$v->scene($scene);
|
||||
}
|
||||
}
|
||||
|
||||
$v->message($message);
|
||||
|
||||
// 是否批量验证
|
||||
if ($batch || $this->batchValidate) {
|
||||
$v->batch(true);
|
||||
}
|
||||
|
||||
return $v->failException(true)->check($data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,171 +1,171 @@
|
||||
<?php
|
||||
// 应用公共文件
|
||||
|
||||
/**
|
||||
* 统一响应格式
|
||||
* @param int $code 状态码
|
||||
* @param string $message 消息
|
||||
* @param mixed $data 数据
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function json_response($code = 200, $message = 'success', $data = null)
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
* @param mixed $data 数据
|
||||
* @param string $message 消息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function success($data = null, $message = 'success')
|
||||
{
|
||||
return json_response(200, $message, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
* @param string $message 错误消息
|
||||
* @param int $code 错误码
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function error($message = 'error', $code = 400)
|
||||
{
|
||||
return json_response($code, $message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
* @param array $list 列表数据
|
||||
* @param int $total 总数
|
||||
* @param int $page 当前页
|
||||
* @param int $pageSize 每页数量
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function paginate_response($list, $total, $page = 1, $pageSize = 10)
|
||||
{
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'hasMore' => ($page * $pageSize) < $total
|
||||
]);
|
||||
}
|
||||
|
||||
if (!function_exists('requestCurl')) {
|
||||
/**
|
||||
* @param string $url 请求的链接
|
||||
* @param array $params 请求附带的参数
|
||||
* @param string $method 请求的方式, 支持GET, POST, PUT, DELETE等
|
||||
* @param array $header 头部
|
||||
* @param string $type 数据类型,支持dataBuild、json等
|
||||
* @return bool|string
|
||||
*/
|
||||
function requestCurl($url, $params = [], $method = 'GET', $header = [], $type = 'dataBuild')
|
||||
{
|
||||
$str = '';
|
||||
if (!empty($url)) {
|
||||
try {
|
||||
$ch = curl_init();
|
||||
|
||||
// 处理GET请求的参数
|
||||
if (strtoupper($method) == 'GET' && !empty($params)) {
|
||||
$url = $url . '?' . dataBuild($params);
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_HEADER, 0);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30); //30秒超时
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
|
||||
|
||||
// 处理不同的请求方法
|
||||
if (strtoupper($method) != 'GET') {
|
||||
// 设置请求方法
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
||||
|
||||
// 处理参数格式
|
||||
if ($type == 'dataBuild') {
|
||||
$params = dataBuild($params);
|
||||
} elseif ($type == 'json') {
|
||||
$params = json_encode($params);
|
||||
} else {
|
||||
$params = dataBuild($params);
|
||||
}
|
||||
|
||||
// 设置请求体
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); //是否验证对等证书,1则验证,0则不验证
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
|
||||
$str = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
} catch (Exception $e) {
|
||||
$str = '';
|
||||
}
|
||||
}
|
||||
return $str;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!function_exists('dataBuild')) {
|
||||
function dataBuild($array)
|
||||
{
|
||||
if (!is_array($array)) {
|
||||
return $array;
|
||||
}
|
||||
|
||||
// 处理嵌套数组
|
||||
foreach ($array as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$array[$key] = json_encode($value);
|
||||
}
|
||||
}
|
||||
|
||||
return http_build_query($array);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!function_exists('setHeader')) {
|
||||
/**
|
||||
* 设置头部
|
||||
*
|
||||
* @param array $headerData 头部数组
|
||||
* @param string $authorization
|
||||
* @param string $type 类型 默认json (json,plain)
|
||||
* @return array
|
||||
*/
|
||||
function setHeader($headerData = [], $authorization = '', $type = '')
|
||||
{
|
||||
$header = $headerData;
|
||||
|
||||
switch ($type) {
|
||||
case 'json':
|
||||
$header[] = 'Content-Type:application/json';
|
||||
break;
|
||||
case 'html' :
|
||||
$header[] = 'Content-Type:text/html';
|
||||
break;
|
||||
case 'plain' :
|
||||
$header[] = 'Content-Type:text/plain';
|
||||
break;
|
||||
default:
|
||||
$header[] = 'Content-Type:application/json';
|
||||
}
|
||||
// $header[] = $type == 'plain' ? 'Content-Type:text/plain' : 'Content-Type: application/json';
|
||||
if ($authorization !== "") $header[] = 'Authorization:Bearer ' . $authorization;
|
||||
return $header;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<?php
|
||||
// 应用公共文件
|
||||
|
||||
/**
|
||||
* 统一响应格式
|
||||
* @param int $code 状态码
|
||||
* @param string $message 消息
|
||||
* @param mixed $data 数据
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function json_response($code = 200, $message = 'success', $data = null)
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
* @param mixed $data 数据
|
||||
* @param string $message 消息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function success($data = null, $message = 'success')
|
||||
{
|
||||
return json_response(200, $message, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
* @param string $message 错误消息
|
||||
* @param int $code 错误码
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function error($message = 'error', $code = 400)
|
||||
{
|
||||
return json_response($code, $message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
* @param array $list 列表数据
|
||||
* @param int $total 总数
|
||||
* @param int $page 当前页
|
||||
* @param int $pageSize 每页数量
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
function paginate_response($list, $total, $page = 1, $pageSize = 10)
|
||||
{
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'hasMore' => ($page * $pageSize) < $total
|
||||
]);
|
||||
}
|
||||
|
||||
if (!function_exists('requestCurl')) {
|
||||
/**
|
||||
* @param string $url 请求的链接
|
||||
* @param array $params 请求附带的参数
|
||||
* @param string $method 请求的方式, 支持GET, POST, PUT, DELETE等
|
||||
* @param array $header 头部
|
||||
* @param string $type 数据类型,支持dataBuild、json等
|
||||
* @return bool|string
|
||||
*/
|
||||
function requestCurl($url, $params = [], $method = 'GET', $header = [], $type = 'dataBuild')
|
||||
{
|
||||
$str = '';
|
||||
if (!empty($url)) {
|
||||
try {
|
||||
$ch = curl_init();
|
||||
|
||||
// 处理GET请求的参数
|
||||
if (strtoupper($method) == 'GET' && !empty($params)) {
|
||||
$url = $url . '?' . dataBuild($params);
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_HEADER, 0);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30); //30秒超时
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
|
||||
|
||||
// 处理不同的请求方法
|
||||
if (strtoupper($method) != 'GET') {
|
||||
// 设置请求方法
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
||||
|
||||
// 处理参数格式
|
||||
if ($type == 'dataBuild') {
|
||||
$params = dataBuild($params);
|
||||
} elseif ($type == 'json') {
|
||||
$params = json_encode($params);
|
||||
} else {
|
||||
$params = dataBuild($params);
|
||||
}
|
||||
|
||||
// 设置请求体
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); //是否验证对等证书,1则验证,0则不验证
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
|
||||
$str = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
} catch (Exception $e) {
|
||||
$str = '';
|
||||
}
|
||||
}
|
||||
return $str;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!function_exists('dataBuild')) {
|
||||
function dataBuild($array)
|
||||
{
|
||||
if (!is_array($array)) {
|
||||
return $array;
|
||||
}
|
||||
|
||||
// 处理嵌套数组
|
||||
foreach ($array as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$array[$key] = json_encode($value);
|
||||
}
|
||||
}
|
||||
|
||||
return http_build_query($array);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!function_exists('setHeader')) {
|
||||
/**
|
||||
* 设置头部
|
||||
*
|
||||
* @param array $headerData 头部数组
|
||||
* @param string $authorization
|
||||
* @param string $type 类型 默认json (json,plain)
|
||||
* @return array
|
||||
*/
|
||||
function setHeader($headerData = [], $authorization = '', $type = '')
|
||||
{
|
||||
$header = $headerData;
|
||||
|
||||
switch ($type) {
|
||||
case 'json':
|
||||
$header[] = 'Content-Type:application/json';
|
||||
break;
|
||||
case 'html' :
|
||||
$header[] = 'Content-Type:text/html';
|
||||
break;
|
||||
case 'plain' :
|
||||
$header[] = 'Content-Type:text/plain';
|
||||
break;
|
||||
default:
|
||||
$header[] = 'Content-Type:application/json';
|
||||
}
|
||||
// $header[] = $type == 'plain' ? 'Content-Type:text/plain' : 'Content-Type: application/json';
|
||||
if ($authorization !== "") $header[] = 'Authorization:Bearer ' . $authorization;
|
||||
return $header;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,124 +1,124 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
use think\Validate;
|
||||
use think\facade\Request;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* 公共基础控制器
|
||||
*/
|
||||
abstract class BaseController
|
||||
{
|
||||
/**
|
||||
* Request实例
|
||||
* @var \think\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* 应用实例
|
||||
* @var \think\App
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
* @access public
|
||||
* @param App $app 应用对象
|
||||
*/
|
||||
public function __construct(App $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
$this->request = $this->app->request;
|
||||
|
||||
// 控制器初始化
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
* @param mixed $data 数据
|
||||
* @param string $message 消息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
protected function success($data = null, $message = 'success')
|
||||
{
|
||||
$response = Response::create([
|
||||
'code' => 200,
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
], 'json')->code(200);
|
||||
|
||||
$response->header([
|
||||
'Content-Type' => 'application/json; charset=utf-8'
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
* @param string $message 错误消息
|
||||
* @param int $code 错误码
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
protected function error($message = 'error', $code = 400)
|
||||
{
|
||||
$response = Response::create([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => null
|
||||
], 'json')->code($code);
|
||||
|
||||
$response->header([
|
||||
'Content-Type' => 'application/json; charset=utf-8'
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据
|
||||
* @access protected
|
||||
* @param array $data 数据
|
||||
* @param string|array $validate 验证器名或者验证规则数组
|
||||
* @param array $message 提示信息
|
||||
* @param bool $batch 是否批量验证
|
||||
* @return array|string|true
|
||||
* @throws ValidateException
|
||||
*/
|
||||
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
|
||||
{
|
||||
if (is_array($validate)) {
|
||||
$v = new Validate();
|
||||
$v->rule($validate);
|
||||
} else {
|
||||
if (strpos($validate, '.')) {
|
||||
// 支持场景
|
||||
[$validate, $scene] = explode('.', $validate);
|
||||
}
|
||||
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
|
||||
$v = new $class();
|
||||
if (!empty($scene)) {
|
||||
$v->scene($scene);
|
||||
}
|
||||
}
|
||||
|
||||
$v->message($message);
|
||||
|
||||
// 是否批量验证
|
||||
if ($batch) {
|
||||
$v->batch(true);
|
||||
}
|
||||
|
||||
return $v->failException(true)->check($data);
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
use think\Validate;
|
||||
use think\facade\Request;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* 公共基础控制器
|
||||
*/
|
||||
abstract class BaseController
|
||||
{
|
||||
/**
|
||||
* Request实例
|
||||
* @var \think\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* 应用实例
|
||||
* @var \think\App
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
* @access public
|
||||
* @param App $app 应用对象
|
||||
*/
|
||||
public function __construct(App $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
$this->request = $this->app->request;
|
||||
|
||||
// 控制器初始化
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
* @param mixed $data 数据
|
||||
* @param string $message 消息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
protected function success($data = null, $message = 'success')
|
||||
{
|
||||
$response = Response::create([
|
||||
'code' => 200,
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
], 'json')->code(200);
|
||||
|
||||
$response->header([
|
||||
'Content-Type' => 'application/json; charset=utf-8'
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
* @param string $message 错误消息
|
||||
* @param int $code 错误码
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
protected function error($message = 'error', $code = 400)
|
||||
{
|
||||
$response = Response::create([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => null
|
||||
], 'json')->code($code);
|
||||
|
||||
$response->header([
|
||||
'Content-Type' => 'application/json; charset=utf-8'
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据
|
||||
* @access protected
|
||||
* @param array $data 数据
|
||||
* @param string|array $validate 验证器名或者验证规则数组
|
||||
* @param array $message 提示信息
|
||||
* @param bool $batch 是否批量验证
|
||||
* @return array|string|true
|
||||
* @throws ValidateException
|
||||
*/
|
||||
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
|
||||
{
|
||||
if (is_array($validate)) {
|
||||
$v = new Validate();
|
||||
$v->rule($validate);
|
||||
} else {
|
||||
if (strpos($validate, '.')) {
|
||||
// 支持场景
|
||||
[$validate, $scene] = explode('.', $validate);
|
||||
}
|
||||
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
|
||||
$v = new $class();
|
||||
if (!empty($scene)) {
|
||||
$v->scene($scene);
|
||||
}
|
||||
}
|
||||
|
||||
$v->message($message);
|
||||
|
||||
// 是否批量验证
|
||||
if ($batch) {
|
||||
$v->batch(true);
|
||||
}
|
||||
|
||||
return $v->failException(true)->check($data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,132 +1,132 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* JWT Token 服务类
|
||||
*/
|
||||
class JwtService
|
||||
{
|
||||
/**
|
||||
* 生成Token
|
||||
* @param array $payload 载荷数据
|
||||
* @return string
|
||||
*/
|
||||
public static function generateToken(array $payload): string
|
||||
{
|
||||
$secret = config('jwt.secret', 'mbti_jwt_secret_key_2024');
|
||||
$expire = config('jwt.expire', 86400 * 7); // 默认7天
|
||||
|
||||
// 添加过期时间
|
||||
$payload['exp'] = time() + $expire;
|
||||
$payload['iat'] = time();
|
||||
|
||||
// 生成Token(简单方案:base64编码 + 签名)
|
||||
$header = base64_encode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
|
||||
$payloadStr = base64_encode(json_encode($payload));
|
||||
$signature = hash_hmac('sha256', $header . '.' . $payloadStr, $secret);
|
||||
|
||||
$token = $header . '.' . $payloadStr . '.' . $signature;
|
||||
|
||||
// 将Token存储到缓存(用于刷新和注销),带 source 区分小程序用户与管理员
|
||||
$userId = $payload['userId'] ?? $payload['user_id'] ?? null;
|
||||
if ($userId !== null) {
|
||||
$key = self::tokenCacheKey($userId, $payload['source'] ?? null);
|
||||
Cache::set($key, $token, $expire);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Token
|
||||
* @param string $token
|
||||
* @return array|false 返回载荷数据或false
|
||||
*/
|
||||
public static function verifyToken(string $token)
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$header, $payloadStr, $signature] = $parts;
|
||||
|
||||
// 验证签名
|
||||
$secret = config('jwt.secret', 'mbti_jwt_secret_key_2024');
|
||||
$expectedSignature = hash_hmac('sha256', $header . '.' . $payloadStr, $secret);
|
||||
|
||||
if ($signature !== $expectedSignature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析载荷
|
||||
$payload = json_decode(base64_decode($payloadStr), true);
|
||||
if (!$payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查过期时间
|
||||
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
* @param string $token
|
||||
* @return string|false 返回新Token或false
|
||||
*/
|
||||
public static function refreshToken(string $token)
|
||||
{
|
||||
$payload = self::verifyToken($token);
|
||||
if (!$payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除过期时间字段,重新生成
|
||||
unset($payload['exp'], $payload['iat']);
|
||||
|
||||
return self::generateToken($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 缓存键(区分来源,避免与管理员同 id 冲突)
|
||||
*/
|
||||
public static function tokenCacheKey($userId, ?string $source = null): string
|
||||
{
|
||||
$prefix = $source ? 'jwt_token_' . $source . '_' : 'jwt_token_';
|
||||
return $prefix . $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Token(注销)
|
||||
* @param int $userId
|
||||
* @param string|null $source 来源,如 wechat,不传则按管理员 token 键删除
|
||||
* @return bool
|
||||
*/
|
||||
public static function deleteToken(int $userId, ?string $source = null): bool
|
||||
{
|
||||
return Cache::delete(self::tokenCacheKey($userId, $source));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头获取Token
|
||||
* @param \think\Request $request
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getTokenFromRequest($request): ?string
|
||||
{
|
||||
$authorization = $request->header('Authorization', '');
|
||||
|
||||
if ($authorization && preg_match('/Bearer\s+(.*)$/i', $authorization, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* JWT Token 服务类
|
||||
*/
|
||||
class JwtService
|
||||
{
|
||||
/**
|
||||
* 生成Token
|
||||
* @param array $payload 载荷数据
|
||||
* @return string
|
||||
*/
|
||||
public static function generateToken(array $payload): string
|
||||
{
|
||||
$secret = config('jwt.secret', 'mbti_jwt_secret_key_2024');
|
||||
$expire = config('jwt.expire', 86400 * 7); // 默认7天
|
||||
|
||||
// 添加过期时间
|
||||
$payload['exp'] = time() + $expire;
|
||||
$payload['iat'] = time();
|
||||
|
||||
// 生成Token(简单方案:base64编码 + 签名)
|
||||
$header = base64_encode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
|
||||
$payloadStr = base64_encode(json_encode($payload));
|
||||
$signature = hash_hmac('sha256', $header . '.' . $payloadStr, $secret);
|
||||
|
||||
$token = $header . '.' . $payloadStr . '.' . $signature;
|
||||
|
||||
// 将Token存储到缓存(用于刷新和注销),带 source 区分小程序用户与管理员
|
||||
$userId = $payload['userId'] ?? $payload['user_id'] ?? null;
|
||||
if ($userId !== null) {
|
||||
$key = self::tokenCacheKey($userId, $payload['source'] ?? null);
|
||||
Cache::set($key, $token, $expire);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Token
|
||||
* @param string $token
|
||||
* @return array|false 返回载荷数据或false
|
||||
*/
|
||||
public static function verifyToken(string $token)
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$header, $payloadStr, $signature] = $parts;
|
||||
|
||||
// 验证签名
|
||||
$secret = config('jwt.secret', 'mbti_jwt_secret_key_2024');
|
||||
$expectedSignature = hash_hmac('sha256', $header . '.' . $payloadStr, $secret);
|
||||
|
||||
if ($signature !== $expectedSignature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析载荷
|
||||
$payload = json_decode(base64_decode($payloadStr), true);
|
||||
if (!$payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查过期时间
|
||||
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
* @param string $token
|
||||
* @return string|false 返回新Token或false
|
||||
*/
|
||||
public static function refreshToken(string $token)
|
||||
{
|
||||
$payload = self::verifyToken($token);
|
||||
if (!$payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除过期时间字段,重新生成
|
||||
unset($payload['exp'], $payload['iat']);
|
||||
|
||||
return self::generateToken($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 缓存键(区分来源,避免与管理员同 id 冲突)
|
||||
*/
|
||||
public static function tokenCacheKey($userId, ?string $source = null): string
|
||||
{
|
||||
$prefix = $source ? 'jwt_token_' . $source . '_' : 'jwt_token_';
|
||||
return $prefix . $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Token(注销)
|
||||
* @param int $userId
|
||||
* @param string|null $source 来源,如 wechat,不传则按管理员 token 键删除
|
||||
* @return bool
|
||||
*/
|
||||
public static function deleteToken(int $userId, ?string $source = null): bool
|
||||
{
|
||||
return Cache::delete(self::tokenCacheKey($userId, $source));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头获取Token
|
||||
* @param \think\Request $request
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getTokenFromRequest($request): ?string
|
||||
{
|
||||
$authorization = $request->header('Authorization', '');
|
||||
|
||||
if ($authorization && preg_match('/Bearer\s+(.*)$/i', $authorization, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,194 +1,194 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 微信小程序接口服务
|
||||
*/
|
||||
class WechatService
|
||||
{
|
||||
protected static $jscode2sessionUrl = 'https://api.weixin.qq.com/sns/jscode2session';
|
||||
protected static $tokenUrl = 'https://api.weixin.qq.com/cgi-bin/token';
|
||||
protected static $getPhoneNumberUrl = 'https://api.weixin.qq.com/wxa/business/getuserphonenumber';
|
||||
protected static $getWxacodeUnlimitedUrl = 'https://api.weixin.qq.com/wxa/getwxacodeunlimit';
|
||||
|
||||
/** @var string|null 内存缓存的 access_token */
|
||||
protected static $cachedAccessToken = null;
|
||||
/** @var int 缓存的 access_token 过期时间戳 */
|
||||
protected static $cachedAccessTokenExpire = 0;
|
||||
|
||||
/**
|
||||
* 获取小程序 access_token(带简单内存缓存,过期前 5 分钟刷新)
|
||||
* @return array{access_token:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function getAccessToken(): array
|
||||
{
|
||||
$now = time();
|
||||
if (self::$cachedAccessToken && self::$cachedAccessTokenExpire > $now + 300) {
|
||||
return ['access_token' => self::$cachedAccessToken];
|
||||
}
|
||||
$appId = config('wechat.app_id');
|
||||
$appSecret = config('wechat.app_secret');
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
return ['errcode' => -1, 'errmsg' => '未配置微信小程序 app_id 或 app_secret'];
|
||||
}
|
||||
$url = self::$tokenUrl . '?' . http_build_query([
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $appId,
|
||||
'secret' => $appSecret,
|
||||
]);
|
||||
$resp = @file_get_contents($url);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
$data = json_decode($resp, true);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return ['errcode' => -3, 'errmsg' => '微信接口返回异常'];
|
||||
}
|
||||
if (isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
$token = $data['access_token'] ?? '';
|
||||
$expiresIn = (int) ($data['expires_in'] ?? 7200);
|
||||
self::$cachedAccessToken = $token;
|
||||
self::$cachedAccessTokenExpire = $now + $expiresIn;
|
||||
return ['access_token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 getPhoneNumber 回调里的 code 换取手机号
|
||||
* @param string $code 小程序 button open-type="getPhoneNumber" 回调中的 detail.code
|
||||
* @return array{phoneNumber:string,purePhoneNumber:string,countryCode:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function getPhoneNumber(string $code): array
|
||||
{
|
||||
$tokenResult = self::getAccessToken();
|
||||
if (isset($tokenResult['errcode'])) {
|
||||
return $tokenResult;
|
||||
}
|
||||
$accessToken = $tokenResult['access_token'];
|
||||
$url = self::$getPhoneNumberUrl . '?access_token=' . urlencode($accessToken);
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/json',
|
||||
'content' => json_encode(['code' => $code]),
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
$data = json_decode($resp, true);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return ['errcode' => -3, 'errmsg' => '微信接口返回异常'];
|
||||
}
|
||||
if (isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
$phoneInfo = $data['phone_info'] ?? [];
|
||||
$purePhoneNumber = $phoneInfo['purePhoneNumber'] ?? $phoneInfo['phoneNumber'] ?? '';
|
||||
$phoneNumber = $phoneInfo['phoneNumber'] ?? $purePhoneNumber;
|
||||
$countryCode = $phoneInfo['countryCode'] ?? '86';
|
||||
return [
|
||||
'phoneNumber' => $phoneNumber,
|
||||
'purePhoneNumber' => $purePhoneNumber,
|
||||
'countryCode' => $countryCode,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* code 换取 openid、session_key(及 unionid)
|
||||
* @param string $code 小程序 wx.login 返回的 code
|
||||
* @return array{openid:string,session_key:string,unionid?:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function jscode2session(string $code): array
|
||||
{
|
||||
$appId = config('wechat.app_id');
|
||||
$appSecret = config('wechat.app_secret');
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
return ['errcode' => -1, 'errmsg' => '未配置微信小程序 app_id 或 app_secret'];
|
||||
}
|
||||
|
||||
$url = self::$jscode2sessionUrl . '?' . http_build_query([
|
||||
'appid' => $appId,
|
||||
'secret' => $appSecret,
|
||||
'js_code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
|
||||
$resp = @file_get_contents($url);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return ['errcode' => -3, 'errmsg' => '微信接口返回异常'];
|
||||
}
|
||||
|
||||
if (isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'openid' => $data['openid'] ?? '',
|
||||
'session_key' => $data['session_key'] ?? '',
|
||||
'unionid' => $data['unionid'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带参数的小程序码(永久有效),返回原始二进制或错误信息
|
||||
* @param string $scene 最大 32 个可见字符,用于区分邀请人/渠道
|
||||
* @param string $page 小程序页面路径,如 pages/index/index
|
||||
* @param int $width 小程序码宽度,默认 430
|
||||
* @return array{binary:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function getWxacodeUnlimited(string $scene, string $page, int $width = 430): array
|
||||
{
|
||||
$tokenResult = self::getAccessToken();
|
||||
if (isset($tokenResult['errcode'])) {
|
||||
return $tokenResult;
|
||||
}
|
||||
$accessToken = $tokenResult['access_token'];
|
||||
$url = self::$getWxacodeUnlimitedUrl . '?access_token=' . urlencode($accessToken);
|
||||
$payload = [
|
||||
'scene' => mb_substr($scene, 0, 32),
|
||||
'page' => $page,
|
||||
'width' => $width,
|
||||
'check_path' => false,
|
||||
];
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/json',
|
||||
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
// 微信错误时返回 JSON,成功时返回图片二进制
|
||||
$head = substr($resp, 0, 1);
|
||||
if ($head === '{' || $head === '[') {
|
||||
$data = json_decode($resp, true);
|
||||
if (is_array($data) && isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
}
|
||||
return ['binary' => $resp];
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 微信小程序接口服务
|
||||
*/
|
||||
class WechatService
|
||||
{
|
||||
protected static $jscode2sessionUrl = 'https://api.weixin.qq.com/sns/jscode2session';
|
||||
protected static $tokenUrl = 'https://api.weixin.qq.com/cgi-bin/token';
|
||||
protected static $getPhoneNumberUrl = 'https://api.weixin.qq.com/wxa/business/getuserphonenumber';
|
||||
protected static $getWxacodeUnlimitedUrl = 'https://api.weixin.qq.com/wxa/getwxacodeunlimit';
|
||||
|
||||
/** @var string|null 内存缓存的 access_token */
|
||||
protected static $cachedAccessToken = null;
|
||||
/** @var int 缓存的 access_token 过期时间戳 */
|
||||
protected static $cachedAccessTokenExpire = 0;
|
||||
|
||||
/**
|
||||
* 获取小程序 access_token(带简单内存缓存,过期前 5 分钟刷新)
|
||||
* @return array{access_token:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function getAccessToken(): array
|
||||
{
|
||||
$now = time();
|
||||
if (self::$cachedAccessToken && self::$cachedAccessTokenExpire > $now + 300) {
|
||||
return ['access_token' => self::$cachedAccessToken];
|
||||
}
|
||||
$appId = config('wechat.app_id');
|
||||
$appSecret = config('wechat.app_secret');
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
return ['errcode' => -1, 'errmsg' => '未配置微信小程序 app_id 或 app_secret'];
|
||||
}
|
||||
$url = self::$tokenUrl . '?' . http_build_query([
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $appId,
|
||||
'secret' => $appSecret,
|
||||
]);
|
||||
$resp = @file_get_contents($url);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
$data = json_decode($resp, true);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return ['errcode' => -3, 'errmsg' => '微信接口返回异常'];
|
||||
}
|
||||
if (isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
$token = $data['access_token'] ?? '';
|
||||
$expiresIn = (int) ($data['expires_in'] ?? 7200);
|
||||
self::$cachedAccessToken = $token;
|
||||
self::$cachedAccessTokenExpire = $now + $expiresIn;
|
||||
return ['access_token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 getPhoneNumber 回调里的 code 换取手机号
|
||||
* @param string $code 小程序 button open-type="getPhoneNumber" 回调中的 detail.code
|
||||
* @return array{phoneNumber:string,purePhoneNumber:string,countryCode:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function getPhoneNumber(string $code): array
|
||||
{
|
||||
$tokenResult = self::getAccessToken();
|
||||
if (isset($tokenResult['errcode'])) {
|
||||
return $tokenResult;
|
||||
}
|
||||
$accessToken = $tokenResult['access_token'];
|
||||
$url = self::$getPhoneNumberUrl . '?access_token=' . urlencode($accessToken);
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/json',
|
||||
'content' => json_encode(['code' => $code]),
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
$data = json_decode($resp, true);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return ['errcode' => -3, 'errmsg' => '微信接口返回异常'];
|
||||
}
|
||||
if (isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
$phoneInfo = $data['phone_info'] ?? [];
|
||||
$purePhoneNumber = $phoneInfo['purePhoneNumber'] ?? $phoneInfo['phoneNumber'] ?? '';
|
||||
$phoneNumber = $phoneInfo['phoneNumber'] ?? $purePhoneNumber;
|
||||
$countryCode = $phoneInfo['countryCode'] ?? '86';
|
||||
return [
|
||||
'phoneNumber' => $phoneNumber,
|
||||
'purePhoneNumber' => $purePhoneNumber,
|
||||
'countryCode' => $countryCode,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* code 换取 openid、session_key(及 unionid)
|
||||
* @param string $code 小程序 wx.login 返回的 code
|
||||
* @return array{openid:string,session_key:string,unionid?:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function jscode2session(string $code): array
|
||||
{
|
||||
$appId = config('wechat.app_id');
|
||||
$appSecret = config('wechat.app_secret');
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
return ['errcode' => -1, 'errmsg' => '未配置微信小程序 app_id 或 app_secret'];
|
||||
}
|
||||
|
||||
$url = self::$jscode2sessionUrl . '?' . http_build_query([
|
||||
'appid' => $appId,
|
||||
'secret' => $appSecret,
|
||||
'js_code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
|
||||
$resp = @file_get_contents($url);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return ['errcode' => -3, 'errmsg' => '微信接口返回异常'];
|
||||
}
|
||||
|
||||
if (isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'openid' => $data['openid'] ?? '',
|
||||
'session_key' => $data['session_key'] ?? '',
|
||||
'unionid' => $data['unionid'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带参数的小程序码(永久有效),返回原始二进制或错误信息
|
||||
* @param string $scene 最大 32 个可见字符,用于区分邀请人/渠道
|
||||
* @param string $page 小程序页面路径,如 pages/index/index
|
||||
* @param int $width 小程序码宽度,默认 430
|
||||
* @return array{binary:string}|array{errcode:int,errmsg:string}
|
||||
*/
|
||||
public static function getWxacodeUnlimited(string $scene, string $page, int $width = 430): array
|
||||
{
|
||||
$tokenResult = self::getAccessToken();
|
||||
if (isset($tokenResult['errcode'])) {
|
||||
return $tokenResult;
|
||||
}
|
||||
$accessToken = $tokenResult['access_token'];
|
||||
$url = self::$getWxacodeUnlimitedUrl . '?access_token=' . urlencode($accessToken);
|
||||
$payload = [
|
||||
'scene' => mb_substr($scene, 0, 32),
|
||||
'page' => $page,
|
||||
'width' => $width,
|
||||
'check_path' => false,
|
||||
];
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/json',
|
||||
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
// 微信错误时返回 JSON,成功时返回图片二进制
|
||||
$head = substr($resp, 0, 1);
|
||||
if ($head === '{' || $head === '[') {
|
||||
$data = json_decode($resp, true);
|
||||
if (is_array($data) && isset($data['errcode']) && $data['errcode'] !== 0) {
|
||||
return [
|
||||
'errcode' => (int) $data['errcode'],
|
||||
'errmsg' => $data['errmsg'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
}
|
||||
return ['binary' => $resp];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,495 +1,495 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use Exception;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 微信商家转账到零钱封装
|
||||
*
|
||||
* 配置从 env / config 中读取,字段示例:
|
||||
* - WECHAT_MCH_ID
|
||||
* - WECHAT_APP_ID
|
||||
* - WECHAT_API_V3_KEY
|
||||
* - WECHAT_MCH_PRIVATE_KEY (绝对路径 apiclient_key.pem)
|
||||
* - WECHAT_MCH_CERT_SERIAL
|
||||
*/
|
||||
class WechatTransferService
|
||||
{
|
||||
// 微信支付API域名
|
||||
const API_BASE_URL = 'https://api.mch.weixin.qq.com';
|
||||
const API_BASE_URL_BACKUP = 'https://api2.mch.weixin.qq.com';
|
||||
|
||||
// 配置信息
|
||||
private $mchId; // 商户号
|
||||
private $appId; // 小程序/公众号AppID
|
||||
private $apiV3Key; // API v3密钥
|
||||
private $privateKey; // 商户私钥(用于签名)
|
||||
private $certSerialNo; // 证书序列号(用于加密敏感信息)
|
||||
private $publicKey; // 微信支付公钥(用于验证回调)
|
||||
|
||||
/**
|
||||
* 构造函数:直接从 .env 读取配置
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
// 核心配置来自 mbti/api/.env
|
||||
$this->mchId = env('MCH_ID', '');
|
||||
$this->appId = env('WECHAT_APPID', '');
|
||||
$this->apiV3Key = env('API_KEY', '');
|
||||
$this->certSerialNo= env('CERT_SERIAL_NO', '');
|
||||
|
||||
if (!$this->mchId || !$this->appId || !$this->apiV3Key || !$this->certSerialNo) {
|
||||
throw new Exception('微信转账配置不完整,请检查 .env 中的 MCH_ID / WECHAT_APPID / API_KEY / CERT_SERIAL_NO');
|
||||
}
|
||||
|
||||
// 私钥:支持本地路径、URL 或直接内容
|
||||
$privateKeyConf = env('PRIVATE_KEY', '');
|
||||
if ($privateKeyConf) {
|
||||
if (file_exists($privateKeyConf)) {
|
||||
$this->privateKey = file_get_contents($privateKeyConf);
|
||||
} elseif (filter_var($privateKeyConf, FILTER_VALIDATE_URL)) {
|
||||
$this->privateKey = file_get_contents($privateKeyConf);
|
||||
if ($this->privateKey === false) {
|
||||
Log::error('无法从URL加载私钥', ['url' => $privateKeyConf]);
|
||||
$this->privateKey = '';
|
||||
}
|
||||
} else {
|
||||
$this->privateKey = $privateKeyConf;
|
||||
}
|
||||
}
|
||||
if (empty($this->privateKey)) {
|
||||
throw new Exception('商户私钥加载失败,请检查 PRIVATE_KEY 配置');
|
||||
}
|
||||
|
||||
// 公钥(可选):用于后续回调验签,支持本地路径、URL 或直接内容
|
||||
$publicKeyConf = env('WECHAT_PAY_PUB_KEY', '');
|
||||
$this->publicKey = '';
|
||||
if ($publicKeyConf) {
|
||||
if (file_exists($publicKeyConf)) {
|
||||
$this->publicKey = file_get_contents($publicKeyConf);
|
||||
} elseif (filter_var($publicKeyConf, FILTER_VALIDATE_URL)) {
|
||||
$this->publicKey = file_get_contents($publicKeyConf);
|
||||
if ($this->publicKey === false) {
|
||||
Log::error('无法从URL加载公钥', ['url' => $publicKeyConf]);
|
||||
$this->publicKey = '';
|
||||
}
|
||||
} else {
|
||||
$this->publicKey = $publicKeyConf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起转账
|
||||
* @param array $params 转账参数
|
||||
* - out_bill_no: 商户单号(必填)
|
||||
* - openid: 收款用户OpenID(必填)
|
||||
* - transfer_amount: 转账金额,单位:分(必填)
|
||||
* - transfer_remark: 转账备注(必填)
|
||||
* - transfer_scene_id: 转账场景ID(必填,如:1000现金营销,1006企业报销)
|
||||
* - user_name: 收款用户姓名(选填,>=2000元必填)
|
||||
* - transfer_scene_report_infos: 转账场景报备信息(必填)
|
||||
* - notify_url: 通知地址(选填)
|
||||
* - user_recv_perception: 用户收款感知(选填)
|
||||
* @return array
|
||||
*/
|
||||
public function createTransfer($params)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills';
|
||||
|
||||
// 构建请求体
|
||||
$body = [
|
||||
'appid' => $this->appId,
|
||||
'out_bill_no' => $params['out_bill_no'],
|
||||
'transfer_scene_id' => $params['transfer_scene_id'],
|
||||
'openid' => $params['openid'],
|
||||
'transfer_amount' => intval($params['transfer_amount']),
|
||||
'transfer_remark' => $params['transfer_remark'],
|
||||
// 场景报备信息(必填):岗位类型 + 报酬说明
|
||||
'transfer_scene_report_infos' => $params['transfer_scene_report_infos'] ?? [],
|
||||
];
|
||||
|
||||
// 可选参数
|
||||
if (isset($params['user_name']) && !empty($params['user_name'])) {
|
||||
// 需要加密
|
||||
$body['user_name'] = $this->encryptSensitiveData($params['user_name']);
|
||||
}
|
||||
|
||||
if (isset($params['notify_url']) && !empty($params['notify_url'])) {
|
||||
$body['notify_url'] = $params['notify_url'];
|
||||
}
|
||||
|
||||
// user_recv_perception 暂不传,避免 INVALID_REQUEST:“暂不支持展示当前传入的用户收款感知”
|
||||
|
||||
$result = $this->request('POST', $url, $body);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询转账单(通过商户单号)
|
||||
* @param string $outBillNo 商户单号
|
||||
* @return array
|
||||
*/
|
||||
public function queryByOutBillNo($outBillNo)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/' . $outBillNo;
|
||||
return $this->request('GET', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询转账单(通过微信单号)
|
||||
* 参考:https://pay.weixin.qq.com/doc/v3/merchant/4012716457
|
||||
* @param string $transferBillNo 微信转账单号
|
||||
* @return array
|
||||
*/
|
||||
public function queryByTransferBillNo($transferBillNo)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/transfer-bill-no/' . $transferBillNo;
|
||||
return $this->request('GET', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销转账
|
||||
* @param string $transferBillNo 微信转账单号
|
||||
* @return array
|
||||
*/
|
||||
public function cancelTransfer($transferBillNo)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/' . $transferBillNo . '/cancel';
|
||||
return $this->request('POST', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
* @param string $method 请求方法
|
||||
* @param string $url 请求URL
|
||||
* @param array $body 请求体(POST时使用)
|
||||
* @return array
|
||||
*/
|
||||
private function request($method, $url, $body = [])
|
||||
{
|
||||
$timestamp = time();
|
||||
$nonce = $this->generateNonce();
|
||||
$bodyStr = !empty($body) ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
|
||||
|
||||
// 构建签名
|
||||
$signature = $this->buildSignature($method, $url, $timestamp, $nonce, $bodyStr);
|
||||
|
||||
// 构建请求头
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'User-Agent: WechatPay-APIv3-PHP',
|
||||
'Authorization: ' . $this->buildAuthorization($method, $url, $timestamp, $nonce, $bodyStr),
|
||||
];
|
||||
|
||||
// 如果有证书序列号,添加到请求头
|
||||
if (!empty($this->certSerialNo)) {
|
||||
$headers[] = 'Wechatpay-Serial: ' . $this->certSerialNo;
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
// 尝试写入日志,但不影响错误返回
|
||||
try {
|
||||
Log::error('微信支付请求失败: ' . $error);
|
||||
} catch (\Exception $e) {
|
||||
// 日志写入失败不影响错误返回
|
||||
}
|
||||
return ['success' => false, 'error' => ['code' => 'CURL_ERROR', 'message' => $error]];
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
return ['success' => true, 'data' => $result];
|
||||
} else {
|
||||
// 尝试写入日志,但不影响错误返回
|
||||
try {
|
||||
Log::error('微信支付API错误: HTTP ' . $httpCode . ', Response: ' . $response);
|
||||
} catch (\Exception $e) {
|
||||
// 日志写入失败不影响错误返回
|
||||
}
|
||||
return ['success' => false, 'http_code' => $httpCode, 'error' => $result];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建签名
|
||||
* @param string $method 请求方法
|
||||
* @param string $url 请求URL(不包含域名)
|
||||
* @param int $timestamp 时间戳
|
||||
* @param string $nonce 随机字符串
|
||||
* @param string $body 请求体
|
||||
* @return string
|
||||
*/
|
||||
private function buildSignature($method, $url, $timestamp, $nonce, $body)
|
||||
{
|
||||
$urlParts = parse_url($url);
|
||||
$urlPath = $urlParts['path'] . (isset($urlParts['query']) ? '?' . $urlParts['query'] : '');
|
||||
|
||||
$message = $method . "\n" .
|
||||
$urlPath . "\n" .
|
||||
$timestamp . "\n" .
|
||||
$nonce . "\n" .
|
||||
$body . "\n";
|
||||
|
||||
openssl_sign($message, $signature, $this->privateKey, OPENSSL_ALGO_SHA256);
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Authorization头
|
||||
* @param string $method
|
||||
* @param string $url
|
||||
* @param int $timestamp
|
||||
* @param string $nonce
|
||||
* @param string $body
|
||||
* @return string
|
||||
*/
|
||||
private function buildAuthorization($method, $url, $timestamp, $nonce, $body)
|
||||
{
|
||||
$urlParts = parse_url($url);
|
||||
$urlPath = $urlParts['path'] . (isset($urlParts['query']) ? '?' . $urlParts['query'] : '');
|
||||
|
||||
$signature = $this->buildSignature($method, $url, $timestamp, $nonce, $body);
|
||||
|
||||
// 获取证书序列号(从私钥中提取,这里简化处理,实际应该从证书中获取)
|
||||
$serialNo = $this->certSerialNo ?: 'YOUR_CERT_SERIAL_NO';
|
||||
|
||||
return sprintf(
|
||||
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
|
||||
$this->mchId,
|
||||
$nonce,
|
||||
$timestamp,
|
||||
$serialNo,
|
||||
$signature
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密敏感信息(使用微信支付公钥加密)
|
||||
* @param string $data 待加密数据
|
||||
* @return string base64编码的加密数据
|
||||
*/
|
||||
private function encryptSensitiveData($data)
|
||||
{
|
||||
// 注意:这里需要使用微信支付平台证书公钥加密
|
||||
// 简化实现,实际应该使用微信支付平台证书
|
||||
if (empty($this->publicKey)) {
|
||||
// 如果没有配置公钥,返回原数据(实际生产环境必须加密)
|
||||
Log::warning('未配置微信支付公钥,敏感数据未加密');
|
||||
return $data;
|
||||
}
|
||||
|
||||
$encrypted = '';
|
||||
if (openssl_public_encrypt($data, $encrypted, $this->publicKey, OPENSSL_PKCS1_OAEP_PADDING)) {
|
||||
return base64_encode($encrypted);
|
||||
}
|
||||
|
||||
Log::error('敏感数据加密失败');
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
* @param int $length 长度
|
||||
* @return string
|
||||
*/
|
||||
private function generateNonce($length = 32)
|
||||
{
|
||||
return bin2hex(random_bytes($length / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证回调签名
|
||||
* @param array $headers 请求头
|
||||
* @param string $body 请求体
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyCallback($headers, $body)
|
||||
{
|
||||
if (empty($this->publicKey)) {
|
||||
// 不记录日志,避免日志错误
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从请求头中提取签名信息(注意:HTTP头中的下划线会被转换为中划线)
|
||||
$signature = $headers['Wechatpay-Signature'] ?? $headers['wechatpay-signature'] ?? '';
|
||||
$timestamp = $headers['Wechatpay-Timestamp'] ?? $headers['wechatpay-timestamp'] ?? '';
|
||||
$nonce = $headers['Wechatpay-Nonce'] ?? $headers['wechatpay-nonce'] ?? '';
|
||||
$serial = $headers['Wechatpay-Serial'] ?? $headers['wechatpay-serial'] ?? '';
|
||||
|
||||
if (empty($signature) || empty($timestamp) || empty($nonce) || empty($serial)) {
|
||||
// 不记录日志,避免日志错误
|
||||
return false;
|
||||
}
|
||||
|
||||
// 构建验证消息(按照微信支付文档格式)
|
||||
$message = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
|
||||
|
||||
// 验证签名
|
||||
$signatureData = base64_decode($signature);
|
||||
$result = openssl_verify($message, $signatureData, $this->publicKey, OPENSSL_ALGO_SHA256);
|
||||
|
||||
if ($result === 1) {
|
||||
return true;
|
||||
} else {
|
||||
// 不记录日志,避免日志错误
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密回调通知中的resource数据
|
||||
* @param array $resource 回调通知中的resource对象
|
||||
* @return array|null 解密后的数据,失败返回null
|
||||
*/
|
||||
/**
|
||||
* 解密回调报文(按照官方文档实现)
|
||||
* 参考:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
|
||||
*
|
||||
* @param array $resource 加密的资源对象
|
||||
* @return array|null 解密后的数据
|
||||
*/
|
||||
public function decryptCallbackResource($resource)
|
||||
{
|
||||
// 调试信息
|
||||
$debug = [];
|
||||
$debug['step'] = '1.检查输入参数';
|
||||
|
||||
// 1. 检查必要参数
|
||||
if (empty($resource['ciphertext']) || empty($resource['nonce']) || !isset($resource['associated_data'])) {
|
||||
$debug['error'] = '缺少必要参数';
|
||||
$debug['has_ciphertext'] = !empty($resource['ciphertext']);
|
||||
$debug['has_nonce'] = !empty($resource['nonce']);
|
||||
$debug['has_associated_data'] = isset($resource['associated_data']);
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 2. 检查加密算法
|
||||
$algorithm = $resource['algorithm'] ?? '';
|
||||
$debug['step'] = '2.检查加密算法';
|
||||
$debug['algorithm'] = $algorithm;
|
||||
|
||||
if ($algorithm !== 'AEAD_AES_256_GCM') {
|
||||
$debug['error'] = '不支持的加密算法';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 3. 检查APIv3密钥长度(必须是32字节)
|
||||
$debug['step'] = '3.检查APIv3密钥';
|
||||
$debug['api_v3_key_length'] = strlen($this->apiV3Key);
|
||||
|
||||
if (strlen($this->apiV3Key) !== 32) {
|
||||
$debug['error'] = 'APIv3密钥长度必须为32字节';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 4. 准备解密参数(按照官方文档)
|
||||
$debug['step'] = '4.准备解密参数';
|
||||
|
||||
// Base64解码密文
|
||||
$ciphertext = base64_decode($resource['ciphertext']);
|
||||
$nonce = $resource['nonce'];
|
||||
$associatedData = $resource['associated_data'];
|
||||
|
||||
$debug['ciphertext_base64_length'] = strlen($resource['ciphertext']);
|
||||
$debug['ciphertext_decoded_length'] = strlen($ciphertext);
|
||||
$debug['nonce'] = $nonce;
|
||||
$debug['nonce_length'] = strlen($nonce);
|
||||
$debug['associated_data'] = $associatedData;
|
||||
|
||||
// 5. 检查密文长度(必须大于认证标签长度16字节)
|
||||
$AUTH_TAG_LENGTH = 16;
|
||||
if (strlen($ciphertext) <= $AUTH_TAG_LENGTH) {
|
||||
$debug['error'] = '密文长度不足,必须大于' . $AUTH_TAG_LENGTH . '字节';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 6. 分离密文和认证标签(按照官方文档)
|
||||
$debug['step'] = '6.分离密文和认证标签';
|
||||
|
||||
// 密文主体(去掉最后16字节)
|
||||
$ctext = substr($ciphertext, 0, -$AUTH_TAG_LENGTH);
|
||||
// 认证标签(最后16字节)
|
||||
$authTag = substr($ciphertext, -$AUTH_TAG_LENGTH);
|
||||
|
||||
$debug['ctext_length'] = strlen($ctext);
|
||||
$debug['authTag_length'] = strlen($authTag);
|
||||
|
||||
// 7. 使用OpenSSL解密(按照官方文档)
|
||||
$debug['step'] = '7.OpenSSL解密';
|
||||
|
||||
// PHP >= 7.1 支持 AES-256-GCM
|
||||
if (PHP_VERSION_ID < 70100) {
|
||||
$debug['error'] = 'PHP版本必须 >= 7.1';
|
||||
$debug['php_version'] = PHP_VERSION;
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
if (!in_array('aes-256-gcm', openssl_get_cipher_methods())) {
|
||||
$debug['error'] = 'OpenSSL不支持aes-256-gcm算法';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 执行解密(参数顺序按照官方文档)
|
||||
$decrypted = openssl_decrypt(
|
||||
$ctext, // 密文主体
|
||||
'aes-256-gcm', // 加密算法
|
||||
$this->apiV3Key, // API v3密钥
|
||||
OPENSSL_RAW_DATA, // 原始数据
|
||||
$nonce, // 随机串
|
||||
$authTag, // 认证标签
|
||||
$associatedData // 附加数据
|
||||
);
|
||||
|
||||
$debug['step'] = '8.检查解密结果';
|
||||
$debug['decrypt_success'] = ($decrypted !== false);
|
||||
|
||||
if ($decrypted === false) {
|
||||
$debug['error'] = 'openssl_decrypt解密失败';
|
||||
$debug['openssl_error'] = openssl_error_string() ?: '无错误信息';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
$debug['decrypted_length'] = strlen($decrypted);
|
||||
$debug['decrypted_preview'] = substr($decrypted, 0, 200);
|
||||
|
||||
// 8. 解析JSON
|
||||
$debug['step'] = '9.解析JSON';
|
||||
$data = json_decode($decrypted, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$debug['error'] = 'JSON解析失败';
|
||||
$debug['json_error'] = json_last_error_msg();
|
||||
$debug['decrypted_full'] = $decrypted;
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
$debug['success'] = true;
|
||||
$debug['data_keys'] = array_keys($data);
|
||||
|
||||
return ['_debug' => $debug, 'result' => $data];
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use Exception;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 微信商家转账到零钱封装
|
||||
*
|
||||
* 配置从 env / config 中读取,字段示例:
|
||||
* - WECHAT_MCH_ID
|
||||
* - WECHAT_APP_ID
|
||||
* - WECHAT_API_V3_KEY
|
||||
* - WECHAT_MCH_PRIVATE_KEY (绝对路径 apiclient_key.pem)
|
||||
* - WECHAT_MCH_CERT_SERIAL
|
||||
*/
|
||||
class WechatTransferService
|
||||
{
|
||||
// 微信支付API域名
|
||||
const API_BASE_URL = 'https://api.mch.weixin.qq.com';
|
||||
const API_BASE_URL_BACKUP = 'https://api2.mch.weixin.qq.com';
|
||||
|
||||
// 配置信息
|
||||
private $mchId; // 商户号
|
||||
private $appId; // 小程序/公众号AppID
|
||||
private $apiV3Key; // API v3密钥
|
||||
private $privateKey; // 商户私钥(用于签名)
|
||||
private $certSerialNo; // 证书序列号(用于加密敏感信息)
|
||||
private $publicKey; // 微信支付公钥(用于验证回调)
|
||||
|
||||
/**
|
||||
* 构造函数:直接从 .env 读取配置
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
// 核心配置来自 mbti/api/.env
|
||||
$this->mchId = env('MCH_ID', '');
|
||||
$this->appId = env('WECHAT_APPID', '');
|
||||
$this->apiV3Key = env('API_KEY', '');
|
||||
$this->certSerialNo= env('CERT_SERIAL_NO', '');
|
||||
|
||||
if (!$this->mchId || !$this->appId || !$this->apiV3Key || !$this->certSerialNo) {
|
||||
throw new Exception('微信转账配置不完整,请检查 .env 中的 MCH_ID / WECHAT_APPID / API_KEY / CERT_SERIAL_NO');
|
||||
}
|
||||
|
||||
// 私钥:支持本地路径、URL 或直接内容
|
||||
$privateKeyConf = env('PRIVATE_KEY', '');
|
||||
if ($privateKeyConf) {
|
||||
if (file_exists($privateKeyConf)) {
|
||||
$this->privateKey = file_get_contents($privateKeyConf);
|
||||
} elseif (filter_var($privateKeyConf, FILTER_VALIDATE_URL)) {
|
||||
$this->privateKey = file_get_contents($privateKeyConf);
|
||||
if ($this->privateKey === false) {
|
||||
Log::error('无法从URL加载私钥', ['url' => $privateKeyConf]);
|
||||
$this->privateKey = '';
|
||||
}
|
||||
} else {
|
||||
$this->privateKey = $privateKeyConf;
|
||||
}
|
||||
}
|
||||
if (empty($this->privateKey)) {
|
||||
throw new Exception('商户私钥加载失败,请检查 PRIVATE_KEY 配置');
|
||||
}
|
||||
|
||||
// 公钥(可选):用于后续回调验签,支持本地路径、URL 或直接内容
|
||||
$publicKeyConf = env('WECHAT_PAY_PUB_KEY', '');
|
||||
$this->publicKey = '';
|
||||
if ($publicKeyConf) {
|
||||
if (file_exists($publicKeyConf)) {
|
||||
$this->publicKey = file_get_contents($publicKeyConf);
|
||||
} elseif (filter_var($publicKeyConf, FILTER_VALIDATE_URL)) {
|
||||
$this->publicKey = file_get_contents($publicKeyConf);
|
||||
if ($this->publicKey === false) {
|
||||
Log::error('无法从URL加载公钥', ['url' => $publicKeyConf]);
|
||||
$this->publicKey = '';
|
||||
}
|
||||
} else {
|
||||
$this->publicKey = $publicKeyConf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起转账
|
||||
* @param array $params 转账参数
|
||||
* - out_bill_no: 商户单号(必填)
|
||||
* - openid: 收款用户OpenID(必填)
|
||||
* - transfer_amount: 转账金额,单位:分(必填)
|
||||
* - transfer_remark: 转账备注(必填)
|
||||
* - transfer_scene_id: 转账场景ID(必填,如:1000现金营销,1006企业报销)
|
||||
* - user_name: 收款用户姓名(选填,>=2000元必填)
|
||||
* - transfer_scene_report_infos: 转账场景报备信息(必填)
|
||||
* - notify_url: 通知地址(选填)
|
||||
* - user_recv_perception: 用户收款感知(选填)
|
||||
* @return array
|
||||
*/
|
||||
public function createTransfer($params)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills';
|
||||
|
||||
// 构建请求体
|
||||
$body = [
|
||||
'appid' => $this->appId,
|
||||
'out_bill_no' => $params['out_bill_no'],
|
||||
'transfer_scene_id' => $params['transfer_scene_id'],
|
||||
'openid' => $params['openid'],
|
||||
'transfer_amount' => intval($params['transfer_amount']),
|
||||
'transfer_remark' => $params['transfer_remark'],
|
||||
// 场景报备信息(必填):岗位类型 + 报酬说明
|
||||
'transfer_scene_report_infos' => $params['transfer_scene_report_infos'] ?? [],
|
||||
];
|
||||
|
||||
// 可选参数
|
||||
if (isset($params['user_name']) && !empty($params['user_name'])) {
|
||||
// 需要加密
|
||||
$body['user_name'] = $this->encryptSensitiveData($params['user_name']);
|
||||
}
|
||||
|
||||
if (isset($params['notify_url']) && !empty($params['notify_url'])) {
|
||||
$body['notify_url'] = $params['notify_url'];
|
||||
}
|
||||
|
||||
// user_recv_perception 暂不传,避免 INVALID_REQUEST:“暂不支持展示当前传入的用户收款感知”
|
||||
|
||||
$result = $this->request('POST', $url, $body);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询转账单(通过商户单号)
|
||||
* @param string $outBillNo 商户单号
|
||||
* @return array
|
||||
*/
|
||||
public function queryByOutBillNo($outBillNo)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/' . $outBillNo;
|
||||
return $this->request('GET', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询转账单(通过微信单号)
|
||||
* 参考:https://pay.weixin.qq.com/doc/v3/merchant/4012716457
|
||||
* @param string $transferBillNo 微信转账单号
|
||||
* @return array
|
||||
*/
|
||||
public function queryByTransferBillNo($transferBillNo)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/transfer-bill-no/' . $transferBillNo;
|
||||
return $this->request('GET', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销转账
|
||||
* @param string $transferBillNo 微信转账单号
|
||||
* @return array
|
||||
*/
|
||||
public function cancelTransfer($transferBillNo)
|
||||
{
|
||||
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/' . $transferBillNo . '/cancel';
|
||||
return $this->request('POST', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
* @param string $method 请求方法
|
||||
* @param string $url 请求URL
|
||||
* @param array $body 请求体(POST时使用)
|
||||
* @return array
|
||||
*/
|
||||
private function request($method, $url, $body = [])
|
||||
{
|
||||
$timestamp = time();
|
||||
$nonce = $this->generateNonce();
|
||||
$bodyStr = !empty($body) ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
|
||||
|
||||
// 构建签名
|
||||
$signature = $this->buildSignature($method, $url, $timestamp, $nonce, $bodyStr);
|
||||
|
||||
// 构建请求头
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'User-Agent: WechatPay-APIv3-PHP',
|
||||
'Authorization: ' . $this->buildAuthorization($method, $url, $timestamp, $nonce, $bodyStr),
|
||||
];
|
||||
|
||||
// 如果有证书序列号,添加到请求头
|
||||
if (!empty($this->certSerialNo)) {
|
||||
$headers[] = 'Wechatpay-Serial: ' . $this->certSerialNo;
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
// 尝试写入日志,但不影响错误返回
|
||||
try {
|
||||
Log::error('微信支付请求失败: ' . $error);
|
||||
} catch (\Exception $e) {
|
||||
// 日志写入失败不影响错误返回
|
||||
}
|
||||
return ['success' => false, 'error' => ['code' => 'CURL_ERROR', 'message' => $error]];
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
return ['success' => true, 'data' => $result];
|
||||
} else {
|
||||
// 尝试写入日志,但不影响错误返回
|
||||
try {
|
||||
Log::error('微信支付API错误: HTTP ' . $httpCode . ', Response: ' . $response);
|
||||
} catch (\Exception $e) {
|
||||
// 日志写入失败不影响错误返回
|
||||
}
|
||||
return ['success' => false, 'http_code' => $httpCode, 'error' => $result];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建签名
|
||||
* @param string $method 请求方法
|
||||
* @param string $url 请求URL(不包含域名)
|
||||
* @param int $timestamp 时间戳
|
||||
* @param string $nonce 随机字符串
|
||||
* @param string $body 请求体
|
||||
* @return string
|
||||
*/
|
||||
private function buildSignature($method, $url, $timestamp, $nonce, $body)
|
||||
{
|
||||
$urlParts = parse_url($url);
|
||||
$urlPath = $urlParts['path'] . (isset($urlParts['query']) ? '?' . $urlParts['query'] : '');
|
||||
|
||||
$message = $method . "\n" .
|
||||
$urlPath . "\n" .
|
||||
$timestamp . "\n" .
|
||||
$nonce . "\n" .
|
||||
$body . "\n";
|
||||
|
||||
openssl_sign($message, $signature, $this->privateKey, OPENSSL_ALGO_SHA256);
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Authorization头
|
||||
* @param string $method
|
||||
* @param string $url
|
||||
* @param int $timestamp
|
||||
* @param string $nonce
|
||||
* @param string $body
|
||||
* @return string
|
||||
*/
|
||||
private function buildAuthorization($method, $url, $timestamp, $nonce, $body)
|
||||
{
|
||||
$urlParts = parse_url($url);
|
||||
$urlPath = $urlParts['path'] . (isset($urlParts['query']) ? '?' . $urlParts['query'] : '');
|
||||
|
||||
$signature = $this->buildSignature($method, $url, $timestamp, $nonce, $body);
|
||||
|
||||
// 获取证书序列号(从私钥中提取,这里简化处理,实际应该从证书中获取)
|
||||
$serialNo = $this->certSerialNo ?: 'YOUR_CERT_SERIAL_NO';
|
||||
|
||||
return sprintf(
|
||||
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
|
||||
$this->mchId,
|
||||
$nonce,
|
||||
$timestamp,
|
||||
$serialNo,
|
||||
$signature
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密敏感信息(使用微信支付公钥加密)
|
||||
* @param string $data 待加密数据
|
||||
* @return string base64编码的加密数据
|
||||
*/
|
||||
private function encryptSensitiveData($data)
|
||||
{
|
||||
// 注意:这里需要使用微信支付平台证书公钥加密
|
||||
// 简化实现,实际应该使用微信支付平台证书
|
||||
if (empty($this->publicKey)) {
|
||||
// 如果没有配置公钥,返回原数据(实际生产环境必须加密)
|
||||
Log::warning('未配置微信支付公钥,敏感数据未加密');
|
||||
return $data;
|
||||
}
|
||||
|
||||
$encrypted = '';
|
||||
if (openssl_public_encrypt($data, $encrypted, $this->publicKey, OPENSSL_PKCS1_OAEP_PADDING)) {
|
||||
return base64_encode($encrypted);
|
||||
}
|
||||
|
||||
Log::error('敏感数据加密失败');
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
* @param int $length 长度
|
||||
* @return string
|
||||
*/
|
||||
private function generateNonce($length = 32)
|
||||
{
|
||||
return bin2hex(random_bytes($length / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证回调签名
|
||||
* @param array $headers 请求头
|
||||
* @param string $body 请求体
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyCallback($headers, $body)
|
||||
{
|
||||
if (empty($this->publicKey)) {
|
||||
// 不记录日志,避免日志错误
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从请求头中提取签名信息(注意:HTTP头中的下划线会被转换为中划线)
|
||||
$signature = $headers['Wechatpay-Signature'] ?? $headers['wechatpay-signature'] ?? '';
|
||||
$timestamp = $headers['Wechatpay-Timestamp'] ?? $headers['wechatpay-timestamp'] ?? '';
|
||||
$nonce = $headers['Wechatpay-Nonce'] ?? $headers['wechatpay-nonce'] ?? '';
|
||||
$serial = $headers['Wechatpay-Serial'] ?? $headers['wechatpay-serial'] ?? '';
|
||||
|
||||
if (empty($signature) || empty($timestamp) || empty($nonce) || empty($serial)) {
|
||||
// 不记录日志,避免日志错误
|
||||
return false;
|
||||
}
|
||||
|
||||
// 构建验证消息(按照微信支付文档格式)
|
||||
$message = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
|
||||
|
||||
// 验证签名
|
||||
$signatureData = base64_decode($signature);
|
||||
$result = openssl_verify($message, $signatureData, $this->publicKey, OPENSSL_ALGO_SHA256);
|
||||
|
||||
if ($result === 1) {
|
||||
return true;
|
||||
} else {
|
||||
// 不记录日志,避免日志错误
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密回调通知中的resource数据
|
||||
* @param array $resource 回调通知中的resource对象
|
||||
* @return array|null 解密后的数据,失败返回null
|
||||
*/
|
||||
/**
|
||||
* 解密回调报文(按照官方文档实现)
|
||||
* 参考:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
|
||||
*
|
||||
* @param array $resource 加密的资源对象
|
||||
* @return array|null 解密后的数据
|
||||
*/
|
||||
public function decryptCallbackResource($resource)
|
||||
{
|
||||
// 调试信息
|
||||
$debug = [];
|
||||
$debug['step'] = '1.检查输入参数';
|
||||
|
||||
// 1. 检查必要参数
|
||||
if (empty($resource['ciphertext']) || empty($resource['nonce']) || !isset($resource['associated_data'])) {
|
||||
$debug['error'] = '缺少必要参数';
|
||||
$debug['has_ciphertext'] = !empty($resource['ciphertext']);
|
||||
$debug['has_nonce'] = !empty($resource['nonce']);
|
||||
$debug['has_associated_data'] = isset($resource['associated_data']);
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 2. 检查加密算法
|
||||
$algorithm = $resource['algorithm'] ?? '';
|
||||
$debug['step'] = '2.检查加密算法';
|
||||
$debug['algorithm'] = $algorithm;
|
||||
|
||||
if ($algorithm !== 'AEAD_AES_256_GCM') {
|
||||
$debug['error'] = '不支持的加密算法';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 3. 检查APIv3密钥长度(必须是32字节)
|
||||
$debug['step'] = '3.检查APIv3密钥';
|
||||
$debug['api_v3_key_length'] = strlen($this->apiV3Key);
|
||||
|
||||
if (strlen($this->apiV3Key) !== 32) {
|
||||
$debug['error'] = 'APIv3密钥长度必须为32字节';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 4. 准备解密参数(按照官方文档)
|
||||
$debug['step'] = '4.准备解密参数';
|
||||
|
||||
// Base64解码密文
|
||||
$ciphertext = base64_decode($resource['ciphertext']);
|
||||
$nonce = $resource['nonce'];
|
||||
$associatedData = $resource['associated_data'];
|
||||
|
||||
$debug['ciphertext_base64_length'] = strlen($resource['ciphertext']);
|
||||
$debug['ciphertext_decoded_length'] = strlen($ciphertext);
|
||||
$debug['nonce'] = $nonce;
|
||||
$debug['nonce_length'] = strlen($nonce);
|
||||
$debug['associated_data'] = $associatedData;
|
||||
|
||||
// 5. 检查密文长度(必须大于认证标签长度16字节)
|
||||
$AUTH_TAG_LENGTH = 16;
|
||||
if (strlen($ciphertext) <= $AUTH_TAG_LENGTH) {
|
||||
$debug['error'] = '密文长度不足,必须大于' . $AUTH_TAG_LENGTH . '字节';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 6. 分离密文和认证标签(按照官方文档)
|
||||
$debug['step'] = '6.分离密文和认证标签';
|
||||
|
||||
// 密文主体(去掉最后16字节)
|
||||
$ctext = substr($ciphertext, 0, -$AUTH_TAG_LENGTH);
|
||||
// 认证标签(最后16字节)
|
||||
$authTag = substr($ciphertext, -$AUTH_TAG_LENGTH);
|
||||
|
||||
$debug['ctext_length'] = strlen($ctext);
|
||||
$debug['authTag_length'] = strlen($authTag);
|
||||
|
||||
// 7. 使用OpenSSL解密(按照官方文档)
|
||||
$debug['step'] = '7.OpenSSL解密';
|
||||
|
||||
// PHP >= 7.1 支持 AES-256-GCM
|
||||
if (PHP_VERSION_ID < 70100) {
|
||||
$debug['error'] = 'PHP版本必须 >= 7.1';
|
||||
$debug['php_version'] = PHP_VERSION;
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
if (!in_array('aes-256-gcm', openssl_get_cipher_methods())) {
|
||||
$debug['error'] = 'OpenSSL不支持aes-256-gcm算法';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
// 执行解密(参数顺序按照官方文档)
|
||||
$decrypted = openssl_decrypt(
|
||||
$ctext, // 密文主体
|
||||
'aes-256-gcm', // 加密算法
|
||||
$this->apiV3Key, // API v3密钥
|
||||
OPENSSL_RAW_DATA, // 原始数据
|
||||
$nonce, // 随机串
|
||||
$authTag, // 认证标签
|
||||
$associatedData // 附加数据
|
||||
);
|
||||
|
||||
$debug['step'] = '8.检查解密结果';
|
||||
$debug['decrypt_success'] = ($decrypted !== false);
|
||||
|
||||
if ($decrypted === false) {
|
||||
$debug['error'] = 'openssl_decrypt解密失败';
|
||||
$debug['openssl_error'] = openssl_error_string() ?: '无错误信息';
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
$debug['decrypted_length'] = strlen($decrypted);
|
||||
$debug['decrypted_preview'] = substr($decrypted, 0, 200);
|
||||
|
||||
// 8. 解析JSON
|
||||
$debug['step'] = '9.解析JSON';
|
||||
$data = json_decode($decrypted, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$debug['error'] = 'JSON解析失败';
|
||||
$debug['json_error'] = json_last_error_msg();
|
||||
$debug['decrypted_full'] = $decrypted;
|
||||
return ['_debug' => $debug, 'result' => null];
|
||||
}
|
||||
|
||||
$debug['success'] = true;
|
||||
$debug['data_keys'] = array_keys($data);
|
||||
|
||||
return ['_debug' => $debug, 'result' => $data];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,358 +1,358 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 测试用户(小程序用户)管理 - 只读列表与详情
|
||||
* 数据来源:wechat_users,测试记录来自 test_results(userId 关联 wechat_users.id)
|
||||
*/
|
||||
class AppUser extends BaseController
|
||||
{
|
||||
/**
|
||||
* 测试用户列表:分页、关键词搜索
|
||||
* GET /api/v1/admin/app-users?page=1&pageSize=20&keyword=
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
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', ''));
|
||||
|
||||
$where = [];
|
||||
if ($keyword !== '') {
|
||||
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
|
||||
// admin / enterprise_admin 均只能看本企业数据
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
// JWT 未含 enterpriseId 时回退查库(兼容旧 token)
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
// 若有企业ID:先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表)
|
||||
$profileUserIds = [];
|
||||
if ($enterpriseId) {
|
||||
$profileUserIds = Db::name('user_profile')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->column('userId');
|
||||
$profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : [];
|
||||
if (empty($profileUserIds)) {
|
||||
return paginate_response([], 0, $page, $pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// 按 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);
|
||||
// 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
|
||||
if (!empty($profileUserIds)) {
|
||||
$baseQuery->whereIn('id', $profileUserIds);
|
||||
}
|
||||
if ($where) {
|
||||
$baseQuery->where($where);
|
||||
}
|
||||
|
||||
$total = (int) $baseQuery->count();
|
||||
$list = (clone $baseQuery)
|
||||
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 为每条用户附加测试统计(test_results.userId 对应 wechat_users.id)
|
||||
$ids = array_column($list, 'id');
|
||||
$testCounts = [];
|
||||
$lastTestAt = [];
|
||||
$testTypes = []; // 每个用户最新几条测试类型,用于展示 MBTI/PDP/DISC
|
||||
$payStats = [];
|
||||
$enterpriseName = null;
|
||||
if ($enterpriseId) {
|
||||
$ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
|
||||
$enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
|
||||
}
|
||||
if (!empty($ids)) {
|
||||
// 测试统计严格按 test_results.enterpriseId 归属企业过滤
|
||||
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
|
||||
if ($enterpriseId) {
|
||||
$trBase->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$counts = (clone $trBase)
|
||||
->group('userId')
|
||||
->column('COUNT(*) as cnt', 'userId');
|
||||
$testCounts = $counts ?: [];
|
||||
|
||||
$lastRows = (clone $trBase)
|
||||
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
|
||||
->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'],
|
||||
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
|
||||
];
|
||||
}
|
||||
// 付款统计:user_profile(按当前企业过滤)
|
||||
try {
|
||||
$profilesQuery = Db::name('user_profile')
|
||||
->where('userId', 'in', $ids);
|
||||
if ($enterpriseId) {
|
||||
$profilesQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$profiles = $profilesQuery
|
||||
->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) {
|
||||
$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'] = $enterpriseName !== null ? $enterpriseName : '全部';
|
||||
$pay = $payStats[$id] ?? null;
|
||||
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
|
||||
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户详情:基本信息 + 测试记录列表
|
||||
* GET /api/v1/admin/app-users/:id
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
// admin / enterprise_admin 均只能查看本企业的用户
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
if ($enterpriseId) {
|
||||
// 使用 user_profile 判断该用户是否属于当前企业(以画像为主表)
|
||||
$has = Db::name('user_profile')
|
||||
->where('userId', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
if (!$has) {
|
||||
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,
|
||||
];
|
||||
|
||||
// 测试列表:严格按 test_results.enterpriseId 归属本企业过滤
|
||||
$testQuery = Db::name('test_results')->where('userId', $id);
|
||||
if ($enterpriseId) {
|
||||
$testQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$tests = $testQuery
|
||||
->field('id, testType, resultData, enterpriseId as testEnterpriseId, 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);
|
||||
$t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
|
||||
unset($t['testEnterpriseId']);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从测试记录中取出某类型的最近结果(result 可能是 JSON 字符串,取 type 或 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)) {
|
||||
return $targetType === 'face' ? '人脸分析' : trim($result);
|
||||
}
|
||||
|
||||
if ($targetType === 'face') {
|
||||
return '人脸分析';
|
||||
}
|
||||
|
||||
if ($targetType === 'mbti') {
|
||||
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
|
||||
}
|
||||
|
||||
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'] ?? '');
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 测试用户(小程序用户)管理 - 只读列表与详情
|
||||
* 数据来源:wechat_users,测试记录来自 test_results(userId 关联 wechat_users.id)
|
||||
*/
|
||||
class AppUser extends BaseController
|
||||
{
|
||||
/**
|
||||
* 测试用户列表:分页、关键词搜索
|
||||
* GET /api/v1/admin/app-users?page=1&pageSize=20&keyword=
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
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', ''));
|
||||
|
||||
$where = [];
|
||||
if ($keyword !== '') {
|
||||
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
|
||||
// admin / enterprise_admin 均只能看本企业数据
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
// JWT 未含 enterpriseId 时回退查库(兼容旧 token)
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
// 若有企业ID:先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表)
|
||||
$profileUserIds = [];
|
||||
if ($enterpriseId) {
|
||||
$profileUserIds = Db::name('user_profile')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->column('userId');
|
||||
$profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : [];
|
||||
if (empty($profileUserIds)) {
|
||||
return paginate_response([], 0, $page, $pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// 按 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);
|
||||
// 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
|
||||
if (!empty($profileUserIds)) {
|
||||
$baseQuery->whereIn('id', $profileUserIds);
|
||||
}
|
||||
if ($where) {
|
||||
$baseQuery->where($where);
|
||||
}
|
||||
|
||||
$total = (int) $baseQuery->count();
|
||||
$list = (clone $baseQuery)
|
||||
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 为每条用户附加测试统计(test_results.userId 对应 wechat_users.id)
|
||||
$ids = array_column($list, 'id');
|
||||
$testCounts = [];
|
||||
$lastTestAt = [];
|
||||
$testTypes = []; // 每个用户最新几条测试类型,用于展示 MBTI/PDP/DISC
|
||||
$payStats = [];
|
||||
$enterpriseName = null;
|
||||
if ($enterpriseId) {
|
||||
$ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
|
||||
$enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
|
||||
}
|
||||
if (!empty($ids)) {
|
||||
// 测试统计严格按 test_results.enterpriseId 归属企业过滤
|
||||
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
|
||||
if ($enterpriseId) {
|
||||
$trBase->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$counts = (clone $trBase)
|
||||
->group('userId')
|
||||
->column('COUNT(*) as cnt', 'userId');
|
||||
$testCounts = $counts ?: [];
|
||||
|
||||
$lastRows = (clone $trBase)
|
||||
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
|
||||
->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'],
|
||||
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
|
||||
];
|
||||
}
|
||||
// 付款统计:user_profile(按当前企业过滤)
|
||||
try {
|
||||
$profilesQuery = Db::name('user_profile')
|
||||
->where('userId', 'in', $ids);
|
||||
if ($enterpriseId) {
|
||||
$profilesQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$profiles = $profilesQuery
|
||||
->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) {
|
||||
$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'] = $enterpriseName !== null ? $enterpriseName : '全部';
|
||||
$pay = $payStats[$id] ?? null;
|
||||
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
|
||||
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户详情:基本信息 + 测试记录列表
|
||||
* GET /api/v1/admin/app-users/:id
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
// admin / enterprise_admin 均只能查看本企业的用户
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
if ($enterpriseId) {
|
||||
// 使用 user_profile 判断该用户是否属于当前企业(以画像为主表)
|
||||
$has = Db::name('user_profile')
|
||||
->where('userId', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
if (!$has) {
|
||||
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,
|
||||
];
|
||||
|
||||
// 测试列表:严格按 test_results.enterpriseId 归属本企业过滤
|
||||
$testQuery = Db::name('test_results')->where('userId', $id);
|
||||
if ($enterpriseId) {
|
||||
$testQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$tests = $testQuery
|
||||
->field('id, testType, resultData, enterpriseId as testEnterpriseId, 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);
|
||||
$t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
|
||||
unset($t['testEnterpriseId']);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从测试记录中取出某类型的最近结果(result 可能是 JSON 字符串,取 type 或 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)) {
|
||||
return $targetType === 'face' ? '人脸分析' : trim($result);
|
||||
}
|
||||
|
||||
if ($targetType === 'face') {
|
||||
return '人脸分析';
|
||||
}
|
||||
|
||||
if ($targetType === 'mbti') {
|
||||
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
|
||||
}
|
||||
|
||||
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'] ?? '');
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,265 +1,265 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\User as UserModel;
|
||||
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', 'in', ['admin', 'enterprise_admin']) // 只允许普通管理员和企业管理员登录
|
||||
->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'],
|
||||
'enterpriseId' => $user['enterpriseId'] ?? null
|
||||
];
|
||||
|
||||
$token = JwtService::generateToken($payload);
|
||||
|
||||
unset($user['password']);
|
||||
|
||||
return success([
|
||||
'token' => $token,
|
||||
'expires_in' => config('jwt.expire'),
|
||||
'user' => $user
|
||||
], '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录(新路由:/api/v1/auth/admin/login)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function adminLogin()
|
||||
{
|
||||
$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', 'in', ['admin', 'enterprise_admin']) // 只允许普通管理员和企业管理员
|
||||
->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'],
|
||||
'enterpriseId' => $user['enterpriseId'] ?? null
|
||||
];
|
||||
|
||||
$token = JwtService::generateToken($payload);
|
||||
|
||||
unset($user['password']);
|
||||
|
||||
return success([
|
||||
'token' => $token,
|
||||
'expires_in' => config('jwt.expire'),
|
||||
'user' => $user
|
||||
], '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员登录(新路由:/api/v1/auth/superadmin/login)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function superAdminLogin()
|
||||
{
|
||||
$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,
|
||||
'expires_in' => config('jwt.expire'),
|
||||
'user' => $user
|
||||
], '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录管理员信息(需要认证)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function me()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
$userModel = Db::name('users')->where('id', $user['userId'] ?? $user['user_id'] ?? null)->find();
|
||||
if (!$userModel) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
// 检查角色(必须是普通管理员或企业管理员,不包括超级管理员)
|
||||
if (!in_array($userModel['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问后台', 403);
|
||||
}
|
||||
|
||||
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']);
|
||||
} elseif ($user && isset($user['user_id'])) {
|
||||
JwtService::deleteToken($user['user_id']);
|
||||
}
|
||||
|
||||
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')
|
||||
], '刷新成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\User as UserModel;
|
||||
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', 'in', ['admin', 'enterprise_admin']) // 只允许普通管理员和企业管理员登录
|
||||
->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'],
|
||||
'enterpriseId' => $user['enterpriseId'] ?? null
|
||||
];
|
||||
|
||||
$token = JwtService::generateToken($payload);
|
||||
|
||||
unset($user['password']);
|
||||
|
||||
return success([
|
||||
'token' => $token,
|
||||
'expires_in' => config('jwt.expire'),
|
||||
'user' => $user
|
||||
], '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录(新路由:/api/v1/auth/admin/login)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function adminLogin()
|
||||
{
|
||||
$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', 'in', ['admin', 'enterprise_admin']) // 只允许普通管理员和企业管理员
|
||||
->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'],
|
||||
'enterpriseId' => $user['enterpriseId'] ?? null
|
||||
];
|
||||
|
||||
$token = JwtService::generateToken($payload);
|
||||
|
||||
unset($user['password']);
|
||||
|
||||
return success([
|
||||
'token' => $token,
|
||||
'expires_in' => config('jwt.expire'),
|
||||
'user' => $user
|
||||
], '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员登录(新路由:/api/v1/auth/superadmin/login)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function superAdminLogin()
|
||||
{
|
||||
$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,
|
||||
'expires_in' => config('jwt.expire'),
|
||||
'user' => $user
|
||||
], '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录管理员信息(需要认证)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function me()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
$userModel = Db::name('users')->where('id', $user['userId'] ?? $user['user_id'] ?? null)->find();
|
||||
if (!$userModel) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
// 检查角色(必须是普通管理员或企业管理员,不包括超级管理员)
|
||||
if (!in_array($userModel['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问后台', 403);
|
||||
}
|
||||
|
||||
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']);
|
||||
} elseif ($user && isset($user['user_id'])) {
|
||||
JwtService::deleteToken($user['user_id']);
|
||||
}
|
||||
|
||||
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')
|
||||
], '刷新成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,179 +1,179 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 数据概览控制器(普通管理员)
|
||||
*/
|
||||
class Dashboard extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取统计数据
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 验证是否为管理员
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// admin / enterprise_admin 均只统计本企业数据
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
// 企业用户 ID 集合(用于后续统计个人版测试)
|
||||
$enterpriseUserIds = [];
|
||||
if ($enterpriseId) {
|
||||
$enterpriseUserIds = Db::name('wechat_users')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->column('id');
|
||||
$enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
|
||||
}
|
||||
|
||||
// 总用户数:wechat_users.enterpriseId = 本企业
|
||||
if ($enterpriseId) {
|
||||
$totalUsers = count($enterpriseUserIds);
|
||||
} else {
|
||||
try {
|
||||
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
|
||||
} catch (\Throwable $e) {
|
||||
$totalUsers = (int) Db::name('wechat_users')->count();
|
||||
}
|
||||
}
|
||||
|
||||
// 已完成测试数:严格按 test_results.enterpriseId 归属企业统计
|
||||
if ($enterpriseId) {
|
||||
$testsCompleted = (int) Db::name('test_results')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->count();
|
||||
} else {
|
||||
$testsCompleted = (int) Db::name('test_results')->count();
|
||||
}
|
||||
|
||||
// 今日活跃用户数
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
$activeQuery = Db::name('test_results')
|
||||
->where('createdAt', '>=', $todayStart)
|
||||
->where('createdAt', '<=', $todayEnd);
|
||||
if ($enterpriseId) {
|
||||
$activeQuery->where('enterpriseId', $enterpriseId);
|
||||
$activeIds = $activeQuery->distinct(true)->column('userId');
|
||||
$activeToday = count(array_filter($activeIds));
|
||||
} else {
|
||||
$activeIds = $activeQuery->distinct(true)->column('userId');
|
||||
$activeToday = count(array_filter($activeIds));
|
||||
}
|
||||
|
||||
// 待审核(暂返回0)
|
||||
$pendingReviews = 0;
|
||||
|
||||
// 最近 14 天测试趋势
|
||||
$days = 14;
|
||||
$startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
|
||||
$trendQuery = Db::name('test_results')
|
||||
->where('createdAt', '>=', $startDate)
|
||||
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
|
||||
if ($enterpriseId) {
|
||||
$trendQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$trendRows = $trendQuery
|
||||
->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c")
|
||||
->group('d,testType')
|
||||
->order('d', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 组装为按日期汇总的数组
|
||||
$trendMap = [];
|
||||
foreach ($trendRows 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([
|
||||
'totalUsers' => $totalUsers,
|
||||
'testsCompleted' => $testsCompleted,
|
||||
'activeToday' => $activeToday,
|
||||
'pendingReviews' => $pendingReviews,
|
||||
'testTrends' => $trendData,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取统计数据失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
* @param int $timestamp
|
||||
* @return string
|
||||
*/
|
||||
private function formatTime($timestamp)
|
||||
{
|
||||
if (!$timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$diff = $now - $timestamp;
|
||||
|
||||
if ($diff < 60) {
|
||||
return '刚刚';
|
||||
} elseif ($diff < 3600) {
|
||||
return floor($diff / 60) . '分钟前';
|
||||
} elseif ($diff < 86400) {
|
||||
return floor($diff / 3600) . '小时前';
|
||||
} elseif ($diff < 604800) {
|
||||
return floor($diff / 86400) . '天前';
|
||||
} else {
|
||||
return date('Y-m-d H:i', $timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 数据概览控制器(普通管理员)
|
||||
*/
|
||||
class Dashboard extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取统计数据
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 验证是否为管理员
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// admin / enterprise_admin 均只统计本企业数据
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
// 企业用户 ID 集合(用于后续统计个人版测试)
|
||||
$enterpriseUserIds = [];
|
||||
if ($enterpriseId) {
|
||||
$enterpriseUserIds = Db::name('wechat_users')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->column('id');
|
||||
$enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
|
||||
}
|
||||
|
||||
// 总用户数:wechat_users.enterpriseId = 本企业
|
||||
if ($enterpriseId) {
|
||||
$totalUsers = count($enterpriseUserIds);
|
||||
} else {
|
||||
try {
|
||||
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
|
||||
} catch (\Throwable $e) {
|
||||
$totalUsers = (int) Db::name('wechat_users')->count();
|
||||
}
|
||||
}
|
||||
|
||||
// 已完成测试数:严格按 test_results.enterpriseId 归属企业统计
|
||||
if ($enterpriseId) {
|
||||
$testsCompleted = (int) Db::name('test_results')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->count();
|
||||
} else {
|
||||
$testsCompleted = (int) Db::name('test_results')->count();
|
||||
}
|
||||
|
||||
// 今日活跃用户数
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
$activeQuery = Db::name('test_results')
|
||||
->where('createdAt', '>=', $todayStart)
|
||||
->where('createdAt', '<=', $todayEnd);
|
||||
if ($enterpriseId) {
|
||||
$activeQuery->where('enterpriseId', $enterpriseId);
|
||||
$activeIds = $activeQuery->distinct(true)->column('userId');
|
||||
$activeToday = count(array_filter($activeIds));
|
||||
} else {
|
||||
$activeIds = $activeQuery->distinct(true)->column('userId');
|
||||
$activeToday = count(array_filter($activeIds));
|
||||
}
|
||||
|
||||
// 待审核(暂返回0)
|
||||
$pendingReviews = 0;
|
||||
|
||||
// 最近 14 天测试趋势
|
||||
$days = 14;
|
||||
$startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
|
||||
$trendQuery = Db::name('test_results')
|
||||
->where('createdAt', '>=', $startDate)
|
||||
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
|
||||
if ($enterpriseId) {
|
||||
$trendQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$trendRows = $trendQuery
|
||||
->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c")
|
||||
->group('d,testType')
|
||||
->order('d', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 组装为按日期汇总的数组
|
||||
$trendMap = [];
|
||||
foreach ($trendRows 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([
|
||||
'totalUsers' => $totalUsers,
|
||||
'testsCompleted' => $testsCompleted,
|
||||
'activeToday' => $activeToday,
|
||||
'pendingReviews' => $pendingReviews,
|
||||
'testTrends' => $trendData,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取统计数据失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
* @param int $timestamp
|
||||
* @return string
|
||||
*/
|
||||
private function formatTime($timestamp)
|
||||
{
|
||||
if (!$timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$diff = $now - $timestamp;
|
||||
|
||||
if ($diff < 60) {
|
||||
return '刚刚';
|
||||
} elseif ($diff < 3600) {
|
||||
return floor($diff / 60) . '分钟前';
|
||||
} elseif ($diff < 86400) {
|
||||
return floor($diff / 3600) . '小时前';
|
||||
} elseif ($diff < 604800) {
|
||||
return floor($diff / 86400) . '天前';
|
||||
} else {
|
||||
return date('Y-m-d H:i', $timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,204 +1,204 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\WechatService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 企业财务控制器(企业管理端)
|
||||
*/
|
||||
class Finance extends BaseController
|
||||
{
|
||||
/**
|
||||
* 财务概览
|
||||
*/
|
||||
public function overview()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name, balance')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||||
|
||||
$baseOrderQuery = Db::name('orders')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->whereIn('status', ['paid', 'completed'])
|
||||
->whereIn('productType', ['face', 'mbti', 'disc', 'pdp']);
|
||||
|
||||
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
|
||||
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
|
||||
$monthIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $monthStart)->sum('amount') ?? 0);
|
||||
$paidOrderCount = (int) ((clone $baseOrderQuery)->count());
|
||||
|
||||
$manualRechargeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'recharge')
|
||||
->whereNull('orderId')
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
$frozenCommissionFen = (int) (Db::name('commission_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('status', 'frozen')
|
||||
->sum('commissionFen') ?? 0);
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => $enterprise['name'] ?? '',
|
||||
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
|
||||
'totalIncomeFen' => $totalIncomeFen,
|
||||
'todayIncomeFen' => $todayIncomeFen,
|
||||
'monthIncomeFen' => $monthIncomeFen,
|
||||
'manualRechargeFen' => $manualRechargeFen,
|
||||
'frozenCommissionFen' => $frozenCommissionFen,
|
||||
'paidOrderCount' => $paidOrderCount,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取企业财务概览失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务流水
|
||||
*/
|
||||
public function records()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
|
||||
|
||||
$query = Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->order('createdAt', 'desc')
|
||||
->order('id', 'desc');
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
$list = (clone $query)
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$result = array_map(function ($row) {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : 0;
|
||||
$direction = $type === 'consume' ? 'out' : 'in';
|
||||
$description = (string) ($row['description'] ?? '');
|
||||
$typeLabel = $type === 'consume'
|
||||
? '佣金扣减'
|
||||
: (strpos($description, '企业余额充值') !== false ? '余额充值' : ($orderId > 0 ? '测试收入' : '余额充值'));
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'type' => $type,
|
||||
'typeLabel' => $typeLabel,
|
||||
'direction' => $direction,
|
||||
'amountFen' => (int) ($row['amount'] ?? 0),
|
||||
'balanceBeforeFen' => (int) ($row['balanceBefore'] ?? 0),
|
||||
'balanceAfterFen' => (int) ($row['balanceAfter'] ?? 0),
|
||||
'description' => $description,
|
||||
'orderId' => $orderId ?: null,
|
||||
'createdAt' => (int) ($row['createdAt'] ?? 0),
|
||||
];
|
||||
}, $list);
|
||||
|
||||
return success([
|
||||
'list' => $result,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取财务流水失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业手动充值
|
||||
*/
|
||||
public function rechargeQrcode()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$amountFen = (int) Request::param('amountFen', 0);
|
||||
if ($amountFen <= 0) {
|
||||
return error('充值金额必须大于 0', 400);
|
||||
}
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
// scene 长度要尽量短,避免超过微信限制
|
||||
$scene = 'eid=' . $enterpriseId . '&a=' . $amountFen . '&r=1';
|
||||
$page = 'pages/recharge/index';
|
||||
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
|
||||
if (isset($result['errcode'])) {
|
||||
return error('获取充值小程序码失败:' . ($result['errmsg'] ?? ''), 500);
|
||||
}
|
||||
|
||||
$binary = $result['binary'] ?? '';
|
||||
if ($binary === '') {
|
||||
return error('充值小程序码生成失败', 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => (string) ($enterprise['name'] ?? ''),
|
||||
'amountFen' => $amountFen,
|
||||
'amountYuan' => number_format($amountFen / 100, 2, '.', ''),
|
||||
'scene' => $scene,
|
||||
'page' => $page,
|
||||
'qrcode' => 'data:image/png;base64,' . base64_encode($binary),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('生成充值二维码失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前管理账号所属企业
|
||||
*/
|
||||
protected function resolveEnterpriseId(): ?int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
if ($enterpriseId > 0) {
|
||||
return $enterpriseId;
|
||||
}
|
||||
|
||||
$adminId = (int) ($user['userId'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) (Db::name('users')->where('id', $adminId)->value('enterpriseId') ?? 0) ?: null;
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\WechatService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 企业财务控制器(企业管理端)
|
||||
*/
|
||||
class Finance extends BaseController
|
||||
{
|
||||
/**
|
||||
* 财务概览
|
||||
*/
|
||||
public function overview()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name, balance')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||||
|
||||
$baseOrderQuery = Db::name('orders')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->whereIn('status', ['paid', 'completed'])
|
||||
->whereIn('productType', ['face', 'mbti', 'disc', 'pdp']);
|
||||
|
||||
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
|
||||
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
|
||||
$monthIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $monthStart)->sum('amount') ?? 0);
|
||||
$paidOrderCount = (int) ((clone $baseOrderQuery)->count());
|
||||
|
||||
$manualRechargeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'recharge')
|
||||
->whereNull('orderId')
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
$frozenCommissionFen = (int) (Db::name('commission_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('status', 'frozen')
|
||||
->sum('commissionFen') ?? 0);
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => $enterprise['name'] ?? '',
|
||||
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
|
||||
'totalIncomeFen' => $totalIncomeFen,
|
||||
'todayIncomeFen' => $todayIncomeFen,
|
||||
'monthIncomeFen' => $monthIncomeFen,
|
||||
'manualRechargeFen' => $manualRechargeFen,
|
||||
'frozenCommissionFen' => $frozenCommissionFen,
|
||||
'paidOrderCount' => $paidOrderCount,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取企业财务概览失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务流水
|
||||
*/
|
||||
public function records()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
|
||||
|
||||
$query = Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->order('createdAt', 'desc')
|
||||
->order('id', 'desc');
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
$list = (clone $query)
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$result = array_map(function ($row) {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : 0;
|
||||
$direction = $type === 'consume' ? 'out' : 'in';
|
||||
$description = (string) ($row['description'] ?? '');
|
||||
$typeLabel = $type === 'consume'
|
||||
? '佣金扣减'
|
||||
: (strpos($description, '企业余额充值') !== false ? '余额充值' : ($orderId > 0 ? '测试收入' : '余额充值'));
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'type' => $type,
|
||||
'typeLabel' => $typeLabel,
|
||||
'direction' => $direction,
|
||||
'amountFen' => (int) ($row['amount'] ?? 0),
|
||||
'balanceBeforeFen' => (int) ($row['balanceBefore'] ?? 0),
|
||||
'balanceAfterFen' => (int) ($row['balanceAfter'] ?? 0),
|
||||
'description' => $description,
|
||||
'orderId' => $orderId ?: null,
|
||||
'createdAt' => (int) ($row['createdAt'] ?? 0),
|
||||
];
|
||||
}, $list);
|
||||
|
||||
return success([
|
||||
'list' => $result,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取财务流水失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业手动充值
|
||||
*/
|
||||
public function rechargeQrcode()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$amountFen = (int) Request::param('amountFen', 0);
|
||||
if ($amountFen <= 0) {
|
||||
return error('充值金额必须大于 0', 400);
|
||||
}
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
// scene 长度要尽量短,避免超过微信限制
|
||||
$scene = 'eid=' . $enterpriseId . '&a=' . $amountFen . '&r=1';
|
||||
$page = 'pages/recharge/index';
|
||||
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
|
||||
if (isset($result['errcode'])) {
|
||||
return error('获取充值小程序码失败:' . ($result['errmsg'] ?? ''), 500);
|
||||
}
|
||||
|
||||
$binary = $result['binary'] ?? '';
|
||||
if ($binary === '') {
|
||||
return error('充值小程序码生成失败', 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => (string) ($enterprise['name'] ?? ''),
|
||||
'amountFen' => $amountFen,
|
||||
'amountYuan' => number_format($amountFen / 100, 2, '.', ''),
|
||||
'scene' => $scene,
|
||||
'page' => $page,
|
||||
'qrcode' => 'data:image/png;base64,' . base64_encode($binary),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('生成充值二维码失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前管理账号所属企业
|
||||
*/
|
||||
protected function resolveEnterpriseId(): ?int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
if ($enterpriseId > 0) {
|
||||
return $enterpriseId;
|
||||
}
|
||||
|
||||
$adminId = (int) ($user['userId'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) (Db::name('users')->where('id', $adminId)->value('enterpriseId') ?? 0) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\WechatService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 管理端 - 小程序邀请二维码(带企业参数)
|
||||
*/
|
||||
class Invite extends BaseController
|
||||
{
|
||||
/**
|
||||
* 生成专属邀请小程序码,scene 带企业 ID,扫码进入 pages/enterprise/index 可解析
|
||||
* GET /api/v1/admin/invite/qrcode
|
||||
* 可选:?enterpriseId=1 仅普通管理员指定企业时传;企业管理员用自身 enterpriseId
|
||||
*
|
||||
* 返回 data:image/png;base64,... 形式的图片地址
|
||||
*/
|
||||
public function qrcode()
|
||||
{
|
||||
$admin = $this->request->user ?? null;
|
||||
if (!$admin || !in_array($admin['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = null;
|
||||
if (($admin['role'] ?? '') === 'enterprise_admin') {
|
||||
$row = Db::name('users')->where('id', (int) ($admin['userId'] ?? 0))->find();
|
||||
$enterpriseId = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : null;
|
||||
} else {
|
||||
$enterpriseId = (int) $this->request->param('enterpriseId', 0);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('请指定企业(企业管理员无需传参,使用所属企业)', 400);
|
||||
}
|
||||
}
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('无法确定企业,仅企业管理员或指定 enterpriseId 可生成邀请码', 400);
|
||||
}
|
||||
|
||||
// 场景值:e_企业ID,小程序 onLoad(options.scene) 可解析
|
||||
$scene = 'e_' . $enterpriseId;
|
||||
$page = 'pages/enterprise/index';
|
||||
|
||||
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
|
||||
if (isset($result['errcode'])) {
|
||||
return error('获取小程序码失败:' . ($result['errmsg'] ?? ''), 500);
|
||||
}
|
||||
|
||||
$binary = $result['binary'] ?? '';
|
||||
if ($binary === '') {
|
||||
return error('小程序码生成失败', 500);
|
||||
}
|
||||
|
||||
$base64 = 'data:image/png;base64,' . base64_encode($binary);
|
||||
|
||||
return success([
|
||||
'qrcode' => $base64,
|
||||
'scene' => $scene,
|
||||
'page' => $page,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\WechatService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 管理端 - 小程序邀请二维码(带企业参数)
|
||||
*/
|
||||
class Invite extends BaseController
|
||||
{
|
||||
/**
|
||||
* 生成专属邀请小程序码,scene 带企业 ID,扫码进入 pages/enterprise/index 可解析
|
||||
* GET /api/v1/admin/invite/qrcode
|
||||
* 可选:?enterpriseId=1 仅普通管理员指定企业时传;企业管理员用自身 enterpriseId
|
||||
*
|
||||
* 返回 data:image/png;base64,... 形式的图片地址
|
||||
*/
|
||||
public function qrcode()
|
||||
{
|
||||
$admin = $this->request->user ?? null;
|
||||
if (!$admin || !in_array($admin['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = null;
|
||||
if (($admin['role'] ?? '') === 'enterprise_admin') {
|
||||
$row = Db::name('users')->where('id', (int) ($admin['userId'] ?? 0))->find();
|
||||
$enterpriseId = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : null;
|
||||
} else {
|
||||
$enterpriseId = (int) $this->request->param('enterpriseId', 0);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('请指定企业(企业管理员无需传参,使用所属企业)', 400);
|
||||
}
|
||||
}
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('无法确定企业,仅企业管理员或指定 enterpriseId 可生成邀请码', 400);
|
||||
}
|
||||
|
||||
// 场景值:e_企业ID,小程序 onLoad(options.scene) 可解析
|
||||
$scene = 'e_' . $enterpriseId;
|
||||
$page = 'pages/enterprise/index';
|
||||
|
||||
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
|
||||
if (isset($result['errcode'])) {
|
||||
return error('获取小程序码失败:' . ($result['errmsg'] ?? ''), 500);
|
||||
}
|
||||
|
||||
$binary = $result['binary'] ?? '';
|
||||
if ($binary === '') {
|
||||
return error('小程序码生成失败', 500);
|
||||
}
|
||||
|
||||
$base64 = 'data:image/png;base64,' . base64_encode($binary);
|
||||
|
||||
return success([
|
||||
'qrcode' => $base64,
|
||||
'scene' => $scene,
|
||||
'page' => $page,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 管理端订单列表(只读),包含用户信息与关联的测试数据
|
||||
*/
|
||||
class Order extends BaseController
|
||||
{
|
||||
/**
|
||||
* 订单列表:分页、关键词、状态/产品筛选;企业管理员仅本企业订单
|
||||
* GET /api/v1/admin/orders?page=1&pageSize=20&keyword=&status=&productType=
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
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', ''));
|
||||
$status = trim(Request::param('status', ''));
|
||||
$productType = trim(Request::param('productType', ''));
|
||||
|
||||
// admin / enterprise_admin 均只能看本企业订单
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
$query = Db::name('orders');
|
||||
|
||||
if ($enterpriseId !== null) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($productType !== '') {
|
||||
$query->where('productType', $productType);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
if (is_numeric($keyword)) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->whereLike('orderNo', '%' . $keyword . '%')->whereOr('userId', (int) $keyword);
|
||||
});
|
||||
} else {
|
||||
$userIdsMatch = Db::name('wechat_users')->where('nickname|phone', 'like', '%' . $keyword . '%')->column('id');
|
||||
$userIdsMatch = array_values(array_filter($userIdsMatch));
|
||||
$query->where(function ($q) use ($keyword, $userIdsMatch) {
|
||||
$q->whereLike('orderNo', '%' . $keyword . '%');
|
||||
if (!empty($userIdsMatch)) {
|
||||
$q->whereOr('userId', 'in', $userIdsMatch);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$query->order('createdAt', 'desc');
|
||||
$total = (int) (clone $query)->count();
|
||||
$list = (clone $query)->page($page, $pageSize)->select()->toArray();
|
||||
|
||||
$userIds = array_values(array_unique(array_filter(array_column($list, 'userId'))));
|
||||
$usersMap = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$orderIds = array_column($list, 'id');
|
||||
$testsByOrder = [];
|
||||
if (!empty($orderIds)) {
|
||||
$tests = Db::name('test_results')
|
||||
->where('orderId', 'in', $orderIds)
|
||||
->field('id, orderId, userId, testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($tests as $t) {
|
||||
$oid = (int) ($t['orderId'] ?? 0);
|
||||
if ($oid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($testsByOrder[$oid])) {
|
||||
$testsByOrder[$oid] = [];
|
||||
}
|
||||
$raw = $t['resultData'] ?? '';
|
||||
$resultStr = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
|
||||
$testsByOrder[$oid][] = [
|
||||
'id' => (int) $t['id'],
|
||||
'testType' => $t['testType'] ?? '',
|
||||
'resultSummary' => $this->extractResultSummary($t['testType'] ?? '', $resultStr),
|
||||
'createdAt' => isset($t['createdAt']) ? (int) $t['createdAt'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
$u = $usersMap[$uid] ?? null;
|
||||
$row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
|
||||
$row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
|
||||
$row['testData'] = $testsByOrder[$row['id']] ?? [];
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 resultData 字符串中提取简要结果(用于列表展示)
|
||||
*/
|
||||
private function extractResultSummary(string $testType, string $resultStr): string
|
||||
{
|
||||
if ($resultStr === '') {
|
||||
return '-';
|
||||
}
|
||||
$data = json_decode($resultStr, true);
|
||||
if (!is_array($data)) {
|
||||
return mb_substr($resultStr, 0, 30) . (mb_strlen($resultStr) > 30 ? '…' : '');
|
||||
}
|
||||
$type = strtolower($testType);
|
||||
|
||||
if ($type === 'mbti') {
|
||||
return (string) ($data['mbtiType'] ?? $data['type'] ?? $data['result'] ?? '');
|
||||
}
|
||||
if ($type === 'disc') {
|
||||
$desc = $data['description']['type'] ?? null;
|
||||
if (is_string($desc) && $desc !== '') {
|
||||
return $desc;
|
||||
}
|
||||
if (!empty($data['dominantType'])) {
|
||||
return (string) $data['dominantType'] . '型';
|
||||
}
|
||||
return (string) ($data['disc'] ?? '');
|
||||
}
|
||||
if ($type === 'pdp') {
|
||||
$desc = $data['description']['type'] ?? null;
|
||||
if (is_string($desc) && $desc !== '') {
|
||||
return $desc;
|
||||
}
|
||||
if (!empty($data['dominantType'])) {
|
||||
return (string) $data['dominantType'];
|
||||
}
|
||||
return (string) ($data['pdp'] ?? '');
|
||||
}
|
||||
if ($type === 'face' || $type === 'ai') {
|
||||
return '人脸分析';
|
||||
}
|
||||
|
||||
return (string) ($data['type'] ?? $data['result'] ?? '');
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 管理端订单列表(只读),包含用户信息与关联的测试数据
|
||||
*/
|
||||
class Order extends BaseController
|
||||
{
|
||||
/**
|
||||
* 订单列表:分页、关键词、状态/产品筛选;企业管理员仅本企业订单
|
||||
* GET /api/v1/admin/orders?page=1&pageSize=20&keyword=&status=&productType=
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
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', ''));
|
||||
$status = trim(Request::param('status', ''));
|
||||
$productType = trim(Request::param('productType', ''));
|
||||
|
||||
// admin / enterprise_admin 均只能看本企业订单
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
$query = Db::name('orders');
|
||||
|
||||
if ($enterpriseId !== null) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($productType !== '') {
|
||||
$query->where('productType', $productType);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
if (is_numeric($keyword)) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->whereLike('orderNo', '%' . $keyword . '%')->whereOr('userId', (int) $keyword);
|
||||
});
|
||||
} else {
|
||||
$userIdsMatch = Db::name('wechat_users')->where('nickname|phone', 'like', '%' . $keyword . '%')->column('id');
|
||||
$userIdsMatch = array_values(array_filter($userIdsMatch));
|
||||
$query->where(function ($q) use ($keyword, $userIdsMatch) {
|
||||
$q->whereLike('orderNo', '%' . $keyword . '%');
|
||||
if (!empty($userIdsMatch)) {
|
||||
$q->whereOr('userId', 'in', $userIdsMatch);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$query->order('createdAt', 'desc');
|
||||
$total = (int) (clone $query)->count();
|
||||
$list = (clone $query)->page($page, $pageSize)->select()->toArray();
|
||||
|
||||
$userIds = array_values(array_unique(array_filter(array_column($list, 'userId'))));
|
||||
$usersMap = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$orderIds = array_column($list, 'id');
|
||||
$testsByOrder = [];
|
||||
if (!empty($orderIds)) {
|
||||
$tests = Db::name('test_results')
|
||||
->where('orderId', 'in', $orderIds)
|
||||
->field('id, orderId, userId, testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($tests as $t) {
|
||||
$oid = (int) ($t['orderId'] ?? 0);
|
||||
if ($oid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($testsByOrder[$oid])) {
|
||||
$testsByOrder[$oid] = [];
|
||||
}
|
||||
$raw = $t['resultData'] ?? '';
|
||||
$resultStr = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
|
||||
$testsByOrder[$oid][] = [
|
||||
'id' => (int) $t['id'],
|
||||
'testType' => $t['testType'] ?? '',
|
||||
'resultSummary' => $this->extractResultSummary($t['testType'] ?? '', $resultStr),
|
||||
'createdAt' => isset($t['createdAt']) ? (int) $t['createdAt'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
$u = $usersMap[$uid] ?? null;
|
||||
$row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
|
||||
$row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
|
||||
$row['testData'] = $testsByOrder[$row['id']] ?? [];
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 resultData 字符串中提取简要结果(用于列表展示)
|
||||
*/
|
||||
private function extractResultSummary(string $testType, string $resultStr): string
|
||||
{
|
||||
if ($resultStr === '') {
|
||||
return '-';
|
||||
}
|
||||
$data = json_decode($resultStr, true);
|
||||
if (!is_array($data)) {
|
||||
return mb_substr($resultStr, 0, 30) . (mb_strlen($resultStr) > 30 ? '…' : '');
|
||||
}
|
||||
$type = strtolower($testType);
|
||||
|
||||
if ($type === 'mbti') {
|
||||
return (string) ($data['mbtiType'] ?? $data['type'] ?? $data['result'] ?? '');
|
||||
}
|
||||
if ($type === 'disc') {
|
||||
$desc = $data['description']['type'] ?? null;
|
||||
if (is_string($desc) && $desc !== '') {
|
||||
return $desc;
|
||||
}
|
||||
if (!empty($data['dominantType'])) {
|
||||
return (string) $data['dominantType'] . '型';
|
||||
}
|
||||
return (string) ($data['disc'] ?? '');
|
||||
}
|
||||
if ($type === 'pdp') {
|
||||
$desc = $data['description']['type'] ?? null;
|
||||
if (is_string($desc) && $desc !== '') {
|
||||
return $desc;
|
||||
}
|
||||
if (!empty($data['dominantType'])) {
|
||||
return (string) $data['dominantType'];
|
||||
}
|
||||
return (string) ($data['pdp'] ?? '');
|
||||
}
|
||||
if ($type === 'face' || $type === 'ai') {
|
||||
return '人脸分析';
|
||||
}
|
||||
|
||||
return (string) ($data['type'] ?? $data['result'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +1,185 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 定价管理控制器(普通管理员)
|
||||
* 支持同时配置个人版和企业版定价:
|
||||
* - 个人版:type=admin_personal + enterpriseId(企业管理员)或 enterpriseId=NULL(普通管理员)
|
||||
* - 企业版:type=admin_enterprise + enterpriseId(企业管理员)
|
||||
* 无自定义配置时回落到超管全局定价
|
||||
*/
|
||||
class Pricing extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取定价配置(个人版 + 企业版)
|
||||
* GET /api/v1/admin/pricing
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
// ── 个人版定价 ──
|
||||
$adminPersonalConfig = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
$superPersonalConfig = PricingConfigModel::where('type', 'personal')->whereNull('enterpriseId')->find();
|
||||
$personalConfig = $adminPersonalConfig
|
||||
? $adminPersonalConfig->config
|
||||
: ($superPersonalConfig ? $superPersonalConfig->config : []);
|
||||
$isUsingSuperAdminPersonalConfig = !$adminPersonalConfig;
|
||||
|
||||
// ── 企业版定价 ──
|
||||
$adminEnterpriseConfig = $enterpriseId
|
||||
? $this->queryConfig('admin_enterprise', $enterpriseId)
|
||||
: null;
|
||||
$superEnterpriseConfig = PricingConfigModel::where('type', 'enterprise')->whereNull('enterpriseId')->find();
|
||||
$enterpriseConfig = $adminEnterpriseConfig
|
||||
? $adminEnterpriseConfig->config
|
||||
: ($superEnterpriseConfig ? $superEnterpriseConfig->config : []);
|
||||
$isUsingSuperAdminEnterpriseConfig = !$adminEnterpriseConfig;
|
||||
|
||||
return success([
|
||||
'personal' => $personalConfig,
|
||||
'enterprise' => $enterpriseConfig,
|
||||
'isUsingSuperAdminConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminPersonalConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminEnterpriseConfig' => $isUsingSuperAdminEnterpriseConfig,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取定价配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新定价配置(个人版 + 企业版)
|
||||
* PUT /api/v1/admin/pricing
|
||||
* Body: { personalConfig: {...}, enterpriseConfig: {...} }
|
||||
* 兼容旧格式:{ config: {...} } → 仅更新个人版
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$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 = [];
|
||||
}
|
||||
|
||||
// 兼容旧版仅传 config 的情况
|
||||
$personalConfig = $input['personalConfig'] ?? $input['config'] ?? null;
|
||||
$enterpriseConfig = $input['enterpriseConfig'] ?? null;
|
||||
|
||||
if ($personalConfig === null && $enterpriseConfig === null) {
|
||||
return error('配置数据不能为空', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
$result = [];
|
||||
|
||||
// ── 保存个人版定价 ──
|
||||
if ($personalConfig !== null) {
|
||||
if (!is_array($personalConfig)) {
|
||||
return error('个人版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp'] as $field) {
|
||||
if (!array_key_exists($field, $personalConfig)) {
|
||||
return error("个人版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_personal',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $personalConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $personalConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['personal'] = $cfg->config;
|
||||
}
|
||||
|
||||
// ── 保存企业版定价(仅企业管理员)──
|
||||
if ($enterpriseConfig !== null) {
|
||||
if (!$enterpriseId) {
|
||||
return error('仅企业管理员可设置企业版定价', 403);
|
||||
}
|
||||
if (!is_array($enterpriseConfig)) {
|
||||
return error('企业版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp'] as $field) {
|
||||
if (!array_key_exists($field, $enterpriseConfig)) {
|
||||
return error("企业版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_enterprise', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_enterprise',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $enterpriseConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $enterpriseConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['enterprise'] = $cfg->config;
|
||||
}
|
||||
|
||||
return success($result, '定价配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JWT 用户信息中解析 enterpriseId
|
||||
*/
|
||||
private function resolveEnterpriseId(array $user): ?int
|
||||
{
|
||||
if (($user['role'] ?? '') !== 'enterprise_admin') {
|
||||
return null;
|
||||
}
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$eid = $adminRow['enterpriseId'] ?? null;
|
||||
return $eid ? (int) $eid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 type + enterpriseId 查询定价配置
|
||||
*/
|
||||
private function queryConfig(string $type, ?int $enterpriseId): ?PricingConfigModel
|
||||
{
|
||||
$q = PricingConfigModel::where('type', $type);
|
||||
if ($enterpriseId) {
|
||||
$q->where('enterpriseId', $enterpriseId);
|
||||
} else {
|
||||
$q->whereNull('enterpriseId');
|
||||
}
|
||||
return $q->find();
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 定价管理控制器(普通管理员)
|
||||
* 支持同时配置个人版和企业版定价:
|
||||
* - 个人版:type=admin_personal + enterpriseId(企业管理员)或 enterpriseId=NULL(普通管理员)
|
||||
* - 企业版:type=admin_enterprise + enterpriseId(企业管理员)
|
||||
* 无自定义配置时回落到超管全局定价
|
||||
*/
|
||||
class Pricing extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取定价配置(个人版 + 企业版)
|
||||
* GET /api/v1/admin/pricing
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
// ── 个人版定价 ──
|
||||
$adminPersonalConfig = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
$superPersonalConfig = PricingConfigModel::where('type', 'personal')->whereNull('enterpriseId')->find();
|
||||
$personalConfig = $adminPersonalConfig
|
||||
? $adminPersonalConfig->config
|
||||
: ($superPersonalConfig ? $superPersonalConfig->config : []);
|
||||
$isUsingSuperAdminPersonalConfig = !$adminPersonalConfig;
|
||||
|
||||
// ── 企业版定价 ──
|
||||
$adminEnterpriseConfig = $enterpriseId
|
||||
? $this->queryConfig('admin_enterprise', $enterpriseId)
|
||||
: null;
|
||||
$superEnterpriseConfig = PricingConfigModel::where('type', 'enterprise')->whereNull('enterpriseId')->find();
|
||||
$enterpriseConfig = $adminEnterpriseConfig
|
||||
? $adminEnterpriseConfig->config
|
||||
: ($superEnterpriseConfig ? $superEnterpriseConfig->config : []);
|
||||
$isUsingSuperAdminEnterpriseConfig = !$adminEnterpriseConfig;
|
||||
|
||||
return success([
|
||||
'personal' => $personalConfig,
|
||||
'enterprise' => $enterpriseConfig,
|
||||
'isUsingSuperAdminConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminPersonalConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminEnterpriseConfig' => $isUsingSuperAdminEnterpriseConfig,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取定价配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新定价配置(个人版 + 企业版)
|
||||
* PUT /api/v1/admin/pricing
|
||||
* Body: { personalConfig: {...}, enterpriseConfig: {...} }
|
||||
* 兼容旧格式:{ config: {...} } → 仅更新个人版
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$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 = [];
|
||||
}
|
||||
|
||||
// 兼容旧版仅传 config 的情况
|
||||
$personalConfig = $input['personalConfig'] ?? $input['config'] ?? null;
|
||||
$enterpriseConfig = $input['enterpriseConfig'] ?? null;
|
||||
|
||||
if ($personalConfig === null && $enterpriseConfig === null) {
|
||||
return error('配置数据不能为空', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
$result = [];
|
||||
|
||||
// ── 保存个人版定价 ──
|
||||
if ($personalConfig !== null) {
|
||||
if (!is_array($personalConfig)) {
|
||||
return error('个人版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp'] as $field) {
|
||||
if (!array_key_exists($field, $personalConfig)) {
|
||||
return error("个人版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_personal',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $personalConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $personalConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['personal'] = $cfg->config;
|
||||
}
|
||||
|
||||
// ── 保存企业版定价(仅企业管理员)──
|
||||
if ($enterpriseConfig !== null) {
|
||||
if (!$enterpriseId) {
|
||||
return error('仅企业管理员可设置企业版定价', 403);
|
||||
}
|
||||
if (!is_array($enterpriseConfig)) {
|
||||
return error('企业版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp'] as $field) {
|
||||
if (!array_key_exists($field, $enterpriseConfig)) {
|
||||
return error("企业版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_enterprise', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_enterprise',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $enterpriseConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $enterpriseConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['enterprise'] = $cfg->config;
|
||||
}
|
||||
|
||||
return success($result, '定价配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JWT 用户信息中解析 enterpriseId
|
||||
*/
|
||||
private function resolveEnterpriseId(array $user): ?int
|
||||
{
|
||||
if (($user['role'] ?? '') !== 'enterprise_admin') {
|
||||
return null;
|
||||
}
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$eid = $adminRow['enterpriseId'] ?? null;
|
||||
return $eid ? (int) $eid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 type + enterpriseId 查询定价配置
|
||||
*/
|
||||
private function queryConfig(string $type, ?int $enterpriseId): ?PricingConfigModel
|
||||
{
|
||||
$q = PricingConfigModel::where('type', $type);
|
||||
if ($enterpriseId) {
|
||||
$q->where('enterpriseId', $enterpriseId);
|
||||
} else {
|
||||
$q->whereNull('enterpriseId');
|
||||
}
|
||||
return $q->find();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,457 +1,457 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\Question as QuestionModel;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 题库管理控制器(企业管理员和普通管理员)
|
||||
* 企业管理员只能管理自己企业的题库,如果没有则使用超管题库
|
||||
*/
|
||||
class Question extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取题库列表
|
||||
* 如果企业没有自己的题库,返回超管题库
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
// 企业管理员:使用自己的企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
// 普通管理员:可以查看所有企业的题库,但优先显示超管题库
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
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 = [];
|
||||
|
||||
// 如果指定了企业ID,优先查询企业题库
|
||||
// 如果没有企业题库,则查询超管题库(enterpriseId = NULL)
|
||||
if ($enterpriseId !== null) {
|
||||
// 先检查企业是否有自己的题库
|
||||
$enterpriseQuestionCount = QuestionModel::where('enterpriseId', $enterpriseId)
|
||||
->where('type', $type ?: ['mbti', 'disc', 'pdp'])
|
||||
->count();
|
||||
|
||||
if ($enterpriseQuestionCount > 0) {
|
||||
// 使用企业题库
|
||||
$where['enterpriseId'] = $enterpriseId;
|
||||
} else {
|
||||
// 使用超管题库
|
||||
$where['enterpriseId'] = null;
|
||||
}
|
||||
} else {
|
||||
// 普通管理员查看超管题库
|
||||
$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();
|
||||
|
||||
// 标识当前使用的是企业题库还是超管题库
|
||||
$isUsingSuperAdminBank = ($where['enterpriseId'] === null);
|
||||
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'isUsingSuperAdminBank' => $isUsingSuperAdminBank,
|
||||
'enterpriseId' => $enterpriseId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
// 先查询企业题库,如果没有则查询超管题库
|
||||
$question = null;
|
||||
if ($enterpriseId !== null) {
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$question) {
|
||||
$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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以创建题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以创建题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$data['enterpriseId'] = $enterpriseId;
|
||||
|
||||
// 设置默认值
|
||||
$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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以更新题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以更新题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能更新自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以删除题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以删除题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能删除自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以导入题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以导入题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$q['enterpriseId'] = $enterpriseId;
|
||||
$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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以切换状态
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以切换题目状态', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能操作自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限操作', 404);
|
||||
}
|
||||
|
||||
$question->status = $question->status == 1 ? 0 : 1;
|
||||
$question->save();
|
||||
|
||||
return success($question->toArray(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\Question as QuestionModel;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 题库管理控制器(企业管理员和普通管理员)
|
||||
* 企业管理员只能管理自己企业的题库,如果没有则使用超管题库
|
||||
*/
|
||||
class Question extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取题库列表
|
||||
* 如果企业没有自己的题库,返回超管题库
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
// 企业管理员:使用自己的企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
// 普通管理员:可以查看所有企业的题库,但优先显示超管题库
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
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 = [];
|
||||
|
||||
// 如果指定了企业ID,优先查询企业题库
|
||||
// 如果没有企业题库,则查询超管题库(enterpriseId = NULL)
|
||||
if ($enterpriseId !== null) {
|
||||
// 先检查企业是否有自己的题库
|
||||
$enterpriseQuestionCount = QuestionModel::where('enterpriseId', $enterpriseId)
|
||||
->where('type', $type ?: ['mbti', 'disc', 'pdp'])
|
||||
->count();
|
||||
|
||||
if ($enterpriseQuestionCount > 0) {
|
||||
// 使用企业题库
|
||||
$where['enterpriseId'] = $enterpriseId;
|
||||
} else {
|
||||
// 使用超管题库
|
||||
$where['enterpriseId'] = null;
|
||||
}
|
||||
} else {
|
||||
// 普通管理员查看超管题库
|
||||
$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();
|
||||
|
||||
// 标识当前使用的是企业题库还是超管题库
|
||||
$isUsingSuperAdminBank = ($where['enterpriseId'] === null);
|
||||
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'isUsingSuperAdminBank' => $isUsingSuperAdminBank,
|
||||
'enterpriseId' => $enterpriseId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
// 先查询企业题库,如果没有则查询超管题库
|
||||
$question = null;
|
||||
if ($enterpriseId !== null) {
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$question) {
|
||||
$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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以创建题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以创建题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$data['enterpriseId'] = $enterpriseId;
|
||||
|
||||
// 设置默认值
|
||||
$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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以更新题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以更新题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能更新自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以删除题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以删除题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能删除自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以导入题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以导入题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$q['enterpriseId'] = $enterpriseId;
|
||||
$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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以切换状态
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以切换题目状态', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能操作自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限操作', 404);
|
||||
}
|
||||
|
||||
$question->status = $question->status == 1 ? 0 : 1;
|
||||
$question->save();
|
||||
|
||||
return success($question->toArray(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,418 +1,418 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 验证是否为管理员
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前管理员用户名
|
||||
$jwtUsername = $user['username'] ?? null;
|
||||
$username = 'admin';
|
||||
|
||||
if ($jwtUsername) {
|
||||
$currentUser = UserModel::where('username', $jwtUsername)
|
||||
->whereIn('role', ['admin', 'enterprise_admin'])
|
||||
->find();
|
||||
if ($currentUser) {
|
||||
$username = $currentUser->username;
|
||||
} else {
|
||||
$username = $jwtUsername;
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
'username' => $username
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用字体列表
|
||||
* GET /api/v1/admin/settings/fonts
|
||||
*/
|
||||
public function getFonts()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
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/admin/settings/poster
|
||||
* 有 enterpriseId 则读企业专属行,否则读全局(enterprise_id=0)
|
||||
*/
|
||||
public function getPosterConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
$row = self::getConfig('poster_config', $eid);
|
||||
$poster = $row ?: ['bgColor' => '#ffffff', 'bgImage' => '', 'elements' => []];
|
||||
return success(['poster' => $poster]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存海报配置
|
||||
* PUT /api/v1/admin/settings/poster
|
||||
*/
|
||||
public function updatePosterConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
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'] ?? []
|
||||
];
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
|
||||
try {
|
||||
self::saveConfig('poster_config', $data, $eid, '分销海报可视化配置');
|
||||
return success(null, '海报配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取配置:key + enterprise_id,有企业专属则取,否则降级到 enterprise_id=0
|
||||
*/
|
||||
private static function getConfig(string $key, int $enterpriseId = 0, bool $fallbackGlobal = false): ?array
|
||||
{
|
||||
$row = Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->find();
|
||||
if ($row && !empty($row['value'])) {
|
||||
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (is_array($val)) return $val;
|
||||
}
|
||||
if ($fallbackGlobal && $enterpriseId > 0) {
|
||||
$row = Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if ($row && !empty($row['value'])) {
|
||||
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (is_array($val)) return $val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置:key + enterprise_id,存在则 update,否则 insert
|
||||
*/
|
||||
private static function saveConfig(string $key, array $value, int $enterpriseId = 0, string $description = ''): void
|
||||
{
|
||||
$now = time();
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
$exists = Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->find();
|
||||
if ($exists) {
|
||||
Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->update(['value' => $json, 'updatedAt' => $now]);
|
||||
} else {
|
||||
Db::name('system_config')->insert([
|
||||
'key' => $key,
|
||||
'enterprise_id' => $enterpriseId,
|
||||
'value' => $json,
|
||||
'description' => $description,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解码 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小程序配置
|
||||
* 读取全局 text_config(enterprise_id=0)作为默认值,再用企业专属行覆盖
|
||||
* GET /api/v1/admin/settings/miniprogram
|
||||
*/
|
||||
public function getMiniprogramConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
|
||||
// 全局小程序名称(仅超管可改,此处只读)
|
||||
$miniprogramName = '神仙团队AI性格测试';
|
||||
$siteInfo = Db::name('system_config')
|
||||
->where('key', 'site_info')
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if ($siteInfo && !empty($siteInfo['value'])) {
|
||||
$val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
|
||||
$miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? $miniprogramName);
|
||||
}
|
||||
|
||||
$tcDefaults = [
|
||||
'analyzingTitle' => '正在分析中',
|
||||
'startButtonText' => '开始面相测试',
|
||||
'startButtonEnterprise' => '开始面部测试',
|
||||
'reportTitle' => '分析报告',
|
||||
'aiAnalysisText' => '智能分析',
|
||||
];
|
||||
|
||||
// 全局文案(enterprise_id=0)作为基础
|
||||
$globalTc = self::getConfig('text_config', 0);
|
||||
$textConfigData = $globalTc
|
||||
? array_merge($tcDefaults, array_intersect_key($globalTc, $tcDefaults))
|
||||
: $tcDefaults;
|
||||
|
||||
// 企业专属文案 + 小程序名称 覆盖
|
||||
if ($eid > 0) {
|
||||
$eidTc = self::getConfig('text_config', $eid);
|
||||
if ($eidTc) {
|
||||
$textConfigData = array_merge($textConfigData, array_intersect_key($eidTc, $tcDefaults));
|
||||
if (!empty($eidTc['miniprogramName'])) {
|
||||
$miniprogramName = (string) $eidTc['miniprogramName'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
'miniprogramName' => $miniprogramName,
|
||||
'textConfig' => $textConfigData,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小程序配置
|
||||
* 写入 text_config 行:enterprise_id={eid}(有企业)或 0(无企业)
|
||||
* PUT /api/v1/admin/settings/miniprogram
|
||||
*/
|
||||
public function updateMiniprogramConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$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 = [
|
||||
'miniprogramName' => Request::param('miniprogramName', ''),
|
||||
'textConfig' => Request::param('textConfig', []),
|
||||
];
|
||||
}
|
||||
|
||||
$miniprogramName = trim((string) ($input['miniprogramName'] ?? ''));
|
||||
$textConfig = $input['textConfig'] ?? [];
|
||||
|
||||
if ($miniprogramName === '') {
|
||||
return error('小程序名称不能为空', 400);
|
||||
}
|
||||
|
||||
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
|
||||
$tcDefaults = [
|
||||
'analyzingTitle' => '正在分析中',
|
||||
'startButtonText' => '开始面相测试',
|
||||
'startButtonEnterprise' => '开始面部测试',
|
||||
'reportTitle' => '分析报告',
|
||||
'aiAnalysisText' => '智能分析',
|
||||
];
|
||||
$tcData = is_array($textConfig) ? array_intersect_key($textConfig, array_flip($tcKeys)) : [];
|
||||
$tcMerge = array_merge($tcDefaults, $tcData);
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
|
||||
try {
|
||||
// eid=0:更新 site_info 的小程序名称(全局)
|
||||
if ($eid === 0) {
|
||||
$siteRow = Db::name('system_config')->where('key', 'site_info')->where('enterprise_id', 0)->find();
|
||||
$siteInfo = $siteRow && !empty($siteRow['value'])
|
||||
? (is_string($siteRow['value']) ? json_decode($siteRow['value'], true) : $siteRow['value'])
|
||||
: [];
|
||||
$siteInfo = is_array($siteInfo) ? $siteInfo : [];
|
||||
$siteInfo['miniprogramName'] = $miniprogramName;
|
||||
$siteInfo['siteName'] = $siteInfo['siteName'] ?? $miniprogramName;
|
||||
$siteInfo['updatedAt'] = time();
|
||||
self::saveConfig('site_info', $siteInfo, 0, '站点信息');
|
||||
} else {
|
||||
// 企业专属:把 miniprogramName 一并写入 text_config
|
||||
$tcMerge['miniprogramName'] = $miniprogramName;
|
||||
}
|
||||
|
||||
// 统一写到 text_config(企业行已含 miniprogramName,全局行不含)
|
||||
self::saveConfig('text_config', $tcMerge, $eid, $eid > 0 ? "小程序文案配置(企业{$eid})" : '小程序文案配置(全局)');
|
||||
return success([
|
||||
'miniprogramName' => $miniprogramName,
|
||||
'textConfig' => $tcMerge,
|
||||
], '小程序配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新管理员账户信息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function updateCredentials()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 验证是否为管理员
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
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)) {
|
||||
return error('无法获取用户信息,请重新登录', 400);
|
||||
}
|
||||
|
||||
// 直接通过username查找用户
|
||||
$userModel = UserModel::where('username', $jwtUsername)
|
||||
->whereIn('role', ['admin', 'enterprise_admin'])
|
||||
->find();
|
||||
|
||||
if (!$userModel) {
|
||||
return error('用户不存在,请检查登录状态', 404);
|
||||
}
|
||||
|
||||
// 如果要修改密码,需要验证当前密码
|
||||
if (!empty($newPassword)) {
|
||||
if (empty($currentPassword)) {
|
||||
return error('请输入当前密码', 400);
|
||||
}
|
||||
|
||||
if ($newPassword !== $confirmPassword) {
|
||||
return error('两次输入的密码不一致', 400);
|
||||
}
|
||||
|
||||
// 验证当前密码(User 模型已有原始加密密码)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
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) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 验证是否为管理员
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前管理员用户名
|
||||
$jwtUsername = $user['username'] ?? null;
|
||||
$username = 'admin';
|
||||
|
||||
if ($jwtUsername) {
|
||||
$currentUser = UserModel::where('username', $jwtUsername)
|
||||
->whereIn('role', ['admin', 'enterprise_admin'])
|
||||
->find();
|
||||
if ($currentUser) {
|
||||
$username = $currentUser->username;
|
||||
} else {
|
||||
$username = $jwtUsername;
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
'username' => $username
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用字体列表
|
||||
* GET /api/v1/admin/settings/fonts
|
||||
*/
|
||||
public function getFonts()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
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/admin/settings/poster
|
||||
* 有 enterpriseId 则读企业专属行,否则读全局(enterprise_id=0)
|
||||
*/
|
||||
public function getPosterConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
$row = self::getConfig('poster_config', $eid);
|
||||
$poster = $row ?: ['bgColor' => '#ffffff', 'bgImage' => '', 'elements' => []];
|
||||
return success(['poster' => $poster]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存海报配置
|
||||
* PUT /api/v1/admin/settings/poster
|
||||
*/
|
||||
public function updatePosterConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
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'] ?? []
|
||||
];
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
|
||||
try {
|
||||
self::saveConfig('poster_config', $data, $eid, '分销海报可视化配置');
|
||||
return success(null, '海报配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取配置:key + enterprise_id,有企业专属则取,否则降级到 enterprise_id=0
|
||||
*/
|
||||
private static function getConfig(string $key, int $enterpriseId = 0, bool $fallbackGlobal = false): ?array
|
||||
{
|
||||
$row = Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->find();
|
||||
if ($row && !empty($row['value'])) {
|
||||
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (is_array($val)) return $val;
|
||||
}
|
||||
if ($fallbackGlobal && $enterpriseId > 0) {
|
||||
$row = Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if ($row && !empty($row['value'])) {
|
||||
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (is_array($val)) return $val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置:key + enterprise_id,存在则 update,否则 insert
|
||||
*/
|
||||
private static function saveConfig(string $key, array $value, int $enterpriseId = 0, string $description = ''): void
|
||||
{
|
||||
$now = time();
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
$exists = Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->find();
|
||||
if ($exists) {
|
||||
Db::name('system_config')
|
||||
->where('key', $key)
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->update(['value' => $json, 'updatedAt' => $now]);
|
||||
} else {
|
||||
Db::name('system_config')->insert([
|
||||
'key' => $key,
|
||||
'enterprise_id' => $enterpriseId,
|
||||
'value' => $json,
|
||||
'description' => $description,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解码 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小程序配置
|
||||
* 读取全局 text_config(enterprise_id=0)作为默认值,再用企业专属行覆盖
|
||||
* GET /api/v1/admin/settings/miniprogram
|
||||
*/
|
||||
public function getMiniprogramConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
|
||||
// 全局小程序名称(仅超管可改,此处只读)
|
||||
$miniprogramName = '神仙团队AI性格测试';
|
||||
$siteInfo = Db::name('system_config')
|
||||
->where('key', 'site_info')
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if ($siteInfo && !empty($siteInfo['value'])) {
|
||||
$val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
|
||||
$miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? $miniprogramName);
|
||||
}
|
||||
|
||||
$tcDefaults = [
|
||||
'analyzingTitle' => '正在分析中',
|
||||
'startButtonText' => '开始面相测试',
|
||||
'startButtonEnterprise' => '开始面部测试',
|
||||
'reportTitle' => '分析报告',
|
||||
'aiAnalysisText' => '智能分析',
|
||||
];
|
||||
|
||||
// 全局文案(enterprise_id=0)作为基础
|
||||
$globalTc = self::getConfig('text_config', 0);
|
||||
$textConfigData = $globalTc
|
||||
? array_merge($tcDefaults, array_intersect_key($globalTc, $tcDefaults))
|
||||
: $tcDefaults;
|
||||
|
||||
// 企业专属文案 + 小程序名称 覆盖
|
||||
if ($eid > 0) {
|
||||
$eidTc = self::getConfig('text_config', $eid);
|
||||
if ($eidTc) {
|
||||
$textConfigData = array_merge($textConfigData, array_intersect_key($eidTc, $tcDefaults));
|
||||
if (!empty($eidTc['miniprogramName'])) {
|
||||
$miniprogramName = (string) $eidTc['miniprogramName'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
'miniprogramName' => $miniprogramName,
|
||||
'textConfig' => $textConfigData,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小程序配置
|
||||
* 写入 text_config 行:enterprise_id={eid}(有企业)或 0(无企业)
|
||||
* PUT /api/v1/admin/settings/miniprogram
|
||||
*/
|
||||
public function updateMiniprogramConfig()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$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 = [
|
||||
'miniprogramName' => Request::param('miniprogramName', ''),
|
||||
'textConfig' => Request::param('textConfig', []),
|
||||
];
|
||||
}
|
||||
|
||||
$miniprogramName = trim((string) ($input['miniprogramName'] ?? ''));
|
||||
$textConfig = $input['textConfig'] ?? [];
|
||||
|
||||
if ($miniprogramName === '') {
|
||||
return error('小程序名称不能为空', 400);
|
||||
}
|
||||
|
||||
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
|
||||
$tcDefaults = [
|
||||
'analyzingTitle' => '正在分析中',
|
||||
'startButtonText' => '开始面相测试',
|
||||
'startButtonEnterprise' => '开始面部测试',
|
||||
'reportTitle' => '分析报告',
|
||||
'aiAnalysisText' => '智能分析',
|
||||
];
|
||||
$tcData = is_array($textConfig) ? array_intersect_key($textConfig, array_flip($tcKeys)) : [];
|
||||
$tcMerge = array_merge($tcDefaults, $tcData);
|
||||
$eid = (int)($user['enterpriseId'] ?? 0);
|
||||
|
||||
try {
|
||||
// eid=0:更新 site_info 的小程序名称(全局)
|
||||
if ($eid === 0) {
|
||||
$siteRow = Db::name('system_config')->where('key', 'site_info')->where('enterprise_id', 0)->find();
|
||||
$siteInfo = $siteRow && !empty($siteRow['value'])
|
||||
? (is_string($siteRow['value']) ? json_decode($siteRow['value'], true) : $siteRow['value'])
|
||||
: [];
|
||||
$siteInfo = is_array($siteInfo) ? $siteInfo : [];
|
||||
$siteInfo['miniprogramName'] = $miniprogramName;
|
||||
$siteInfo['siteName'] = $siteInfo['siteName'] ?? $miniprogramName;
|
||||
$siteInfo['updatedAt'] = time();
|
||||
self::saveConfig('site_info', $siteInfo, 0, '站点信息');
|
||||
} else {
|
||||
// 企业专属:把 miniprogramName 一并写入 text_config
|
||||
$tcMerge['miniprogramName'] = $miniprogramName;
|
||||
}
|
||||
|
||||
// 统一写到 text_config(企业行已含 miniprogramName,全局行不含)
|
||||
self::saveConfig('text_config', $tcMerge, $eid, $eid > 0 ? "小程序文案配置(企业{$eid})" : '小程序文案配置(全局)');
|
||||
return success([
|
||||
'miniprogramName' => $miniprogramName,
|
||||
'textConfig' => $tcMerge,
|
||||
], '小程序配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新管理员账户信息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function updateCredentials()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 验证是否为管理员
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
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)) {
|
||||
return error('无法获取用户信息,请重新登录', 400);
|
||||
}
|
||||
|
||||
// 直接通过username查找用户
|
||||
$userModel = UserModel::where('username', $jwtUsername)
|
||||
->whereIn('role', ['admin', 'enterprise_admin'])
|
||||
->find();
|
||||
|
||||
if (!$userModel) {
|
||||
return error('用户不存在,请检查登录状态', 404);
|
||||
}
|
||||
|
||||
// 如果要修改密码,需要验证当前密码
|
||||
if (!empty($newPassword)) {
|
||||
if (empty($currentPassword)) {
|
||||
return error('请输入当前密码', 400);
|
||||
}
|
||||
|
||||
if ($newPassword !== $confirmPassword) {
|
||||
return error('两次输入的密码不一致', 400);
|
||||
}
|
||||
|
||||
// 验证当前密码(User 模型已有原始加密密码)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,347 +1,347 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\UploadFile;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 上传控制器(支持本地 & 阿里云 OSS)
|
||||
* 完全参考 BaseCrawler.php 的实现
|
||||
*/
|
||||
class Upload extends BaseController
|
||||
{
|
||||
/**
|
||||
* 上传图片(新闻封面等)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function image()
|
||||
{
|
||||
$file = Request::file('file');
|
||||
|
||||
if (!$file) {
|
||||
return error('未找到上传文件');
|
||||
}
|
||||
|
||||
// 基本校验:大小 & 类型
|
||||
$maxSize = 5 * 1024 * 1024; // 5MB
|
||||
$allowExts = ['jpg', 'jpeg', 'jfif', 'jpe', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
|
||||
$extension = strtolower($file->extension());
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
if (!in_array($extension, $allowExts, true)) {
|
||||
return error('不支持的文件类型,仅支持:jpg、jpeg、jfif、png、gif、webp、heic');
|
||||
}
|
||||
|
||||
if ($fileSize > $maxSize) {
|
||||
return error('文件过大,最大支持 5MB');
|
||||
}
|
||||
|
||||
$config = config('upload');
|
||||
$driver = $config['driver'] ?? 'oss';
|
||||
|
||||
try {
|
||||
if ($driver === 'oss') {
|
||||
$result = $this->uploadToOss($file, $config['oss'] ?? []);
|
||||
} else {
|
||||
$result = $this->uploadToLocal($file, $config['local'] ?? []);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return error('上传失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return success($result, '上传成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(简历等,可包含图片 / PDF / Word)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function file()
|
||||
{
|
||||
$file = Request::file('file');
|
||||
|
||||
if (!$file) {
|
||||
return error('未找到上传文件');
|
||||
}
|
||||
|
||||
// 基本校验:大小 & 类型(放宽为 10MB)
|
||||
$maxSize = 10 * 1024 * 1024; // 10MB
|
||||
$allowExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx'];
|
||||
$extension = strtolower($file->extension());
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
if (!in_array($extension, $allowExts, true)) {
|
||||
return error('不支持的文件类型,仅支持:jpg、jpeg、png、gif、webp、pdf、doc、docx');
|
||||
}
|
||||
|
||||
if ($fileSize > $maxSize) {
|
||||
return error('文件过大,最大支持 10MB');
|
||||
}
|
||||
|
||||
$config = config('upload');
|
||||
$driver = $config['driver'] ?? 'oss';
|
||||
|
||||
try {
|
||||
if ($driver === 'oss') {
|
||||
$result = $this->uploadToOss($file, $config['oss'] ?? []);
|
||||
} else {
|
||||
$result = $this->uploadToLocal($file, $config['local'] ?? []);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return error('上传失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return success($result, '上传成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地上传
|
||||
*/
|
||||
protected function uploadToLocal($file, array $config): array
|
||||
{
|
||||
$root = $config['root'] ?? (app()->getRootPath() . 'public/uploads');
|
||||
$driver = 'local';
|
||||
|
||||
// 先计算文件哈希,用于去重
|
||||
$hash = md5_file($file->getPathname());
|
||||
$mimeType = $this->getFileMimeSafe($file);
|
||||
$size = $file->getSize();
|
||||
$extension = strtolower($file->extension());
|
||||
|
||||
// 如果已存在相同文件(同一驱动 + hash),直接返回
|
||||
$exists = UploadFile::where(['hash' => $hash, 'driver' => $driver])->find();
|
||||
if ($exists) {
|
||||
return [
|
||||
'path' => $exists->path,
|
||||
'url' => $exists->url,
|
||||
'id' => $exists->id,
|
||||
];
|
||||
}
|
||||
|
||||
// 子目录:按 年/月 分目录,例如 2025/12
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$subDir = $year . DIRECTORY_SEPARATOR . $month;
|
||||
$dir = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $subDir;
|
||||
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException('创建上传目录失败');
|
||||
}
|
||||
|
||||
$filename = uniqid('img_', true) . '.' . $extension;
|
||||
|
||||
// 保存文件
|
||||
$file->move($dir, $filename);
|
||||
|
||||
// web 访问路径使用 / 分隔,例如 uploads/2025/12/xxx.jpg
|
||||
$relativePath = 'uploads/' . $year . '/' . $month . '/' . $filename;
|
||||
$urlPrefix = rtrim($config['url'] ?? '', '/');
|
||||
|
||||
// 如果未配置,使用 API 域名(生产环境:api.737270.com)
|
||||
if (!$urlPrefix) {
|
||||
$apiDomain = env('API_DOMAIN', 'https://api.737270.com');
|
||||
$urlPrefix = rtrim($apiDomain, '/');
|
||||
}
|
||||
|
||||
$url = $urlPrefix . '/' . $relativePath;
|
||||
|
||||
// 记录上传信息
|
||||
$record = new UploadFile();
|
||||
$record->path = $relativePath;
|
||||
$record->url = $url;
|
||||
$record->driver = $driver;
|
||||
$record->hash = $hash;
|
||||
$record->size = $size;
|
||||
$record->mimeType = $mimeType;
|
||||
$record->extension = $extension;
|
||||
$record->save();
|
||||
|
||||
return [
|
||||
'path' => $relativePath,
|
||||
'url' => $url,
|
||||
'id' => $record->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传到阿里云 OSS
|
||||
* 完全参考 BaseCrawler.php 的实现
|
||||
*/
|
||||
protected function uploadToOss($file, array $config): array
|
||||
{
|
||||
if (!class_exists('\OSS\OssClient')) {
|
||||
throw new \RuntimeException('未安装 Aliyun OSS SDK,请先执行:composer require aliyuncs/oss-sdk-php');
|
||||
}
|
||||
|
||||
// 参考 database.php 的配置读取方式,直接从 config 读取(config 已通过 env() 读取 .env)
|
||||
$accessKeyId = $config['access_key_id'] ?? '';
|
||||
$accessKeySecret = $config['access_key_secret'] ?? '';
|
||||
$endpoint = $config['endpoint'] ?? '';
|
||||
$bucket = $config['bucket'] ?? '';
|
||||
$prefix = trim($config['prefix'] ?? 'mbti', '/');
|
||||
$baseUrl = rtrim($config['url'] ?? '', '/');
|
||||
|
||||
// 如果未配置 OSS_URL,自动使用 OSS 自带域名:https://{bucket}.{endpoint}
|
||||
if (empty($baseUrl) && !empty($bucket) && !empty($endpoint)) {
|
||||
// 移除 endpoint 中的协议前缀(如果有)
|
||||
$endpointClean = preg_replace('#^https?://#', '', $endpoint);
|
||||
$baseUrl = 'https://' . $bucket . '.' . $endpointClean;
|
||||
}
|
||||
|
||||
if (empty($accessKeyId) || empty($accessKeySecret) || empty($endpoint) || empty($bucket)) {
|
||||
throw new \RuntimeException('OSS 配置不完整,请在 .env 文件中配置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_ENDPOINT、OSS_BUCKET(OSS_URL 可选,不配置则使用 OSS 自带域名)');
|
||||
}
|
||||
|
||||
$driver = 'oss';
|
||||
$extension = strtolower($file->extension());
|
||||
$hash = md5_file($file->getPathname());
|
||||
$size = $file->getSize();
|
||||
$mimeType = $this->getFileMimeSafe($file);
|
||||
|
||||
// 先查重(完全参考 BaseCrawler.php)
|
||||
try {
|
||||
$exists = UploadFile::where(['hash' => $hash, 'driver' => $driver])->find();
|
||||
if ($exists) {
|
||||
$tempFilePath = $file->getPathname();
|
||||
if (file_exists($tempFilePath)) {
|
||||
@unlink($tempFilePath);
|
||||
}
|
||||
$latestUrl = $baseUrl . '/' . ltrim($exists->path, '/');
|
||||
if ($exists->url !== $latestUrl) {
|
||||
$exists->url = $latestUrl;
|
||||
$exists->save();
|
||||
}
|
||||
return [
|
||||
'path' => $exists->path,
|
||||
'url' => $latestUrl,
|
||||
'id' => $exists->id,
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 查重失败不影响上传流程
|
||||
}
|
||||
|
||||
$object = $prefix . '/' . date('Ymd') . '/' . uniqid('img_', true) . '.' . $extension;
|
||||
|
||||
// 保存并临时清除代理设置(完全参考 BaseCrawler.php)
|
||||
// 如果 putenv 函数可用则使用,否则跳过代理处理
|
||||
$putenvAvailable = function_exists('putenv');
|
||||
$originalHttpProxy = false;
|
||||
$originalHttpsProxy = false;
|
||||
$originalHttpProxyVar = false;
|
||||
$originalHttpsProxyVar = false;
|
||||
|
||||
if ($putenvAvailable) {
|
||||
$originalHttpProxy = getenv('HTTP_PROXY');
|
||||
$originalHttpsProxy = getenv('HTTPS_PROXY');
|
||||
$originalHttpProxyVar = getenv('http_proxy');
|
||||
$originalHttpsProxyVar = getenv('https_proxy');
|
||||
|
||||
\putenv('HTTP_PROXY=');
|
||||
\putenv('HTTPS_PROXY=');
|
||||
\putenv('http_proxy=');
|
||||
\putenv('https_proxy=');
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new \OSS\OssClient($accessKeyId, $accessKeySecret, $endpoint);
|
||||
|
||||
if (!$client->doesBucketExist($bucket)) {
|
||||
throw new \RuntimeException("OSS Bucket '{$bucket}' 不存在或无法访问");
|
||||
}
|
||||
|
||||
$client->uploadFile($bucket, $object, $file->getPathname());
|
||||
} finally {
|
||||
// 恢复代理设置(完全参考 BaseCrawler.php)
|
||||
if ($putenvAvailable) {
|
||||
if ($originalHttpProxy !== false) {
|
||||
\putenv('HTTP_PROXY=' . $originalHttpProxy);
|
||||
} else {
|
||||
\putenv('HTTP_PROXY');
|
||||
}
|
||||
if ($originalHttpsProxy !== false) {
|
||||
\putenv('HTTPS_PROXY=' . $originalHttpsProxy);
|
||||
} else {
|
||||
\putenv('HTTPS_PROXY');
|
||||
}
|
||||
if ($originalHttpProxyVar !== false) {
|
||||
\putenv('http_proxy=' . $originalHttpProxyVar);
|
||||
} else {
|
||||
\putenv('http_proxy');
|
||||
}
|
||||
if ($originalHttpsProxyVar !== false) {
|
||||
\putenv('https_proxy=' . $originalHttpsProxyVar);
|
||||
} else {
|
||||
\putenv('https_proxy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传成功后,删除本地临时文件(完全参考 BaseCrawler.php)
|
||||
$tempFilePath = $file->getPathname();
|
||||
if (file_exists($tempFilePath)) {
|
||||
@unlink($tempFilePath);
|
||||
}
|
||||
|
||||
// 生成文件访问 URL(完全参考 BaseCrawler.php)
|
||||
$ossUrl = $baseUrl . '/' . ltrim($object, '/');
|
||||
|
||||
// 记录上传信息(完全参考 BaseCrawler.php,try-catch 包裹)
|
||||
try {
|
||||
$record = new UploadFile();
|
||||
$record->path = $object;
|
||||
$record->url = $ossUrl;
|
||||
$record->driver = $driver;
|
||||
$record->hash = $hash;
|
||||
$record->size = $size;
|
||||
$record->mimeType = $mimeType;
|
||||
$record->extension = $extension;
|
||||
$record->save();
|
||||
} catch (\Exception $e) {
|
||||
// 记录失败不影响返回 URL
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $object,
|
||||
'url' => $ossUrl,
|
||||
'id' => $record->id ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取文件 MIME:服务器未开 fileinfo 扩展时用扩展名推断,避免 finfo_open() 报错
|
||||
*/
|
||||
protected function getFileMimeSafe($file): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
try {
|
||||
return $file->getMime() ?: $this->mimeByExtension($file->extension());
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mimeByExtension($file->extension());
|
||||
}
|
||||
}
|
||||
return $this->mimeByExtension($file->extension());
|
||||
}
|
||||
|
||||
private function mimeByExtension(string $ext): string
|
||||
{
|
||||
$ext = strtolower($ext ?: '');
|
||||
$map = [
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'jfif' => 'image/jpeg',
|
||||
'jpe' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'bmp' => 'image/bmp',
|
||||
'heic' => 'image/heic',
|
||||
'heif' => 'image/heif',
|
||||
'pdf' => 'application/pdf',
|
||||
'doc' => 'application/msword',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
return $map[$ext] ?? 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\UploadFile;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 上传控制器(支持本地 & 阿里云 OSS)
|
||||
* 完全参考 BaseCrawler.php 的实现
|
||||
*/
|
||||
class Upload extends BaseController
|
||||
{
|
||||
/**
|
||||
* 上传图片(新闻封面等)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function image()
|
||||
{
|
||||
$file = Request::file('file');
|
||||
|
||||
if (!$file) {
|
||||
return error('未找到上传文件');
|
||||
}
|
||||
|
||||
// 基本校验:大小 & 类型
|
||||
$maxSize = 5 * 1024 * 1024; // 5MB
|
||||
$allowExts = ['jpg', 'jpeg', 'jfif', 'jpe', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
|
||||
$extension = strtolower($file->extension());
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
if (!in_array($extension, $allowExts, true)) {
|
||||
return error('不支持的文件类型,仅支持:jpg、jpeg、jfif、png、gif、webp、heic');
|
||||
}
|
||||
|
||||
if ($fileSize > $maxSize) {
|
||||
return error('文件过大,最大支持 5MB');
|
||||
}
|
||||
|
||||
$config = config('upload');
|
||||
$driver = $config['driver'] ?? 'oss';
|
||||
|
||||
try {
|
||||
if ($driver === 'oss') {
|
||||
$result = $this->uploadToOss($file, $config['oss'] ?? []);
|
||||
} else {
|
||||
$result = $this->uploadToLocal($file, $config['local'] ?? []);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return error('上传失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return success($result, '上传成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(简历等,可包含图片 / PDF / Word)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function file()
|
||||
{
|
||||
$file = Request::file('file');
|
||||
|
||||
if (!$file) {
|
||||
return error('未找到上传文件');
|
||||
}
|
||||
|
||||
// 基本校验:大小 & 类型(放宽为 10MB)
|
||||
$maxSize = 10 * 1024 * 1024; // 10MB
|
||||
$allowExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx'];
|
||||
$extension = strtolower($file->extension());
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
if (!in_array($extension, $allowExts, true)) {
|
||||
return error('不支持的文件类型,仅支持:jpg、jpeg、png、gif、webp、pdf、doc、docx');
|
||||
}
|
||||
|
||||
if ($fileSize > $maxSize) {
|
||||
return error('文件过大,最大支持 10MB');
|
||||
}
|
||||
|
||||
$config = config('upload');
|
||||
$driver = $config['driver'] ?? 'oss';
|
||||
|
||||
try {
|
||||
if ($driver === 'oss') {
|
||||
$result = $this->uploadToOss($file, $config['oss'] ?? []);
|
||||
} else {
|
||||
$result = $this->uploadToLocal($file, $config['local'] ?? []);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return error('上传失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return success($result, '上传成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地上传
|
||||
*/
|
||||
protected function uploadToLocal($file, array $config): array
|
||||
{
|
||||
$root = $config['root'] ?? (app()->getRootPath() . 'public/uploads');
|
||||
$driver = 'local';
|
||||
|
||||
// 先计算文件哈希,用于去重
|
||||
$hash = md5_file($file->getPathname());
|
||||
$mimeType = $this->getFileMimeSafe($file);
|
||||
$size = $file->getSize();
|
||||
$extension = strtolower($file->extension());
|
||||
|
||||
// 如果已存在相同文件(同一驱动 + hash),直接返回
|
||||
$exists = UploadFile::where(['hash' => $hash, 'driver' => $driver])->find();
|
||||
if ($exists) {
|
||||
return [
|
||||
'path' => $exists->path,
|
||||
'url' => $exists->url,
|
||||
'id' => $exists->id,
|
||||
];
|
||||
}
|
||||
|
||||
// 子目录:按 年/月 分目录,例如 2025/12
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$subDir = $year . DIRECTORY_SEPARATOR . $month;
|
||||
$dir = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $subDir;
|
||||
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException('创建上传目录失败');
|
||||
}
|
||||
|
||||
$filename = uniqid('img_', true) . '.' . $extension;
|
||||
|
||||
// 保存文件
|
||||
$file->move($dir, $filename);
|
||||
|
||||
// web 访问路径使用 / 分隔,例如 uploads/2025/12/xxx.jpg
|
||||
$relativePath = 'uploads/' . $year . '/' . $month . '/' . $filename;
|
||||
$urlPrefix = rtrim($config['url'] ?? '', '/');
|
||||
|
||||
// 如果未配置,使用 API 域名(生产环境:api.737270.com)
|
||||
if (!$urlPrefix) {
|
||||
$apiDomain = env('API_DOMAIN', 'https://api.737270.com');
|
||||
$urlPrefix = rtrim($apiDomain, '/');
|
||||
}
|
||||
|
||||
$url = $urlPrefix . '/' . $relativePath;
|
||||
|
||||
// 记录上传信息
|
||||
$record = new UploadFile();
|
||||
$record->path = $relativePath;
|
||||
$record->url = $url;
|
||||
$record->driver = $driver;
|
||||
$record->hash = $hash;
|
||||
$record->size = $size;
|
||||
$record->mimeType = $mimeType;
|
||||
$record->extension = $extension;
|
||||
$record->save();
|
||||
|
||||
return [
|
||||
'path' => $relativePath,
|
||||
'url' => $url,
|
||||
'id' => $record->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传到阿里云 OSS
|
||||
* 完全参考 BaseCrawler.php 的实现
|
||||
*/
|
||||
protected function uploadToOss($file, array $config): array
|
||||
{
|
||||
if (!class_exists('\OSS\OssClient')) {
|
||||
throw new \RuntimeException('未安装 Aliyun OSS SDK,请先执行:composer require aliyuncs/oss-sdk-php');
|
||||
}
|
||||
|
||||
// 参考 database.php 的配置读取方式,直接从 config 读取(config 已通过 env() 读取 .env)
|
||||
$accessKeyId = $config['access_key_id'] ?? '';
|
||||
$accessKeySecret = $config['access_key_secret'] ?? '';
|
||||
$endpoint = $config['endpoint'] ?? '';
|
||||
$bucket = $config['bucket'] ?? '';
|
||||
$prefix = trim($config['prefix'] ?? 'mbti', '/');
|
||||
$baseUrl = rtrim($config['url'] ?? '', '/');
|
||||
|
||||
// 如果未配置 OSS_URL,自动使用 OSS 自带域名:https://{bucket}.{endpoint}
|
||||
if (empty($baseUrl) && !empty($bucket) && !empty($endpoint)) {
|
||||
// 移除 endpoint 中的协议前缀(如果有)
|
||||
$endpointClean = preg_replace('#^https?://#', '', $endpoint);
|
||||
$baseUrl = 'https://' . $bucket . '.' . $endpointClean;
|
||||
}
|
||||
|
||||
if (empty($accessKeyId) || empty($accessKeySecret) || empty($endpoint) || empty($bucket)) {
|
||||
throw new \RuntimeException('OSS 配置不完整,请在 .env 文件中配置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_ENDPOINT、OSS_BUCKET(OSS_URL 可选,不配置则使用 OSS 自带域名)');
|
||||
}
|
||||
|
||||
$driver = 'oss';
|
||||
$extension = strtolower($file->extension());
|
||||
$hash = md5_file($file->getPathname());
|
||||
$size = $file->getSize();
|
||||
$mimeType = $this->getFileMimeSafe($file);
|
||||
|
||||
// 先查重(完全参考 BaseCrawler.php)
|
||||
try {
|
||||
$exists = UploadFile::where(['hash' => $hash, 'driver' => $driver])->find();
|
||||
if ($exists) {
|
||||
$tempFilePath = $file->getPathname();
|
||||
if (file_exists($tempFilePath)) {
|
||||
@unlink($tempFilePath);
|
||||
}
|
||||
$latestUrl = $baseUrl . '/' . ltrim($exists->path, '/');
|
||||
if ($exists->url !== $latestUrl) {
|
||||
$exists->url = $latestUrl;
|
||||
$exists->save();
|
||||
}
|
||||
return [
|
||||
'path' => $exists->path,
|
||||
'url' => $latestUrl,
|
||||
'id' => $exists->id,
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 查重失败不影响上传流程
|
||||
}
|
||||
|
||||
$object = $prefix . '/' . date('Ymd') . '/' . uniqid('img_', true) . '.' . $extension;
|
||||
|
||||
// 保存并临时清除代理设置(完全参考 BaseCrawler.php)
|
||||
// 如果 putenv 函数可用则使用,否则跳过代理处理
|
||||
$putenvAvailable = function_exists('putenv');
|
||||
$originalHttpProxy = false;
|
||||
$originalHttpsProxy = false;
|
||||
$originalHttpProxyVar = false;
|
||||
$originalHttpsProxyVar = false;
|
||||
|
||||
if ($putenvAvailable) {
|
||||
$originalHttpProxy = getenv('HTTP_PROXY');
|
||||
$originalHttpsProxy = getenv('HTTPS_PROXY');
|
||||
$originalHttpProxyVar = getenv('http_proxy');
|
||||
$originalHttpsProxyVar = getenv('https_proxy');
|
||||
|
||||
\putenv('HTTP_PROXY=');
|
||||
\putenv('HTTPS_PROXY=');
|
||||
\putenv('http_proxy=');
|
||||
\putenv('https_proxy=');
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new \OSS\OssClient($accessKeyId, $accessKeySecret, $endpoint);
|
||||
|
||||
if (!$client->doesBucketExist($bucket)) {
|
||||
throw new \RuntimeException("OSS Bucket '{$bucket}' 不存在或无法访问");
|
||||
}
|
||||
|
||||
$client->uploadFile($bucket, $object, $file->getPathname());
|
||||
} finally {
|
||||
// 恢复代理设置(完全参考 BaseCrawler.php)
|
||||
if ($putenvAvailable) {
|
||||
if ($originalHttpProxy !== false) {
|
||||
\putenv('HTTP_PROXY=' . $originalHttpProxy);
|
||||
} else {
|
||||
\putenv('HTTP_PROXY');
|
||||
}
|
||||
if ($originalHttpsProxy !== false) {
|
||||
\putenv('HTTPS_PROXY=' . $originalHttpsProxy);
|
||||
} else {
|
||||
\putenv('HTTPS_PROXY');
|
||||
}
|
||||
if ($originalHttpProxyVar !== false) {
|
||||
\putenv('http_proxy=' . $originalHttpProxyVar);
|
||||
} else {
|
||||
\putenv('http_proxy');
|
||||
}
|
||||
if ($originalHttpsProxyVar !== false) {
|
||||
\putenv('https_proxy=' . $originalHttpsProxyVar);
|
||||
} else {
|
||||
\putenv('https_proxy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传成功后,删除本地临时文件(完全参考 BaseCrawler.php)
|
||||
$tempFilePath = $file->getPathname();
|
||||
if (file_exists($tempFilePath)) {
|
||||
@unlink($tempFilePath);
|
||||
}
|
||||
|
||||
// 生成文件访问 URL(完全参考 BaseCrawler.php)
|
||||
$ossUrl = $baseUrl . '/' . ltrim($object, '/');
|
||||
|
||||
// 记录上传信息(完全参考 BaseCrawler.php,try-catch 包裹)
|
||||
try {
|
||||
$record = new UploadFile();
|
||||
$record->path = $object;
|
||||
$record->url = $ossUrl;
|
||||
$record->driver = $driver;
|
||||
$record->hash = $hash;
|
||||
$record->size = $size;
|
||||
$record->mimeType = $mimeType;
|
||||
$record->extension = $extension;
|
||||
$record->save();
|
||||
} catch (\Exception $e) {
|
||||
// 记录失败不影响返回 URL
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $object,
|
||||
'url' => $ossUrl,
|
||||
'id' => $record->id ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取文件 MIME:服务器未开 fileinfo 扩展时用扩展名推断,避免 finfo_open() 报错
|
||||
*/
|
||||
protected function getFileMimeSafe($file): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
try {
|
||||
return $file->getMime() ?: $this->mimeByExtension($file->extension());
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mimeByExtension($file->extension());
|
||||
}
|
||||
}
|
||||
return $this->mimeByExtension($file->extension());
|
||||
}
|
||||
|
||||
private function mimeByExtension(string $ext): string
|
||||
{
|
||||
$ext = strtolower($ext ?: '');
|
||||
$map = [
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'jfif' => 'image/jpeg',
|
||||
'jpe' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'bmp' => 'image/bmp',
|
||||
'heic' => 'image/heic',
|
||||
'heif' => 'image/heif',
|
||||
'pdf' => 'application/pdf',
|
||||
'doc' => 'application/msword',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
return $map[$ext] ?? 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +1,206 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\User as UserModel;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 后台用户管理控制器
|
||||
*/
|
||||
class User extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取用户列表(普通管理员和企业管理员)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
$page = Request::param('page', 1);
|
||||
$pageSize = Request::param('pageSize', 10);
|
||||
$keyword = Request::param('keyword', '');
|
||||
$role = Request::param('role', '');
|
||||
$status = Request::param('status', '');
|
||||
|
||||
$where = [];
|
||||
if ($keyword) {
|
||||
$where[] = ['username|email|phone', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
if ($role) {
|
||||
$where['role'] = $role;
|
||||
}
|
||||
if ($status !== '') {
|
||||
$where['status'] = $status;
|
||||
}
|
||||
|
||||
// 根据角色过滤
|
||||
if (($user['role'] ?? '') === 'enterprise_admin') {
|
||||
// 企业管理员只能查看自己企业的用户
|
||||
$where['enterpriseId'] = $user['enterpriseId'] ?? null;
|
||||
} else {
|
||||
// 普通管理员可以查看所有管理员(不包括超级管理员)
|
||||
$where[] = ['role', 'in', ['admin', 'enterprise_admin']];
|
||||
}
|
||||
|
||||
$list = UserModel::where($where)
|
||||
->order('createdAt', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select();
|
||||
|
||||
$total = UserModel::where($where)->count();
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password']);
|
||||
|
||||
return success($userData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$data = Request::post();
|
||||
|
||||
// 检查用户名是否已存在
|
||||
if (UserModel::where('username', $data['username'])->find()) {
|
||||
return error('用户名已存在');
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if (!empty($data['email']) && UserModel::where('email', $data['email'])->find()) {
|
||||
return error('邮箱已被注册');
|
||||
}
|
||||
|
||||
// 验证角色(只允许管理员角色)
|
||||
$allowedRoles = ['admin', 'enterprise_admin', 'superadmin'];
|
||||
$role = $data['role'] ?? 'admin';
|
||||
if (!in_array($role, $allowedRoles)) {
|
||||
return error('角色必须是管理员类型', 400);
|
||||
}
|
||||
|
||||
$user = new UserModel();
|
||||
$user->username = $data['username'];
|
||||
$user->password = $data['password'] ?? '123456'; // 默认密码
|
||||
$user->email = $data['email'] ?? '';
|
||||
$user->phone = $data['phone'] ?? '';
|
||||
$user->role = $role;
|
||||
$user->enterpriseId = $data['enterpriseId'] ?? $data['enterprise_id'] ?? null;
|
||||
$user->status = $data['status'] ?? 1;
|
||||
$user->save();
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password']);
|
||||
|
||||
return success($userData, '创建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$data = Request::put();
|
||||
|
||||
// 如果更新用户名,检查是否重复
|
||||
if (isset($data['username']) && $data['username'] != $user->username) {
|
||||
if (UserModel::where('username', $data['username'])->find()) {
|
||||
return error('用户名已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果更新邮箱,检查是否重复
|
||||
if (isset($data['email']) && $data['email'] != $user->email) {
|
||||
if (!empty($data['email']) && UserModel::where('email', $data['email'])->find()) {
|
||||
return error('邮箱已被注册');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果更新密码
|
||||
if (isset($data['password'])) {
|
||||
$user->password = $data['password']; // 会自动加密
|
||||
}
|
||||
|
||||
$user->save($data);
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password']);
|
||||
|
||||
return success($userData, '更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(软删除)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
// 检查是否已删除
|
||||
if ($user->deletedAt) {
|
||||
return error('用户已被删除', 400);
|
||||
}
|
||||
|
||||
// 不能删除自己
|
||||
$currentUser = $this->request->user ?? null;
|
||||
if ($currentUser && ($currentUser['userId'] ?? $currentUser['user_id'] ?? null) == $id) {
|
||||
return error('不能删除自己', 400);
|
||||
}
|
||||
|
||||
// 软删除(设置 deletedAt 时间戳)
|
||||
$user->delete();
|
||||
return success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用用户
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$user->status = $user->status == 1 ? 0 : 1;
|
||||
$user->save();
|
||||
|
||||
return success($user, '操作成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\User as UserModel;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 后台用户管理控制器
|
||||
*/
|
||||
class User extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取用户列表(普通管理员和企业管理员)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
$page = Request::param('page', 1);
|
||||
$pageSize = Request::param('pageSize', 10);
|
||||
$keyword = Request::param('keyword', '');
|
||||
$role = Request::param('role', '');
|
||||
$status = Request::param('status', '');
|
||||
|
||||
$where = [];
|
||||
if ($keyword) {
|
||||
$where[] = ['username|email|phone', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
if ($role) {
|
||||
$where['role'] = $role;
|
||||
}
|
||||
if ($status !== '') {
|
||||
$where['status'] = $status;
|
||||
}
|
||||
|
||||
// 根据角色过滤
|
||||
if (($user['role'] ?? '') === 'enterprise_admin') {
|
||||
// 企业管理员只能查看自己企业的用户
|
||||
$where['enterpriseId'] = $user['enterpriseId'] ?? null;
|
||||
} else {
|
||||
// 普通管理员可以查看所有管理员(不包括超级管理员)
|
||||
$where[] = ['role', 'in', ['admin', 'enterprise_admin']];
|
||||
}
|
||||
|
||||
$list = UserModel::where($where)
|
||||
->order('createdAt', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select();
|
||||
|
||||
$total = UserModel::where($where)->count();
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password']);
|
||||
|
||||
return success($userData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$data = Request::post();
|
||||
|
||||
// 检查用户名是否已存在
|
||||
if (UserModel::where('username', $data['username'])->find()) {
|
||||
return error('用户名已存在');
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if (!empty($data['email']) && UserModel::where('email', $data['email'])->find()) {
|
||||
return error('邮箱已被注册');
|
||||
}
|
||||
|
||||
// 验证角色(只允许管理员角色)
|
||||
$allowedRoles = ['admin', 'enterprise_admin', 'superadmin'];
|
||||
$role = $data['role'] ?? 'admin';
|
||||
if (!in_array($role, $allowedRoles)) {
|
||||
return error('角色必须是管理员类型', 400);
|
||||
}
|
||||
|
||||
$user = new UserModel();
|
||||
$user->username = $data['username'];
|
||||
$user->password = $data['password'] ?? '123456'; // 默认密码
|
||||
$user->email = $data['email'] ?? '';
|
||||
$user->phone = $data['phone'] ?? '';
|
||||
$user->role = $role;
|
||||
$user->enterpriseId = $data['enterpriseId'] ?? $data['enterprise_id'] ?? null;
|
||||
$user->status = $data['status'] ?? 1;
|
||||
$user->save();
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password']);
|
||||
|
||||
return success($userData, '创建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$data = Request::put();
|
||||
|
||||
// 如果更新用户名,检查是否重复
|
||||
if (isset($data['username']) && $data['username'] != $user->username) {
|
||||
if (UserModel::where('username', $data['username'])->find()) {
|
||||
return error('用户名已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果更新邮箱,检查是否重复
|
||||
if (isset($data['email']) && $data['email'] != $user->email) {
|
||||
if (!empty($data['email']) && UserModel::where('email', $data['email'])->find()) {
|
||||
return error('邮箱已被注册');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果更新密码
|
||||
if (isset($data['password'])) {
|
||||
$user->password = $data['password']; // 会自动加密
|
||||
}
|
||||
|
||||
$user->save($data);
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password']);
|
||||
|
||||
return success($userData, '更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(软删除)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
// 检查是否已删除
|
||||
if ($user->deletedAt) {
|
||||
return error('用户已被删除', 400);
|
||||
}
|
||||
|
||||
// 不能删除自己
|
||||
$currentUser = $this->request->user ?? null;
|
||||
if ($currentUser && ($currentUser['userId'] ?? $currentUser['user_id'] ?? null) == $id) {
|
||||
return error('不能删除自己', 400);
|
||||
}
|
||||
|
||||
// 软删除(设置 deletedAt 时间戳)
|
||||
$user->delete();
|
||||
return success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用用户
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
$user = UserModel::find($id);
|
||||
if (!$user) {
|
||||
return error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$user->status = $user->status == 1 ? 0 : 1;
|
||||
$user->save();
|
||||
|
||||
return success($user, '操作成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,235 +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 类目配置中的存客宝KEY(consultWechat字段)
|
||||
* @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,
|
||||
];
|
||||
}
|
||||
}
|
||||
<?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 类目配置中的存客宝KEY(consultWechat字段)
|
||||
* @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
@@ -1,250 +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']
|
||||
);
|
||||
}
|
||||
}
|
||||
<?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']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\controller\admin\Upload as AdminUpload;
|
||||
|
||||
/**
|
||||
* 小程序用户上传(头像等),复用管理端上传逻辑,需 JWT 认证
|
||||
*/
|
||||
class Upload extends AdminUpload
|
||||
{
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\controller\admin\Upload as AdminUpload;
|
||||
|
||||
/**
|
||||
* 小程序用户上传(头像等),复用管理端上传逻辑,需 JWT 认证
|
||||
*/
|
||||
class Upload extends AdminUpload
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,101 +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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,482 +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')
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<?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')
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,140 +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')
|
||||
], '刷新成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?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')
|
||||
], '刷新成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,433 +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;
|
||||
}
|
||||
}
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,437 +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, '操作成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?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, '操作成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,337 +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, '财务报表导出功能开发中');
|
||||
}
|
||||
}
|
||||
<?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, '财务报表导出功能开发中');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,431 +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);
|
||||
}
|
||||
}
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,200 +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} 个配置");
|
||||
}
|
||||
}
|
||||
|
||||
<?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} 个配置");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,332 +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(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?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(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class Settings extends BaseController
|
||||
$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();
|
||||
$reviewModeConfig = SystemConfigModel::where('key', 'review_mode')->where('enterprise_id', 0)->find();
|
||||
|
||||
// 获取当前超管用户名(直接使用JWT中的username)
|
||||
$jwtUsername = $user['username'] ?? null;
|
||||
@@ -59,6 +60,7 @@ class Settings extends BaseController
|
||||
'trialTestCount' => 10,
|
||||
'defaultEnterpriseId' => null,
|
||||
],
|
||||
'reviewMode' => $reviewModeConfig && !empty($reviewModeConfig->value) ? $reviewModeConfig->value : ['enabled' => false],
|
||||
'notification' => $notificationConfig ? $notificationConfig->value : [
|
||||
'emailNotification' => true,
|
||||
'lowBalanceAlert' => true,
|
||||
@@ -385,6 +387,40 @@ class Settings extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新审核模式配置
|
||||
* PUT body: { "enabled": true/false }
|
||||
* 开启后小程序隐藏AI面相分析功能,仅展示问卷测试,用于通过微信审核
|
||||
*/
|
||||
public function updateReviewMode()
|
||||
{
|
||||
$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 = Request::param();
|
||||
}
|
||||
$enabled = !empty($input['enabled']);
|
||||
|
||||
try {
|
||||
$config = SystemConfigModel::where('key', 'review_mode')->where('enterprise_id', 0)->find();
|
||||
if (!$config) {
|
||||
$config = new SystemConfigModel();
|
||||
$config->key = 'review_mode';
|
||||
$config->enterprise_id = 0;
|
||||
$config->description = '审核模式:开启后隐藏AI功能以通过微信审核';
|
||||
}
|
||||
$config->value = ['enabled' => $enabled];
|
||||
$config->save();
|
||||
return success($config->value, '审核模式已' . ($enabled ? '开启' : '关闭'));
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用字体列表
|
||||
* GET /api/v1/superadmin/settings/fonts
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<?php
|
||||
namespace app\middleware;
|
||||
|
||||
use app\common\service\JwtService;
|
||||
|
||||
/**
|
||||
* 认证中间件
|
||||
*/
|
||||
class Auth
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return Response
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
$token = JwtService::getTokenFromRequest($request);
|
||||
|
||||
if (empty($token)) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => '未登录或Token无效',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 验证Token
|
||||
$payload = JwtService::verifyToken($token);
|
||||
|
||||
if (!$payload) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => 'Token无效或已过期',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 将用户信息存储到请求中,供控制器使用
|
||||
$request->user = $payload;
|
||||
$request->userId = $payload['userId'] ?? $payload['user_id'] ?? null;
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\middleware;
|
||||
|
||||
use app\common\service\JwtService;
|
||||
|
||||
/**
|
||||
* 认证中间件
|
||||
*/
|
||||
class Auth
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return Response
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
$token = JwtService::getTokenFromRequest($request);
|
||||
|
||||
if (empty($token)) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => '未登录或Token无效',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 验证Token
|
||||
$payload = JwtService::verifyToken($token);
|
||||
|
||||
if (!$payload) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => 'Token无效或已过期',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 将用户信息存储到请求中,供控制器使用
|
||||
$request->user = $payload;
|
||||
$request->userId = $payload['userId'] ?? $payload['user_id'] ?? null;
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
<?php
|
||||
namespace app\middleware;
|
||||
|
||||
/**
|
||||
* 跨域中间件
|
||||
*/
|
||||
class Cors
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return Response
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
// 从配置文件获取跨域配置
|
||||
$config = config('cors');
|
||||
|
||||
$allowOrigin = $config['allow_origin'] ?? '*';
|
||||
$allowMethods = $config['allow_methods'] ?? 'GET,POST,PUT,DELETE,OPTIONS';
|
||||
$allowHeaders = $config['allow_headers'] ?? 'Content-Type,Authorization,X-Requested-With,Accept';
|
||||
$allowCredentials = $config['allow_credentials'] ?? false;
|
||||
$maxAge = $config['max_age'] ?? 86400;
|
||||
|
||||
// 获取请求的Origin
|
||||
$origin = $request->header('Origin', '');
|
||||
|
||||
// 确定允许的Origin
|
||||
$allowedOrigin = null;
|
||||
if ($allowOrigin === '*') {
|
||||
$allowedOrigin = '*';
|
||||
} else {
|
||||
// 支持多个域名(用逗号分隔)
|
||||
$origins = array_map('trim', explode(',', $allowOrigin));
|
||||
|
||||
// 如果请求的Origin在允许列表中,则使用该Origin
|
||||
// 同时支持带/不带尾部斜杠的匹配
|
||||
foreach ($origins as $allowed) {
|
||||
if ($origin === $allowed || $origin === rtrim($allowed, '/') || rtrim($origin, '/') === $allowed) {
|
||||
$allowedOrigin = $origin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理预检请求(OPTIONS)
|
||||
if ($request->method(true) === 'OPTIONS') {
|
||||
$response = response('', 200);
|
||||
} else {
|
||||
$response = $next($request);
|
||||
}
|
||||
|
||||
// 设置CORS响应头
|
||||
if ($allowedOrigin !== null) {
|
||||
$headers = [
|
||||
'Access-Control-Allow-Origin' => $allowedOrigin,
|
||||
'Access-Control-Allow-Methods' => $allowMethods,
|
||||
'Access-Control-Allow-Headers' => $allowHeaders,
|
||||
'Access-Control-Max-Age' => (string)$maxAge,
|
||||
];
|
||||
|
||||
if ($allowCredentials) {
|
||||
$headers['Access-Control-Allow-Credentials'] = 'true';
|
||||
}
|
||||
|
||||
// 使用header方法设置响应头(ThinkPHP 8 需要传递数组)
|
||||
$response->header($headers);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\middleware;
|
||||
|
||||
/**
|
||||
* 跨域中间件
|
||||
*/
|
||||
class Cors
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return Response
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
// 从配置文件获取跨域配置
|
||||
$config = config('cors');
|
||||
|
||||
$allowOrigin = $config['allow_origin'] ?? '*';
|
||||
$allowMethods = $config['allow_methods'] ?? 'GET,POST,PUT,DELETE,OPTIONS';
|
||||
$allowHeaders = $config['allow_headers'] ?? 'Content-Type,Authorization,X-Requested-With,Accept';
|
||||
$allowCredentials = $config['allow_credentials'] ?? false;
|
||||
$maxAge = $config['max_age'] ?? 86400;
|
||||
|
||||
// 获取请求的Origin
|
||||
$origin = $request->header('Origin', '');
|
||||
|
||||
// 确定允许的Origin
|
||||
$allowedOrigin = null;
|
||||
if ($allowOrigin === '*') {
|
||||
$allowedOrigin = '*';
|
||||
} else {
|
||||
// 支持多个域名(用逗号分隔)
|
||||
$origins = array_map('trim', explode(',', $allowOrigin));
|
||||
|
||||
// 如果请求的Origin在允许列表中,则使用该Origin
|
||||
// 同时支持带/不带尾部斜杠的匹配
|
||||
foreach ($origins as $allowed) {
|
||||
if ($origin === $allowed || $origin === rtrim($allowed, '/') || rtrim($origin, '/') === $allowed) {
|
||||
$allowedOrigin = $origin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理预检请求(OPTIONS)
|
||||
if ($request->method(true) === 'OPTIONS') {
|
||||
$response = response('', 200);
|
||||
} else {
|
||||
$response = $next($request);
|
||||
}
|
||||
|
||||
// 设置CORS响应头
|
||||
if ($allowedOrigin !== null) {
|
||||
$headers = [
|
||||
'Access-Control-Allow-Origin' => $allowedOrigin,
|
||||
'Access-Control-Allow-Methods' => $allowMethods,
|
||||
'Access-Control-Allow-Headers' => $allowHeaders,
|
||||
'Access-Control-Max-Age' => (string)$maxAge,
|
||||
];
|
||||
|
||||
if ($allowCredentials) {
|
||||
$headers['Access-Control-Allow-Credentials'] = 'true';
|
||||
}
|
||||
|
||||
// 使用header方法设置响应头(ThinkPHP 8 需要传递数组)
|
||||
$response->header($headers);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
<?php
|
||||
namespace app\middleware;
|
||||
|
||||
use app\common\service\JwtService;
|
||||
|
||||
/**
|
||||
* 超级管理员权限中间件
|
||||
*/
|
||||
class SuperAdmin
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return Response
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
$token = JwtService::getTokenFromRequest($request);
|
||||
|
||||
if (empty($token)) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => '未登录或Token无效',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 验证Token
|
||||
$payload = JwtService::verifyToken($token);
|
||||
|
||||
if (!$payload) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => 'Token无效或已过期',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 验证是否为超级管理员
|
||||
if ($payload['role'] !== 'superadmin') {
|
||||
return json([
|
||||
'code' => 403,
|
||||
'message' => '无权限访问,需要超级管理员权限',
|
||||
'data' => null
|
||||
])->code(403);
|
||||
}
|
||||
|
||||
// 将用户信息存储到请求中,供控制器使用
|
||||
$request->user = $payload;
|
||||
$request->userId = $payload['userId'] ?? null;
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\middleware;
|
||||
|
||||
use app\common\service\JwtService;
|
||||
|
||||
/**
|
||||
* 超级管理员权限中间件
|
||||
*/
|
||||
class SuperAdmin
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return Response
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
$token = JwtService::getTokenFromRequest($request);
|
||||
|
||||
if (empty($token)) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => '未登录或Token无效',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 验证Token
|
||||
$payload = JwtService::verifyToken($token);
|
||||
|
||||
if (!$payload) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'message' => 'Token无效或已过期',
|
||||
'data' => null
|
||||
])->code(401);
|
||||
}
|
||||
|
||||
// 验证是否为超级管理员
|
||||
if ($payload['role'] !== 'superadmin') {
|
||||
return json([
|
||||
'code' => 403,
|
||||
'message' => '无权限访问,需要超级管理员权限',
|
||||
'data' => null
|
||||
])->code(403);
|
||||
}
|
||||
|
||||
// 将用户信息存储到请求中,供控制器使用
|
||||
$request->user = $payload;
|
||||
$request->userId = $payload['userId'] ?? null;
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,109 +1,109 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* AI服务商配置模型
|
||||
*/
|
||||
class AiProvider extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'ai_providers';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'providerId' => 'string',
|
||||
'name' => 'string',
|
||||
'enabled' => 'int',
|
||||
'visible' => 'int',
|
||||
'apiKey' => 'string',
|
||||
'apiEndpoint' => 'string',
|
||||
'model' => 'string',
|
||||
'organizationId' => 'string',
|
||||
'maxTokens' => 'int',
|
||||
'balanceAlertEnabled' => 'int',
|
||||
'balanceAlertThreshold' => 'float',
|
||||
'notes' => 'string',
|
||||
'docUrl' => 'string',
|
||||
'isFree' => 'int',
|
||||
'supportsBalance' => 'int',
|
||||
'lastBalance' => 'float',
|
||||
'lastBalanceCurrency' => 'string',
|
||||
'lastBalanceCheckedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'extraConfig' => 'string',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'lastBalanceCheckedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'extraConfig' => 'json',
|
||||
];
|
||||
|
||||
// 注意:API Key需要可逆读取用于API调用,所以不隐藏,但在获取器中脱敏
|
||||
|
||||
/**
|
||||
* API Key 修改器(存储原始值,用于API调用)
|
||||
*/
|
||||
public function setApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
// 如果输入的是脱敏格式(包含****),不更新
|
||||
if (strpos($value, '****') !== false) {
|
||||
return null; // 返回null表示不更新此字段
|
||||
}
|
||||
// 直接存储原始值(实际生产环境建议使用AES加密)
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 获取器(返回脱敏后的密钥)
|
||||
* 注意:如果需要原始密钥用于API调用,使用 getRawApiKey() 方法
|
||||
*/
|
||||
public function getApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
// 返回脱敏后的密钥(显示前6位和后4位)
|
||||
if (strlen($value) > 10) {
|
||||
return substr($value, 0, 6) . '****' . substr($value, -4);
|
||||
}
|
||||
return '****';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始API Key(用于API调用)
|
||||
* @return string
|
||||
*/
|
||||
public function getRawApiKey()
|
||||
{
|
||||
// 直接从数据库读取原始值,绕过获取器
|
||||
return \think\facade\Db::name('ai_providers')
|
||||
->where('id', $this->id)
|
||||
->value('apiKey') ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* AI服务商配置模型
|
||||
*/
|
||||
class AiProvider extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'ai_providers';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'providerId' => 'string',
|
||||
'name' => 'string',
|
||||
'enabled' => 'int',
|
||||
'visible' => 'int',
|
||||
'apiKey' => 'string',
|
||||
'apiEndpoint' => 'string',
|
||||
'model' => 'string',
|
||||
'organizationId' => 'string',
|
||||
'maxTokens' => 'int',
|
||||
'balanceAlertEnabled' => 'int',
|
||||
'balanceAlertThreshold' => 'float',
|
||||
'notes' => 'string',
|
||||
'docUrl' => 'string',
|
||||
'isFree' => 'int',
|
||||
'supportsBalance' => 'int',
|
||||
'lastBalance' => 'float',
|
||||
'lastBalanceCurrency' => 'string',
|
||||
'lastBalanceCheckedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'extraConfig' => 'string',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'lastBalanceCheckedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'extraConfig' => 'json',
|
||||
];
|
||||
|
||||
// 注意:API Key需要可逆读取用于API调用,所以不隐藏,但在获取器中脱敏
|
||||
|
||||
/**
|
||||
* API Key 修改器(存储原始值,用于API调用)
|
||||
*/
|
||||
public function setApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
// 如果输入的是脱敏格式(包含****),不更新
|
||||
if (strpos($value, '****') !== false) {
|
||||
return null; // 返回null表示不更新此字段
|
||||
}
|
||||
// 直接存储原始值(实际生产环境建议使用AES加密)
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 获取器(返回脱敏后的密钥)
|
||||
* 注意:如果需要原始密钥用于API调用,使用 getRawApiKey() 方法
|
||||
*/
|
||||
public function getApiKeyAttr($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
// 返回脱敏后的密钥(显示前6位和后4位)
|
||||
if (strlen($value) > 10) {
|
||||
return substr($value, 0, 6) . '****' . substr($value, -4);
|
||||
}
|
||||
return '****';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始API Key(用于API调用)
|
||||
* @return string
|
||||
*/
|
||||
public function getRawApiKey()
|
||||
{
|
||||
// 直接从数据库读取原始值,绕过获取器
|
||||
return \think\facade\Db::name('ai_providers')
|
||||
->where('id', $this->id)
|
||||
->value('apiKey') ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 数据库备份记录模型
|
||||
*/
|
||||
class BackupRecord extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'backup_records';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'filename' => 'string',
|
||||
'filepath' => 'string',
|
||||
'fileSize' => 'int',
|
||||
'ossUrl' => 'string',
|
||||
'ossPath' => 'string',
|
||||
'status' => 'string',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 数据库备份记录模型
|
||||
*/
|
||||
class BackupRecord extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'backup_records';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'filename' => 'string',
|
||||
'filepath' => 'string',
|
||||
'fileSize' => 'int',
|
||||
'ossUrl' => 'string',
|
||||
'ossPath' => 'string',
|
||||
'status' => 'string',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 企业模型
|
||||
*/
|
||||
class Enterprise extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'enterprises';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'code' => 'string',
|
||||
'contactName' => 'string',
|
||||
'contactPhone' => 'string',
|
||||
'contactEmail' => 'string',
|
||||
'balance' => 'float',
|
||||
'status' => 'string',
|
||||
'trialExpireAt' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'trialExpireAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 企业模型
|
||||
*/
|
||||
class Enterprise extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'enterprises';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'code' => 'string',
|
||||
'contactName' => 'string',
|
||||
'contactPhone' => 'string',
|
||||
'contactEmail' => 'string',
|
||||
'balance' => 'float',
|
||||
'status' => 'string',
|
||||
'trialExpireAt' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'trialExpireAt' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 企业版简历上传记录(仅记录上传,支持预览)
|
||||
*/
|
||||
class EnterpriseResumeUpload extends Model
|
||||
{
|
||||
protected $name = 'enterprise_resume_uploads';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'userId' => 'int',
|
||||
'enterpriseId' => 'int',
|
||||
'fileUrl' => 'string',
|
||||
'fileName' => 'string',
|
||||
'is_default' => 'int',
|
||||
'createdAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
}
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 企业版简历上传记录(仅记录上传,支持预览)
|
||||
*/
|
||||
class EnterpriseResumeUpload extends Model
|
||||
{
|
||||
protected $name = 'enterprise_resume_uploads';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'userId' => 'int',
|
||||
'enterpriseId' => 'int',
|
||||
'fileUrl' => 'string',
|
||||
'fileName' => 'string',
|
||||
'is_default' => 'int',
|
||||
'createdAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
}
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 定价配置模型
|
||||
* 实际表名 = 数据库前缀 + pricing_config,例如 .env 中 DATABASE_PREFIX=mbti_ 时为 mbti_pricing_config
|
||||
*/
|
||||
class PricingConfig extends Model
|
||||
{
|
||||
// 表名(不含前缀);最终访问表 = config(database.prefix) + pricing_config
|
||||
protected $name = 'pricing_config';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'type' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'config' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
// JSON字段自动转换
|
||||
protected $json = ['config'];
|
||||
|
||||
/**
|
||||
* 配置修改器(自动转换为JSON)
|
||||
*/
|
||||
public function setConfigAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置获取器(自动解析JSON)
|
||||
*/
|
||||
public function getConfigAttr($value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型与可选企业ID取定价配置
|
||||
*
|
||||
* personal(个人版)优先级:
|
||||
* 1. admin_personal + enterpriseId(企业专属管理端配置,有 eid 时)
|
||||
* 2. admin_personal + null(通用管理端配置)
|
||||
* 3. 任意一条 admin_personal(兜底:只要管理端配过就不走超管)
|
||||
* 4. personal + null(超管全局,仅在管理端完全未配置时使用)
|
||||
*
|
||||
* enterprise(企业版)优先级:
|
||||
* 1. admin_enterprise + enterpriseId(有 eid 时)
|
||||
* 2. admin_enterprise + null(通用管理端企业配置)
|
||||
* 3. 任意一条 admin_enterprise
|
||||
* 4. enterprise + null(超管全局兜底)
|
||||
*
|
||||
* @param string $type personal|enterprise|deep
|
||||
* @param int|null $enterpriseId 有则优先读该企业专属配置
|
||||
* @return \app\model\PricingConfig|null
|
||||
*/
|
||||
public static function getByTypeAndEnterprise(string $type, ?int $enterpriseId = null): ?self
|
||||
{
|
||||
if ($type === 'personal') {
|
||||
if (!empty($enterpriseId)) {
|
||||
$row = self::where('type', 'admin_personal')->where('enterpriseId', $enterpriseId)->find();
|
||||
if ($row) return $row;
|
||||
}
|
||||
// 通用管理端个人配置(admin_personal + null)
|
||||
$row = self::where('type', 'admin_personal')->whereNull('enterpriseId')->find();
|
||||
if ($row) return $row;
|
||||
// 任意管理端个人配置(兜底:管理端配过就不走超管)
|
||||
$row = self::where('type', 'admin_personal')->order('id', 'asc')->find();
|
||||
if ($row) return $row;
|
||||
// 超管全局个人定价(最后兜底,仅管理端完全未配置时使用)
|
||||
return self::where('type', 'personal')->whereNull('enterpriseId')->find();
|
||||
}
|
||||
if ($type === 'enterprise') {
|
||||
if (!empty($enterpriseId)) {
|
||||
$row = self::where('type', 'admin_enterprise')->where('enterpriseId', $enterpriseId)->find();
|
||||
if ($row) return $row;
|
||||
}
|
||||
// 通用管理端企业配置(admin_enterprise + null)
|
||||
$row = self::where('type', 'admin_enterprise')->whereNull('enterpriseId')->find();
|
||||
if ($row) return $row;
|
||||
// 任意管理端企业配置(兜底)
|
||||
$row = self::where('type', 'admin_enterprise')->order('id', 'asc')->find();
|
||||
if ($row) return $row;
|
||||
return self::where('type', 'enterprise')->whereNull('enterpriseId')->find();
|
||||
}
|
||||
if ($type === 'deep') {
|
||||
return self::where('type', 'deep')->whereNull('enterpriseId')->find();
|
||||
}
|
||||
return self::where('type', $type)->whereNull('enterpriseId')->find();
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 定价配置模型
|
||||
* 实际表名 = 数据库前缀 + pricing_config,例如 .env 中 DATABASE_PREFIX=mbti_ 时为 mbti_pricing_config
|
||||
*/
|
||||
class PricingConfig extends Model
|
||||
{
|
||||
// 表名(不含前缀);最终访问表 = config(database.prefix) + pricing_config
|
||||
protected $name = 'pricing_config';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'type' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'config' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
// JSON字段自动转换
|
||||
protected $json = ['config'];
|
||||
|
||||
/**
|
||||
* 配置修改器(自动转换为JSON)
|
||||
*/
|
||||
public function setConfigAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置获取器(自动解析JSON)
|
||||
*/
|
||||
public function getConfigAttr($value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型与可选企业ID取定价配置
|
||||
*
|
||||
* personal(个人版)优先级:
|
||||
* 1. admin_personal + enterpriseId(企业专属管理端配置,有 eid 时)
|
||||
* 2. admin_personal + null(通用管理端配置)
|
||||
* 3. 任意一条 admin_personal(兜底:只要管理端配过就不走超管)
|
||||
* 4. personal + null(超管全局,仅在管理端完全未配置时使用)
|
||||
*
|
||||
* enterprise(企业版)优先级:
|
||||
* 1. admin_enterprise + enterpriseId(有 eid 时)
|
||||
* 2. admin_enterprise + null(通用管理端企业配置)
|
||||
* 3. 任意一条 admin_enterprise
|
||||
* 4. enterprise + null(超管全局兜底)
|
||||
*
|
||||
* @param string $type personal|enterprise|deep
|
||||
* @param int|null $enterpriseId 有则优先读该企业专属配置
|
||||
* @return \app\model\PricingConfig|null
|
||||
*/
|
||||
public static function getByTypeAndEnterprise(string $type, ?int $enterpriseId = null): ?self
|
||||
{
|
||||
if ($type === 'personal') {
|
||||
if (!empty($enterpriseId)) {
|
||||
$row = self::where('type', 'admin_personal')->where('enterpriseId', $enterpriseId)->find();
|
||||
if ($row) return $row;
|
||||
}
|
||||
// 通用管理端个人配置(admin_personal + null)
|
||||
$row = self::where('type', 'admin_personal')->whereNull('enterpriseId')->find();
|
||||
if ($row) return $row;
|
||||
// 任意管理端个人配置(兜底:管理端配过就不走超管)
|
||||
$row = self::where('type', 'admin_personal')->order('id', 'asc')->find();
|
||||
if ($row) return $row;
|
||||
// 超管全局个人定价(最后兜底,仅管理端完全未配置时使用)
|
||||
return self::where('type', 'personal')->whereNull('enterpriseId')->find();
|
||||
}
|
||||
if ($type === 'enterprise') {
|
||||
if (!empty($enterpriseId)) {
|
||||
$row = self::where('type', 'admin_enterprise')->where('enterpriseId', $enterpriseId)->find();
|
||||
if ($row) return $row;
|
||||
}
|
||||
// 通用管理端企业配置(admin_enterprise + null)
|
||||
$row = self::where('type', 'admin_enterprise')->whereNull('enterpriseId')->find();
|
||||
if ($row) return $row;
|
||||
// 任意管理端企业配置(兜底)
|
||||
$row = self::where('type', 'admin_enterprise')->order('id', 'asc')->find();
|
||||
if ($row) return $row;
|
||||
return self::where('type', 'enterprise')->whereNull('enterpriseId')->find();
|
||||
}
|
||||
if ($type === 'deep') {
|
||||
return self::where('type', 'deep')->whereNull('enterpriseId')->find();
|
||||
}
|
||||
return self::where('type', $type)->whereNull('enterpriseId')->find();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 题目模型
|
||||
*/
|
||||
class Question extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'questions';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'type' => 'string',
|
||||
'question' => 'string',
|
||||
'options' => 'string',
|
||||
'dimension' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'sort' => 'int',
|
||||
'status' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
// JSON字段自动转换
|
||||
protected $json = ['options'];
|
||||
|
||||
/**
|
||||
* 选项修改器(自动转换为JSON)
|
||||
*/
|
||||
public function setOptionsAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选项获取器(自动解析JSON,确保返回数组格式)
|
||||
*/
|
||||
public function getOptionsAttr($value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
// 如果解码失败或返回null,返回空数组
|
||||
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
return [];
|
||||
}
|
||||
// 如果是对象格式(关联数组),转换为索引数组
|
||||
if (is_array($decoded) && !empty($decoded) && !isset($decoded[0])) {
|
||||
return array_values($decoded);
|
||||
}
|
||||
return $decoded ?: [];
|
||||
}
|
||||
// 如果是对象(stdClass),转换为数组
|
||||
if (is_object($value)) {
|
||||
$value = json_decode(json_encode($value), true);
|
||||
}
|
||||
// 如果已经是数组,确保是索引数组
|
||||
if (is_array($value) && !empty($value) && !isset($value[0])) {
|
||||
return array_values($value);
|
||||
}
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 题目模型
|
||||
*/
|
||||
class Question extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'questions';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'type' => 'string',
|
||||
'question' => 'string',
|
||||
'options' => 'string',
|
||||
'dimension' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'sort' => 'int',
|
||||
'status' => 'int',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
// JSON字段自动转换
|
||||
protected $json = ['options'];
|
||||
|
||||
/**
|
||||
* 选项修改器(自动转换为JSON)
|
||||
*/
|
||||
public function setOptionsAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选项获取器(自动解析JSON,确保返回数组格式)
|
||||
*/
|
||||
public function getOptionsAttr($value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
// 如果解码失败或返回null,返回空数组
|
||||
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
return [];
|
||||
}
|
||||
// 如果是对象格式(关联数组),转换为索引数组
|
||||
if (is_array($decoded) && !empty($decoded) && !isset($decoded[0])) {
|
||||
return array_values($decoded);
|
||||
}
|
||||
return $decoded ?: [];
|
||||
}
|
||||
// 如果是对象(stdClass),转换为数组
|
||||
if (is_object($value)) {
|
||||
$value = json_decode(json_encode($value), true);
|
||||
}
|
||||
// 如果已经是数组,确保是索引数组
|
||||
if (is_array($value) && !empty($value) && !isset($value[0])) {
|
||||
return array_values($value);
|
||||
}
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 系统配置模型
|
||||
*/
|
||||
class SystemConfig extends Model
|
||||
{
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'system_config';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'key' => 'string',
|
||||
'enterprise_id' => 'int',
|
||||
'value' => 'string',
|
||||
'description' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
// JSON字段自动转换
|
||||
protected $json = ['value'];
|
||||
|
||||
/**
|
||||
* 配置值修改器(自动转换为JSON)
|
||||
*/
|
||||
public function setValueAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置值获取器(自动解析JSON)
|
||||
*/
|
||||
public function getValueAttr($value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 系统配置模型
|
||||
*/
|
||||
class SystemConfig extends Model
|
||||
{
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'system_config';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'key' => 'string',
|
||||
'enterprise_id' => 'int',
|
||||
'value' => 'string',
|
||||
'description' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
// JSON字段自动转换
|
||||
protected $json = ['value'];
|
||||
|
||||
/**
|
||||
* 配置值修改器(自动转换为JSON)
|
||||
*/
|
||||
public function setValueAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置值获取器(自动解析JSON)
|
||||
*/
|
||||
public function getValueAttr($value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 上传文件记录(本地/OSS),用于去重与 URL 查询
|
||||
*/
|
||||
class UploadFile extends Model
|
||||
{
|
||||
protected $name = 'upload_files';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'path' => 'string',
|
||||
'url' => 'string',
|
||||
'driver' => 'string',
|
||||
'hash' => 'string',
|
||||
'size' => 'int',
|
||||
'mimeType' => 'string',
|
||||
'extension' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
}
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 上传文件记录(本地/OSS),用于去重与 URL 查询
|
||||
*/
|
||||
class UploadFile extends Model
|
||||
{
|
||||
protected $name = 'upload_files';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'path' => 'string',
|
||||
'url' => 'string',
|
||||
'driver' => 'string',
|
||||
'hash' => 'string',
|
||||
'size' => 'int',
|
||||
'mimeType' => 'string',
|
||||
'extension' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
}
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 用户模型
|
||||
*/
|
||||
class User extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'users';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'username' => 'string',
|
||||
'password' => 'string',
|
||||
'phone' => 'string',
|
||||
'email' => 'string',
|
||||
'role' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'mbtiType' => 'string',
|
||||
'region' => 'string',
|
||||
'industry' => 'string',
|
||||
'status' => 'int',
|
||||
'lastLoginTime' => 'int',
|
||||
'lastLoginIp' => 'string',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 隐藏字段(不返回给前端)
|
||||
protected $hidden = ['password'];
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'lastLoginTime' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 密码修改器(自动加密)
|
||||
*/
|
||||
public function setPasswordAttr($value)
|
||||
{
|
||||
return password_hash($value, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
* @param string $password 明文密码
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyPassword($password)
|
||||
{
|
||||
return password_verify($password, $this->password);
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 用户模型
|
||||
*/
|
||||
class User extends Model
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
// 设置表名(不带前缀,前缀在数据库配置中设置)
|
||||
protected $name = 'users';
|
||||
|
||||
// 软删除字段(驼峰命名,时间戳格式)
|
||||
protected $deleteTime = 'deletedAt';
|
||||
|
||||
// 设置字段信息(匹配数据库字段命名:驼峰命名)
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'username' => 'string',
|
||||
'password' => 'string',
|
||||
'phone' => 'string',
|
||||
'email' => 'string',
|
||||
'role' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'mbtiType' => 'string',
|
||||
'region' => 'string',
|
||||
'industry' => 'string',
|
||||
'status' => 'int',
|
||||
'lastLoginTime' => 'int',
|
||||
'lastLoginIp' => 'string',
|
||||
'deletedAt' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
// 自动时间戳(使用驼峰命名,时间戳格式)
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 时间戳字段名(驼峰命名,匹配数据库)
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
// 隐藏字段(不返回给前端)
|
||||
protected $hidden = ['password'];
|
||||
|
||||
// 时间字段类型(时间戳格式)
|
||||
protected $type = [
|
||||
'lastLoginTime' => 'integer',
|
||||
'deletedAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 密码修改器(自动加密)
|
||||
*/
|
||||
public function setPasswordAttr($value)
|
||||
{
|
||||
return password_hash($value, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
* @param string $password 明文密码
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyPassword($password)
|
||||
{
|
||||
return password_verify($password, $this->password);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,162 +1,162 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 用户画像汇总模型
|
||||
* 实际表名: 前缀 + user_profile (如 mbti_user_profile)
|
||||
*/
|
||||
class UserProfile extends Model
|
||||
{
|
||||
protected $name = 'user_profile';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'userId' => 'int',
|
||||
'userType' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'testsTotal' => 'int',
|
||||
'testsMbti' => 'int',
|
||||
'testsDisc' => 'int',
|
||||
'testsPdp' => 'int',
|
||||
'testsFace' => 'int',
|
||||
'ordersTotal' => 'int',
|
||||
'paidOrders' => 'int',
|
||||
'totalPaidAmount' => 'int',
|
||||
'lastTestResultId'=> 'int',
|
||||
'lastTestType' => 'string',
|
||||
'lastTestAt' => 'int',
|
||||
'lastMbtiResultId'=> 'int',
|
||||
'lastDiscResultId'=> 'int',
|
||||
'lastPdpResultId' => 'int',
|
||||
'lastFaceResultId'=> 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
/**
|
||||
* 测试完成后更新用户画像统计与最近测试ID
|
||||
*/
|
||||
public static function recordTest(int $userId, string $testType, int $testResultId, ?int $enterpriseId = null, ?int $createdAt = null): void
|
||||
{
|
||||
if ($userId <= 0 || !$testType || $testResultId <= 0) {
|
||||
return;
|
||||
}
|
||||
$now = $createdAt ?: time();
|
||||
$userType = $enterpriseId ? 'enterprise' : 'personal';
|
||||
|
||||
[$data, $id] = self::loadOrInitRow($userId, $userType, $enterpriseId, $now);
|
||||
|
||||
$data['testsTotal']++;
|
||||
switch ($testType) {
|
||||
case 'mbti':
|
||||
$data['testsMbti']++;
|
||||
$data['lastMbtiResultId'] = $testResultId;
|
||||
break;
|
||||
case 'disc':
|
||||
$data['testsDisc']++;
|
||||
$data['lastDiscResultId'] = $testResultId;
|
||||
break;
|
||||
case 'pdp':
|
||||
$data['testsPdp']++;
|
||||
$data['lastPdpResultId'] = $testResultId;
|
||||
break;
|
||||
case 'face':
|
||||
case 'ai':
|
||||
$data['testsFace']++;
|
||||
$data['lastFaceResultId'] = $testResultId;
|
||||
break;
|
||||
}
|
||||
|
||||
$data['lastTestResultId'] = $testResultId;
|
||||
$data['lastTestType'] = $testType;
|
||||
$data['lastTestAt'] = $now;
|
||||
$data['updatedAt'] = $now;
|
||||
|
||||
self::upsertRow($data, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后更新订单统计与总支付金额
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int|null $enterpriseId
|
||||
* @param int $amountFen 本次支付金额(分)
|
||||
*/
|
||||
public static function recordPayment(int $userId, ?int $enterpriseId, int $amountFen): void
|
||||
{
|
||||
if ($userId <= 0 || $amountFen <= 0) {
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
$userType = $enterpriseId ? 'enterprise' : 'personal';
|
||||
|
||||
[$data, $id] = self::loadOrInitRow($userId, $userType, $enterpriseId, $now);
|
||||
|
||||
$data['ordersTotal'] = (int) ($data['ordersTotal'] ?? 0) + 1;
|
||||
$data['paidOrders'] = (int) ($data['paidOrders'] ?? 0) + 1;
|
||||
$currentTotal = (int) ($data['totalPaidAmount'] ?? 0);
|
||||
$data['totalPaidAmount'] = $currentTotal + $amountFen;
|
||||
$data['updatedAt'] = $now;
|
||||
|
||||
self::upsertRow($data, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读或初始化一行画像数据
|
||||
*/
|
||||
protected static function loadOrInitRow(int $userId, string $userType, ?int $enterpriseId, int $now): array
|
||||
{
|
||||
$where = [
|
||||
'userId' => $userId,
|
||||
'userType' => $userType,
|
||||
'enterpriseId' => $enterpriseId,
|
||||
];
|
||||
|
||||
$row = Db::name('user_profile')->where($where)->lock(true)->find();
|
||||
|
||||
$base = [
|
||||
'testsTotal' => 0,
|
||||
'testsMbti' => 0,
|
||||
'testsDisc' => 0,
|
||||
'testsPdp' => 0,
|
||||
'testsFace' => 0,
|
||||
'ordersTotal' => 0,
|
||||
'paidOrders' => 0,
|
||||
'totalPaidAmount' => 0,
|
||||
'lastMbtiResultId'=> null,
|
||||
'lastDiscResultId'=> null,
|
||||
'lastPdpResultId' => null,
|
||||
'lastFaceResultId'=> null,
|
||||
];
|
||||
|
||||
if ($row) {
|
||||
$data = array_merge($base, $row);
|
||||
$id = (int) $row['id'];
|
||||
} else {
|
||||
$data = array_merge($base, $where, ['createdAt' => $now]);
|
||||
$id = 0;
|
||||
}
|
||||
|
||||
return [$data, $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入或更新一行画像数据
|
||||
*/
|
||||
protected static function upsertRow(array $data, int $id): void
|
||||
{
|
||||
if ($id > 0) {
|
||||
Db::name('user_profile')->where('id', $id)->update($data);
|
||||
} else {
|
||||
Db::name('user_profile')->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 用户画像汇总模型
|
||||
* 实际表名: 前缀 + user_profile (如 mbti_user_profile)
|
||||
*/
|
||||
class UserProfile extends Model
|
||||
{
|
||||
protected $name = 'user_profile';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'userId' => 'int',
|
||||
'userType' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'testsTotal' => 'int',
|
||||
'testsMbti' => 'int',
|
||||
'testsDisc' => 'int',
|
||||
'testsPdp' => 'int',
|
||||
'testsFace' => 'int',
|
||||
'ordersTotal' => 'int',
|
||||
'paidOrders' => 'int',
|
||||
'totalPaidAmount' => 'int',
|
||||
'lastTestResultId'=> 'int',
|
||||
'lastTestType' => 'string',
|
||||
'lastTestAt' => 'int',
|
||||
'lastMbtiResultId'=> 'int',
|
||||
'lastDiscResultId'=> 'int',
|
||||
'lastPdpResultId' => 'int',
|
||||
'lastFaceResultId'=> 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
/**
|
||||
* 测试完成后更新用户画像统计与最近测试ID
|
||||
*/
|
||||
public static function recordTest(int $userId, string $testType, int $testResultId, ?int $enterpriseId = null, ?int $createdAt = null): void
|
||||
{
|
||||
if ($userId <= 0 || !$testType || $testResultId <= 0) {
|
||||
return;
|
||||
}
|
||||
$now = $createdAt ?: time();
|
||||
$userType = $enterpriseId ? 'enterprise' : 'personal';
|
||||
|
||||
[$data, $id] = self::loadOrInitRow($userId, $userType, $enterpriseId, $now);
|
||||
|
||||
$data['testsTotal']++;
|
||||
switch ($testType) {
|
||||
case 'mbti':
|
||||
$data['testsMbti']++;
|
||||
$data['lastMbtiResultId'] = $testResultId;
|
||||
break;
|
||||
case 'disc':
|
||||
$data['testsDisc']++;
|
||||
$data['lastDiscResultId'] = $testResultId;
|
||||
break;
|
||||
case 'pdp':
|
||||
$data['testsPdp']++;
|
||||
$data['lastPdpResultId'] = $testResultId;
|
||||
break;
|
||||
case 'face':
|
||||
case 'ai':
|
||||
$data['testsFace']++;
|
||||
$data['lastFaceResultId'] = $testResultId;
|
||||
break;
|
||||
}
|
||||
|
||||
$data['lastTestResultId'] = $testResultId;
|
||||
$data['lastTestType'] = $testType;
|
||||
$data['lastTestAt'] = $now;
|
||||
$data['updatedAt'] = $now;
|
||||
|
||||
self::upsertRow($data, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后更新订单统计与总支付金额
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int|null $enterpriseId
|
||||
* @param int $amountFen 本次支付金额(分)
|
||||
*/
|
||||
public static function recordPayment(int $userId, ?int $enterpriseId, int $amountFen): void
|
||||
{
|
||||
if ($userId <= 0 || $amountFen <= 0) {
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
$userType = $enterpriseId ? 'enterprise' : 'personal';
|
||||
|
||||
[$data, $id] = self::loadOrInitRow($userId, $userType, $enterpriseId, $now);
|
||||
|
||||
$data['ordersTotal'] = (int) ($data['ordersTotal'] ?? 0) + 1;
|
||||
$data['paidOrders'] = (int) ($data['paidOrders'] ?? 0) + 1;
|
||||
$currentTotal = (int) ($data['totalPaidAmount'] ?? 0);
|
||||
$data['totalPaidAmount'] = $currentTotal + $amountFen;
|
||||
$data['updatedAt'] = $now;
|
||||
|
||||
self::upsertRow($data, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读或初始化一行画像数据
|
||||
*/
|
||||
protected static function loadOrInitRow(int $userId, string $userType, ?int $enterpriseId, int $now): array
|
||||
{
|
||||
$where = [
|
||||
'userId' => $userId,
|
||||
'userType' => $userType,
|
||||
'enterpriseId' => $enterpriseId,
|
||||
];
|
||||
|
||||
$row = Db::name('user_profile')->where($where)->lock(true)->find();
|
||||
|
||||
$base = [
|
||||
'testsTotal' => 0,
|
||||
'testsMbti' => 0,
|
||||
'testsDisc' => 0,
|
||||
'testsPdp' => 0,
|
||||
'testsFace' => 0,
|
||||
'ordersTotal' => 0,
|
||||
'paidOrders' => 0,
|
||||
'totalPaidAmount' => 0,
|
||||
'lastMbtiResultId'=> null,
|
||||
'lastDiscResultId'=> null,
|
||||
'lastPdpResultId' => null,
|
||||
'lastFaceResultId'=> null,
|
||||
];
|
||||
|
||||
if ($row) {
|
||||
$data = array_merge($base, $row);
|
||||
$id = (int) $row['id'];
|
||||
} else {
|
||||
$data = array_merge($base, $where, ['createdAt' => $now]);
|
||||
$id = 0;
|
||||
}
|
||||
|
||||
return [$data, $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入或更新一行画像数据
|
||||
*/
|
||||
protected static function upsertRow(array $data, int $id): void
|
||||
{
|
||||
if ($id > 0) {
|
||||
Db::name('user_profile')->where('id', $id)->update($data);
|
||||
} else {
|
||||
Db::name('user_profile')->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 微信小程序用户模型
|
||||
*/
|
||||
class WechatUser extends Model
|
||||
{
|
||||
protected $name = 'wechat_users';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'openid' => 'string',
|
||||
'unionid' => 'string',
|
||||
'sessionKey' => 'string',
|
||||
'nickname' => 'string',
|
||||
'avatar' => 'string',
|
||||
'phone' => 'string',
|
||||
'gender' => 'int',
|
||||
'country' => 'string',
|
||||
'province' => 'string',
|
||||
'city' => 'string',
|
||||
'birthday' => 'string',
|
||||
'status' => 'int',
|
||||
'lastLoginAt' => 'int',
|
||||
'lastLoginIp' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
protected $hidden = ['sessionKey', 'openid'];
|
||||
|
||||
protected $type = [
|
||||
'lastLoginAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 返回给前端的用户信息(不包含敏感字段)
|
||||
*/
|
||||
public function toApiArray(): array
|
||||
{
|
||||
$row = $this->toArray();
|
||||
unset($row['sessionKey'], $row['openid']);
|
||||
$row['avatarUrl'] = $row['avatar'] ?? '';
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 微信小程序用户模型
|
||||
*/
|
||||
class WechatUser extends Model
|
||||
{
|
||||
protected $name = 'wechat_users';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'openid' => 'string',
|
||||
'unionid' => 'string',
|
||||
'sessionKey' => 'string',
|
||||
'nickname' => 'string',
|
||||
'avatar' => 'string',
|
||||
'phone' => 'string',
|
||||
'gender' => 'int',
|
||||
'country' => 'string',
|
||||
'province' => 'string',
|
||||
'city' => 'string',
|
||||
'birthday' => 'string',
|
||||
'status' => 'int',
|
||||
'lastLoginAt' => 'int',
|
||||
'lastLoginIp' => 'string',
|
||||
'enterpriseId' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
protected $hidden = ['sessionKey', 'openid'];
|
||||
|
||||
protected $type = [
|
||||
'lastLoginAt' => 'integer',
|
||||
'createdAt' => 'integer',
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 返回给前端的用户信息(不包含敏感字段)
|
||||
*/
|
||||
public function toApiArray(): array
|
||||
{
|
||||
$row = $this->toArray();
|
||||
unset($row['sessionKey'], $row['openid']);
|
||||
$row['avatarUrl'] = $row['avatar'] ?? '';
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user