服务端-其余代码

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-17 11:54:23 +08:00
parent 28b478dab5
commit a9bedf019d
25 changed files with 9354 additions and 0 deletions

121
api/app/BaseController.php Normal file
View File

@@ -0,0 +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);
}
}

171
api/app/common.php Normal file
View File

@@ -0,0 +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;
}
}

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +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;
}
}

View File

@@ -0,0 +1,612 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 推广海报合成服务PHP GD
* 需要 GD 扩展,中文需在 public/fonts/ 放置 TTF 字体(如 simhei.ttf
*
* 支持两种模式:
* 1. buildFromConfig() — 根据超管可视化配置渲染
* 2. build() — 旧版硬编码布局(兜底)
*/
class PosterService
{
/** 编辑器画布基准尺寸CSS px */
private const CANVAS_W = 375;
private const CANVAS_H = 667;
/** 渲染倍率2x 清晰度) */
private const SCALE = 2;
/** 旧版硬编码尺寸(向下兼容) */
private const WIDTH = 600;
private const HEIGHT = 1066;
/**
* 字体注册表key => [显示名, 文件名]
* 文件存放于 public/fonts/ 目录
*/
private const FONT_MAP = [
'noto-sans' => ['思源黑体', 'NotoSansCJKsc-Regular.otf'],
'noto-serif' => ['思源宋体', 'NotoSerifCJKsc-Regular.otf'],
'alimama' => ['阿里妈妈方圆体', 'AlimamaFangYuanTiVF.ttf'],
'wqy-microhei' => ['文泉驿微米黑', 'wqy-microhei.ttc'],
];
/**
* 返回服务器上实际可用的字体列表
* @return array [ ['key'=>'noto-sans','name'=>'思源黑体'], ... ]
*/
public static function getAvailableFonts(): array
{
$base = root_path() . 'public/fonts/';
$list = [];
foreach (self::FONT_MAP as $key => [$name, $file]) {
if (file_exists($base . $file)) {
$list[] = ['key' => $key, 'name' => $name];
}
}
// 兼容旧字体
$legacy = ['simhei.ttf' => '黑体', 'msyh.ttf' => '微软雅黑'];
foreach ($legacy as $file => $name) {
if (file_exists($base . $file)) {
$list[] = ['key' => pathinfo($file, PATHINFO_FILENAME), 'name' => $name];
}
}
return $list;
}
// ─────────────────────────────────────────────
// NEW根据超管可视化配置渲染海报
// ─────────────────────────────────────────────
/**
* 读取 poster_config 并渲染,若无配置则回退到旧版 build()
* @param int|null $enterpriseId 企业 ID优先读 enterprise_id={id} 行,无则降级到 enterprise_id=0 全局行
*/
public static function buildFromConfig(array $user, string $qrBinary, ?string $avatarBinary = null, ?int $enterpriseId = null): string
{
$raw = null;
// 1. 优先读取企业专属海报配置enterprise_id 列)
if ($enterpriseId > 0) {
$eidRow = Db::name('system_config')
->where('key', 'poster_config')
->where('enterprise_id', $enterpriseId)
->find();
if ($eidRow && !empty($eidRow['value'])) {
$raw = $eidRow['value'];
}
}
// 2. 降级到全局配置enterprise_id=0
if ($raw === null) {
$row = Db::name('system_config')
->where('key', 'poster_config')
->where('enterprise_id', 0)
->find();
$raw = $row['value'] ?? null;
}
// 安全解码:处理可能的多重 JSON 编码
$cfg = null;
if ($raw) {
$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;
}
$cfg = is_array($val) ? $val : null;
}
if (empty($cfg) || empty($cfg['elements']) || !is_array($cfg['elements'])) {
return self::build($user, $qrBinary, $avatarBinary);
}
return self::renderConfig($cfg, $user, $qrBinary, $avatarBinary);
}
/**
* 根据配置数组渲染海报
*/
private static function renderConfig(array $cfg, array $user, string $qrBinary, ?string $avatarBinary): string
{
$s = self::SCALE;
$cw = self::CANVAS_W * $s;
$ch = self::CANVAS_H * $s;
$img = imagecreatetruecolor($cw, $ch);
if (!$img) throw new \RuntimeException('GD image create failed');
imagesavealpha($img, true);
imagealphablending($img, true);
// 背景颜色
$bgColorHex = $cfg['bgColor'] ?? '#ffffff';
$bgRgb = self::parseColorToRgb($bgColorHex);
$bgColor = imagecolorallocate($img, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
imagefilledrectangle($img, 0, 0, $cw - 1, $ch - 1, $bgColor);
// 背景图片
if (!empty($cfg['bgImage'])) {
$bgBin = self::fetchImage($cfg['bgImage']);
if ($bgBin) {
$bgImg = @imagecreatefromstring($bgBin);
if ($bgImg) {
imagecopyresampled($img, $bgImg, 0, 0, 0, 0, $cw, $ch, imagesx($bgImg), imagesy($bgImg));
imagedestroy($bgImg);
}
}
}
// 缓存图像资源
$qrRes = null;
$avatarRes = null;
// 预先查询用户最近的测试结果mbti / pdp / disc / face
$testResults = self::fetchLatestTestResults((int)($user['id'] ?? 0));
foreach ($cfg['elements'] as $el) {
$type = $el['type'] ?? '';
$x = (int)(($el['x'] ?? 0) * $s);
$y = (int)(($el['y'] ?? 0) * $s);
$w = (int)(($el['w'] ?? 80) * $s);
$h = (int)(($el['h'] ?? 80) * $s);
$fontKey = $el['fontFamily'] ?? null;
// 对齐方式:优先 align 字段,兼容旧 center 字段
$align = $el['align'] ?? (!empty($el['center']) ? 'center' : 'left');
switch ($type) {
case 'text':
$text = $el['content'] ?? '';
$fontSize = max(8, (int)(($el['fontSize'] ?? 16) * $s));
$colorHex = $el['color'] ?? '#333333';
$bold = !empty($el['bold']);
$colorInt = self::allocateHexColor($img, $colorHex);
self::drawTextBlock($img, $x, $y, $w, $h, $text, $colorInt, $fontSize, $bold, $align, $fontKey);
break;
case 'nickname':
$text = mb_substr($user['nickname'] ?? '好友', 0, 20);
$fontSize = max(8, (int)(($el['fontSize'] ?? 16) * $s));
$colorHex = $el['color'] ?? '#333333';
$bold = !empty($el['bold']);
$colorInt = self::allocateHexColor($img, $colorHex);
self::drawTextBlock($img, $x, $y, $w, $h, $text, $colorInt, $fontSize, $bold, $align, $fontKey);
break;
case 'avatar':
if ($avatarBinary) {
if (!$avatarRes) $avatarRes = @imagecreatefromstring($avatarBinary);
if ($avatarRes) {
$shape = $el['shape'] ?? 'circle';
self::drawImageElement($img, $avatarRes, $x, $y, $w, $h, $shape);
}
}
break;
case 'qrcode':
if (!$qrRes) $qrRes = @imagecreatefromstring($qrBinary);
if ($qrRes) {
self::drawImageElement($img, $qrRes, $x, $y, $w, $h, 'square');
}
break;
case 'image':
if (!empty($el['url'])) {
$bin = self::fetchImage($el['url']);
if ($bin) {
$staticImg = @imagecreatefromstring($bin);
if ($staticImg) {
$shape = $el['shape'] ?? 'square';
self::drawImageElement($img, $staticImg, $x, $y, $w, $h, $shape);
imagedestroy($staticImg);
}
}
}
break;
case 'mbti':
case 'pdp':
case 'disc':
$text = $testResults[$type] ?? ($el['content'] ?? strtoupper($type));
$fontSize = max(8, (int)(($el['fontSize'] ?? 16) * $s));
$colorHex = $el['color'] ?? '#333333';
$bold = !empty($el['bold']);
$colorInt = self::allocateHexColor($img, $colorHex);
self::drawTextBlock($img, $x, $y, $w, $h, $text, $colorInt, $fontSize, $bold, $align, $fontKey);
break;
}
}
if ($qrRes) imagedestroy($qrRes);
if ($avatarRes) imagedestroy($avatarRes);
ob_start();
imagepng($img);
$png = ob_get_clean();
imagedestroy($img);
return $png ?: '';
}
/**
* 查询用户最近各类型测试的 resultText
* 复用 Test::_formatRecentRow 的解析逻辑
* @return array ['mbti' => 'ESTP', 'pdp' => '老虎型', 'disc' => 'D型']
*/
private static function fetchLatestTestResults(int $userId): array
{
$out = [];
if ($userId <= 0) return $out;
$types = ['face', 'mbti', 'pdp', 'disc'];
foreach ($types as $t) {
$row = Db::name('test_results')
->where('userId', $userId)
->where('testType', $t)
->order('createdAt', 'desc')
->field('resultData, testType')
->find();
if (!$row || empty($row['resultData'])) continue;
$raw = $row['resultData'];
$data = is_string($raw) ? json_decode($raw, true) : $raw;
if (!is_array($data)) continue;
switch ($t) {
case 'face':
// face 结果中包含 mbti/pdp/disc 子结构
if (!isset($out['mbti'])) {
$v = '';
if (isset($data['mbti']['type'])) $v = $data['mbti']['type'];
elseif (isset($data['mbti']) && is_scalar($data['mbti'])) $v = (string)$data['mbti'];
if ($v !== '') $out['mbti'] = $v;
}
if (!isset($out['pdp'])) {
$v = '';
if (isset($data['pdp']['type'])) $v = $data['pdp']['type'];
elseif (isset($data['pdp']) && is_scalar($data['pdp'])) $v = (string)$data['pdp'];
if ($v !== '') $out['pdp'] = $v;
}
if (!isset($out['disc'])) {
$v = '';
if (isset($data['disc']['primary'])) $v = $data['disc']['primary'] . '型';
elseif (isset($data['disc']) && is_scalar($data['disc'])) $v = (string)$data['disc'];
if ($v !== '') $out['disc'] = $v;
}
break;
case 'mbti':
if (!isset($out['mbti'])) {
$v = $data['mbtiType'] ?? $data['mbti'] ?? '';
if (is_array($v)) $v = $v['type'] ?? '';
if ((string)$v !== '') $out['mbti'] = (string)$v;
}
break;
case 'pdp':
if (!isset($out['pdp'])) {
$v = $data['description']['type'] ?? $data['pdp'] ?? '';
if (is_array($v)) $v = $v['type'] ?? '';
if ((string)$v !== '') $out['pdp'] = (string)$v;
}
break;
case 'disc':
if (!isset($out['disc'])) {
$v = $data['dominantType'] ?? $data['disc'] ?? '';
if (is_array($v)) $v = $v['type'] ?? $v['primary'] ?? '';
if ((string)$v !== '') $out['disc'] = (string)$v . '型';
}
break;
}
}
return $out;
}
/**
* 在指定区域内绘制文字imagettftext 的 y 为基线坐标)
*/
private static function drawTextBlock($img, int $x, int $y, int $w, int $h, string $text, int $color, int $fontSize, bool $bold, string $align = 'left', ?string $fontKey = null): void
{
$font = self::getFontPath($fontKey);
if ($font && function_exists('imagettftext')) {
$textW = self::ttfTextWidth($font, $fontSize, $text);
switch ($align) {
case 'center': $drawX = $x + (int)(($w - $textW) / 2); break;
case 'right': $drawX = $x + $w - $textW - 6; break;
default: $drawX = $x + 6; break;
}
$drawY = $y + (int)(($h + $fontSize * 0.8) / 2);
imagettftext($img, $fontSize, 0, $drawX, $drawY, $color, $font, $text);
} else {
$f = $fontSize <= 16 ? 4 : 5;
$textW = imagefontwidth($f) * mb_strlen($text);
switch ($align) {
case 'center': $drawX = $x + (int)(($w - $textW) / 2); break;
case 'right': $drawX = $x + $w - $textW - 4; break;
default: $drawX = $x + 4; break;
}
imagestring($img, $f, $drawX, $y + (int)(($h - imagefontheight($f)) / 2), preg_replace('/[^\x20-\x7e]/', '?', $text), $color);
}
}
/**
* 估算 TTF 文字宽度(近似)
*/
private static function ttfTextWidth(string $font, int $size, string $text): int
{
if (function_exists('imagettfbbox')) {
$box = imagettfbbox($size, 0, $font, $text);
return $box ? abs($box[4] - $box[0]) : $size * mb_strlen($text);
}
return $size * mb_strlen($text);
}
/**
* 将图像资源绘制到画布(支持圆形/方形裁剪)
*/
private static function drawImageElement($img, $src, int $x, int $y, int $w, int $h, string $shape): void
{
$srcW = imagesx($src);
$srcH = imagesy($src);
if ($shape === 'circle') {
// 先绘制到临时图像再做圆形遮罩
$tmp = imagecreatetruecolor($w, $h);
imagesavealpha($tmp, true);
imagealphablending($tmp, false);
$transparent = imagecolorallocatealpha($tmp, 0, 0, 0, 127);
imagefilledrectangle($tmp, 0, 0, $w - 1, $h - 1, $transparent);
imagealphablending($tmp, true);
imagecopyresampled($tmp, $src, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
// 圆形遮罩(逐像素)—— 仅在 GD 无更好方案时使用
$mask = imagecreatetruecolor($w, $h);
imagesavealpha($mask, true);
imagealphablending($mask, false);
imagefilledrectangle($mask, 0, 0, $w - 1, $h - 1, imagecolorallocatealpha($mask, 0, 0, 0, 127));
imagealphablending($mask, true);
imagefilledellipse($mask, (int)($w / 2), (int)($h / 2), $w, $h, imagecolorallocate($mask, 255, 255, 255));
// 将 tmp 叠加到主图(遮罩内白外黑,白色表示保留区域)
for ($px = 0; $px < $w; $px++) {
for ($py = 0; $py < $h; $py++) {
$mPx = imagecolorat($mask, $px, $py);
$rMask = ($mPx >> 16) & 0xFF;
if ($rMask > 128) {
$srcPx = imagecolorat($tmp, $px, $py);
$r = ($srcPx >> 16) & 0xFF;
$g = ($srcPx >> 8) & 0xFF;
$b = $srcPx & 0xFF;
$c = imagecolorallocate($img, $r, $g, $b);
imagesetpixel($img, $x + $px, $y + $py, $c);
}
}
}
imagedestroy($tmp);
imagedestroy($mask);
} else {
imagecopyresampled($img, $src, $x, $y, 0, 0, $w, $h, $srcW, $srcH);
}
}
/**
* 将颜色字符串解析为 RGB 数组
* 支持 #fff、#ffffff、rgba(r,g,b,a)、rgb(r,g,b)、数组 [r,g,b]
*/
private static function parseColorToRgb($color): array
{
if (is_array($color)) {
$r = (int)($color['r'] ?? $color[0] ?? 51);
$g = (int)($color['g'] ?? $color[1] ?? 51);
$b = (int)($color['b'] ?? $color[2] ?? 51);
return [min(255, max(0, $r)), min(255, max(0, $g)), min(255, max(0, $b))];
}
if (!is_string($color) && !is_scalar($color)) {
return [51, 51, 51];
}
$s = trim((string)$color);
if ($s === '') {
return [51, 51, 51];
}
// rgba(r,g,b,a) 或 rgb(r,g,b)
if (preg_match('/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i', $s, $m)) {
return [(int)min(255, $m[1]), (int)min(255, $m[2]), (int)min(255, $m[3])];
}
// 仅保留 # 后合法十六进制字符
$hex = preg_replace('/[^0-9a-fA-F]/', '', ltrim($s, '#'));
if (strlen($hex) === 3) {
$hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
}
if (strlen($hex) !== 6) {
return [51, 51, 51];
}
$r = (int)hexdec(substr($hex, 0, 2));
$g = (int)hexdec(substr($hex, 2, 2));
$b = (int)hexdec(substr($hex, 4, 2));
return [min(255, $r), min(255, $g), min(255, $b)];
}
private static function allocateHexColor($img, $hex): int
{
[$r, $g, $b] = self::parseColorToRgb($hex);
return imagecolorallocate($img, $r, $g, $b);
}
/**
* 根据字体 key 获取字体文件路径key 为空则返回第一个可用字体
*/
private static function getFontPath(?string $fontKey = null): ?string
{
$base = root_path() . 'public/fonts/';
// 按 key 精确查找
if ($fontKey) {
if (isset(self::FONT_MAP[$fontKey])) {
$p = $base . self::FONT_MAP[$fontKey][1];
if (file_exists($p)) return $p;
}
// 兼容旧字体 key如 simhei / msyh
$legacy = $base . $fontKey . '.ttf';
if (file_exists($legacy)) return $legacy;
}
// 回退:按优先级返回第一个可用字体
foreach (self::FONT_MAP as [$name, $file]) {
$p = $base . $file;
if (file_exists($p)) return $p;
}
$fallbacks = [$base . 'simhei.ttf', $base . 'msyh.ttf', '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc'];
foreach ($fallbacks as $p) {
if (file_exists($p)) return $p;
}
return null;
}
private static function drawText($img, int $x, int $y, string $text, int $color, int $size = 14): void
{
$font = self::getFontPath();
if ($font && function_exists('imagettftext')) {
imagettftext($img, $size, 0, $x, $y + $size, $color, $font, $text);
} else {
$f = $size <= 12 ? 4 : 5;
imagestring($img, $f, $x, $y, preg_replace('/[^\x20-\x7e]/', '', $text) ?: $text, $color);
}
}
/**
* 合成推广海报 PNG 二进制
* @param array $user 当前用户 {id, nickname, avatar}
* @param string $qrBinary 小程序码 PNG 二进制
* @param string|null $avatarBinary 头像二进制(已下载),为空则跳过
*/
public static function build(array $user, string $qrBinary, ?string $avatarBinary = null): string
{
$w = self::WIDTH;
$h = self::HEIGHT;
$img = imagecreatetruecolor($w, $h);
if (!$img) {
throw new \RuntimeException('GD image create failed');
}
imagesavealpha($img, true);
imagealphablending($img, true);
self::drawGradient($img, 0, 0, $w, (int)($h * 0.45), [0xFF, 0xD1, 0xE3], [0xE9, 0xD5, 0xFF]);
$white = imagecolorallocate($img, 255, 255, 255);
imagefilledrectangle($img, 0, (int)($h * 0.4), $w, $h, $white);
$primary = imagecolorallocate($img, 244, 63, 94);
$secondary = imagecolorallocate($img, 139, 92, 246);
$orange = imagecolorallocate($img, 249, 115, 22);
$dark = imagecolorallocate($img, 30, 41, 59);
$gray = imagecolorallocate($img, 100, 116, 139);
$lightGray = imagecolorallocate($img, 148, 163, 184);
$cx = (int)($w / 2);
self::drawRoundedRect($img, 30, 50, 60, 20, 10, imagecolorallocatealpha($img, 255, 255, 255, 80));
self::drawRoundedRect($img, 130, 50, 60, 20, 10, $primary);
self::drawText($img, 45, 52, '专业分析', $primary, 10);
self::drawText($img, 145, 52, '结果精准', $white, 10);
self::drawText($img, $cx - 80, 100, 'MBTI 神仙测试', $dark, 22);
self::drawRoundedRect($img, $cx - 80, 150, 65, 24, 8, imagecolorallocatealpha($img, 255, 255, 255, 50));
self::drawRoundedRect($img, $cx + 5, 150, 65, 24, 8, imagecolorallocatealpha($img, 255, 255, 255, 50));
self::drawText($img, $cx - 70, 155, 'INTJ', $primary, 10);
self::drawText($img, $cx - 45, 155, '战略家', $gray, 10);
self::drawText($img, $cx + 15, 155, 'PDP', $secondary, 10);
self::drawText($img, $cx + 40, 155, '猫头鹰', $gray, 10);
$gridY = 220;
$gridW = (int)(($w - 80) / 3);
self::drawStatCard($img, 30, $gridY, $gridW, 70, '100+', '性格档案', $primary);
self::drawStatCard($img, 30 + $gridW + 10, $gridY, $gridW, 70, '0%', '好友折扣', $secondary);
self::drawStatCard($img, 30 + ($gridW + 10) * 2, $gridY, $gridW, 70, '90%', '收益分红', $orange);
$cardY = 320;
self::drawRoundedRect($img, 30, $cardY, $w - 60, 180, 20, $white);
imagefilledrectangle($img, 45, $cardY + 18, 49, $cardY + 34, $primary);
self::drawText($img, 55, $cardY + 15, '完整版性格深度解析', $dark, 12);
$bullets = [
'你的决策风格在高压场景下会如何变化?',
'在团队中更适合担当怎样的关键角色?',
'哪些性格盲区最容易拖累你的发展?',
];
foreach ($bullets as $i => $t) {
self::drawText($img, 50, $cardY + 50 + $i * 28, '• ' . $t, $gray, 10);
}
$recY = 530;
self::drawRoundedRect($img, $cx - 100, $recY, 200, 36, 18, imagecolorallocate($img, 248, 250, 252));
if ($avatarBinary) {
$avatar = @imagecreatefromstring($avatarBinary);
if ($avatar) {
imagecopyresampled($img, $avatar, $cx - 92, $recY + 6, 0, 0, 24, 24, imagesx($avatar), imagesy($avatar));
imagedestroy($avatar);
}
}
$nickname = mb_substr($user['nickname'] ?? '好友', 0, 8);
self::drawText($img, $cx - 95, $recY + 12, '由 ' . $nickname . ' 推荐给你', $gray, 10);
$qrY = 620;
$qrImg = @imagecreatefromstring($qrBinary);
if ($qrImg) {
$qrSize = 88;
$qrX = $cx - (int)($qrSize / 2);
imagecopyresampled($img, $qrImg, $qrX, $qrY + 6, 0, 0, $qrSize, $qrSize, imagesx($qrImg), imagesy($qrImg));
imagedestroy($qrImg);
}
self::drawText($img, $cx - 80, $qrY + 100, '扫码解锁完整报告', $lightGray, 10);
$inviteCode = 'MBTI-' . ($user['id'] ?? '888');
self::drawText($img, $cx - 60, $qrY + 125, '邀请码 ' . $inviteCode, $primary, 11);
ob_start();
imagepng($img);
$png = ob_get_clean();
imagedestroy($img);
return $png ?: '';
}
private static function drawGradient($img, $x, $y, $w, $h, array $from, array $to): void
{
for ($i = 0; $i < $h; $i++) {
$r = (int)($from[0] + ($to[0] - $from[0]) * $i / $h);
$g = (int)($from[1] + ($to[1] - $from[1]) * $i / $h);
$b = (int)($from[2] + ($to[2] - $from[2]) * $i / $h);
$c = imagecolorallocate($img, max(0, min(255, $r)), max(0, min(255, $g)), max(0, min(255, $b)));
imagefilledrectangle($img, $x, $y + $i, $x + $w - 1, $y + $i, $c);
}
}
private static function drawRoundedRect($img, $x, $y, $w, $h, $r, $color): void
{
imagefilledrectangle($img, $x + $r, $y, $x + $w - $r - 1, $y + $h - 1, $color);
imagefilledrectangle($img, $x, $y + $r, $x + $w - 1, $y + $h - $r - 1, $color);
imagefilledellipse($img, $x + $r, $y + $r, $r * 2, $r * 2, $color);
imagefilledellipse($img, $x + $w - $r - 1, $y + $r, $r * 2, $r * 2, $color);
imagefilledellipse($img, $x + $r, $y + $h - $r - 1, $r * 2, $r * 2, $color);
imagefilledellipse($img, $x + $w - $r - 1, $y + $h - $r - 1, $r * 2, $r * 2, $color);
}
private static function drawStatCard($img, $x, $y, $w, $h, string $val, string $label, int $color): void
{
$white = imagecolorallocate($img, 255, 255, 255);
self::drawRoundedRect($img, $x, $y, $w, $h, 15, $white);
$gray = imagecolorallocate($img, 148, 163, 184);
$lw = imagefontwidth(5) * strlen($val);
imagestring($img, 5, $x + ($w - $lw) / 2, $y + 20, $val, $color);
$lw2 = imagefontwidth(4) * strlen($label);
imagestring($img, 4, $x + ($w - $lw2) / 2, $y + 45, $label, $gray);
}
/**
* 下载远程图片为二进制
*/
public static function fetchImage(string $url): ?string
{
$url = str_replace('http://', 'https://', $url);
$ctx = stream_context_create(['http' => ['timeout' => 10]]);
$bin = @file_get_contents($url, false, $ctx);
return $bin !== false ? $bin : null;
}
}

View File

@@ -0,0 +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];
}
}

View File

@@ -0,0 +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];
}
}

View File

@@ -0,0 +1,358 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 测试用户(小程序用户)管理 - 只读列表与详情
* 数据来源wechat_users测试记录来自 test_resultsuserId 关联 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 '';
}
}

View File

@@ -0,0 +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')
], '刷新成功');
}
}

View File

@@ -0,0 +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);
}
}
}

View File

@@ -0,0 +1,772 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 分销管理控制器(企业管理端)
* 路由前缀:/api/v1/admin/distribution
*/
class Distribution extends BaseController
{
// ─────────────────────────────────────────────────────────────
// GET distribution/overview
// ─────────────────────────────────────────────────────────────
public function overview()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$days = 7;
$trendStart = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
try {
$query = Db::name('commission_records')
->where('enterpriseId', $enterpriseId);
$totalCommission = (clone $query)->whereIn('status', ['paid', 'frozen'])->sum('commissionFen') ?: 0;
$paidCommission = (clone $query)->where('status', 'paid')->sum('commissionFen') ?: 0;
$frozenCommission = (clone $query)->where('status', 'frozen')->sum('commissionFen') ?: 0;
$totalOrders = (clone $query)->whereIn('status', ['paid', 'frozen'])->count();
$todayCommission = (clone $query)
->whereIn('status', ['paid', 'frozen'])
->where('createdAt', '>=', $todayStart)
->sum('commissionFen') ?: 0;
$pendingCount = (clone $query)->where('status', 'frozen')->count();
$bindingQuery = Db::name('distribution_bindings')
->where('enterpriseId', $enterpriseId)
->where('status', 'active')
->where('expireAt', '>', time());
$bindingCount = (clone $bindingQuery)->count();
$totalAgents = (clone $bindingQuery)->distinct(true)->count('inviterId');
$todayAgents = Db::name('distribution_bindings')
->where('enterpriseId', $enterpriseId)
->where('createdAt', '>=', $todayStart)
->distinct(true)
->count('inviterId');
$trendRows = Db::name('commission_records')
->where('enterpriseId', $enterpriseId)
->whereIn('status', ['paid', 'frozen'])
->where('createdAt', '>=', $trendStart)
->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, SUM(commissionFen) as totalFen")
->group('d')
->order('d', 'asc')
->select()
->toArray();
$trendMap = [];
foreach ($trendRows as $row) {
$trendMap[$row['d']] = (int) ($row['totalFen'] ?? 0);
}
$commissionTrend = [];
for ($i = 0; $i < $days; $i++) {
$date = date('Y-m-d', strtotime('-' . ($days - 1 - $i) . ' days'));
$commissionTrend[] = [
'date' => $date,
'amount' => round(($trendMap[$date] ?? 0) / 100, 2),
];
}
$productSeries = self::buildProductCommissionSeries($enterpriseId);
return success([
'totalAgents' => (int) $totalAgents,
'todayAgents' => (int) $todayAgents,
'totalCommission' => number_format($totalCommission / 100, 2, '.', ''),
'todayCommission' => number_format($todayCommission / 100, 2, '.', ''),
'pendingCommission' => number_format($frozenCommission / 100, 2, '.', ''),
'pendingCount' => (int) $pendingCount,
'paidCommission' => number_format($paidCommission / 100, 2, '.', ''),
'frozenCommission' => number_format($frozenCommission / 100, 2, '.', ''),
'totalOrders' => (int) $totalOrders,
'bindingCount' => (int) $bindingCount,
'commissionTrend' => $commissionTrend,
'productCommissionSeries' => $productSeries,
]);
} catch (\Exception $e) {
return error('获取数据失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// GET distribution/distributors 分销商列表(有过邀请行为的用户)
// ─────────────────────────────────────────────────────────────
public function distributors()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$search = trim((string) Request::param('search', ''));
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
try {
// 找出与本企业关联的所有有过邀请行为的用户(不限 scope按 enterpriseId 筛选)
$query = Db::name('distribution_bindings')
->where('enterpriseId', $enterpriseId)
->distinct(true)
->field('inviterId')
->buildSql();
$inviterQuery = Db::name('wechat_users')
->alias('u')
->whereRaw("u.id IN {$query}")
->field('u.id, u.nickname, u.avatar, u.createdAt');
if ($search !== '') {
$inviterQuery->where(function ($q) use ($search) {
$q->where('u.nickname', 'like', "%{$search}%")
->whereOr('u.id', '=', is_numeric($search) ? (int)$search : -1);
});
}
$total = (clone $inviterQuery)->count();
$inviters = $inviterQuery->page($page, $pageSize)->select()->toArray();
$inviterIds = array_column($inviters, 'id');
// 各邀请人的累计佣金与可提现佣金
$commStats = [];
if (!empty($inviterIds)) {
$rows = Db::name('commission_records')
->whereIn('inviterId', $inviterIds)
->where('enterpriseId', $enterpriseId)
->field('inviterId,
SUM(IF(status IN ("paid","frozen"), commissionFen, 0)) as totalFen,
SUM(IF(status = "paid", commissionFen, 0)) as paidFen')
->group('inviterId')
->select()->toArray();
foreach ($rows as $r) {
$commStats[$r['inviterId']] = $r;
}
// 已提现金额
$withdrawnRows = Db::name('distribution_withdrawals')
->whereIn('userId', $inviterIds)
// 提现金额统计0=审核中,2=待收款,3=已收款
->whereIn('status', [0, 2, 3])
->field('userId, SUM(amountFen) as withdrawnFen')
->group('userId')
->select()->toArray();
$withdrawnMap = [];
foreach ($withdrawnRows as $r) {
$withdrawnMap[$r['userId']] = (int)$r['withdrawnFen'];
}
// 团队人数(绑定人数,不限 scope
$teamRows = Db::name('distribution_bindings')
->whereIn('inviterId', $inviterIds)
->where('enterpriseId', $enterpriseId)
->field('inviterId, COUNT(DISTINCT inviteeId) as teamCount')
->group('inviterId')
->select()->toArray();
$teamMap = [];
foreach ($teamRows as $r) {
$teamMap[$r['inviterId']] = (int)$r['teamCount'];
}
}
$list = [];
foreach ($inviters as $inv) {
$uid = $inv['id'];
$totalFen = (int)($commStats[$uid]['totalFen'] ?? 0);
$paidFen = (int)($commStats[$uid]['paidFen'] ?? 0);
$withdrawn = $withdrawnMap[$uid] ?? 0;
$avail = max(0, $paidFen - $withdrawn);
$list[] = [
'id' => $uid,
'agentName' => $inv['nickname'] ?: ('用户' . $uid),
'avatar' => $inv['avatar'] ?? '',
'totalCommission' => number_format($totalFen / 100, 2, '.', ''),
'availableCommission'=> number_format($avail / 100, 2, '.', ''),
'teamCount' => $teamMap[$uid] ?? 0,
'teamPerformance' => '-',
'inviteCode' => '-',
'level' => '-',
'createdAt' => $inv['createdAt'],
];
}
return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
} catch (\Exception $e) {
return error('获取分销商列表失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// GET distribution/bindings 绑定记录列表
// ─────────────────────────────────────────────────────────────
public function bindings()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$status = Request::param('status', '');
$inviterId = (int) Request::param('inviterId', 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')
->field('b.*, inv.nickname as inviterName, inv.avatar as inviterAvatar,
invt.nickname as inviteeName, invt.avatar as inviteeAvatar')
->where('b.enterpriseId', $enterpriseId);
if ($inviterId > 0) {
$query->where('b.inviterId', $inviterId);
}
if ($status) {
$query->where('b.status', $status);
}
$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()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$status = Request::param('status', '');
$inviterId = (int) Request::param('inviterId', 0);
try {
$query = Db::name('commission_records')
->alias('c')
->leftJoin('wechat_users inv', 'c.inviterId = inv.id')
->leftJoin('wechat_users invt', 'c.inviteeId = invt.id')
->field('c.*, inv.nickname as inviterName, inv.avatar as inviterAvatar, invt.nickname as inviteeName, invt.avatar as inviteeAvatar')
->where('c.enterpriseId', $enterpriseId);
if ($inviterId > 0) {
$query->where('c.inviterId', $inviterId);
}
if ($status) {
$query->where('c.status', $status);
}
$total = (clone $query)->count();
$list = $query->order('c.createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
$orderIds = [];
$testResultIds = [];
foreach ($list as $row) {
if (!empty($row['orderId'])) {
$orderIds[] = (int) $row['orderId'];
}
if (!empty($row['testResultId'])) {
$testResultIds[] = (int) $row['testResultId'];
}
}
$orderTypeMap = [];
if (!empty($orderIds)) {
$rows = Db::name('test_results')
->whereIn('orderId', array_values(array_unique($orderIds)))
->field('orderId, testType')
->select()
->toArray();
foreach ($rows as $item) {
$orderTypeMap[(int) $item['orderId']] = self::normalizeTestType($item['testType'] ?? '');
}
}
$resultTypeMap = [];
if (!empty($testResultIds)) {
$rows = Db::name('test_results')
->whereIn('id', array_values(array_unique($testResultIds)))
->field('id, testType')
->select()
->toArray();
foreach ($rows as $item) {
$resultTypeMap[(int) $item['id']] = self::normalizeTestType($item['testType'] ?? '');
}
}
foreach ($list as &$row) {
$testType = 'other';
if (($row['commissionSource'] ?? '') === 'test_completion' && !empty($row['testResultId'])) {
$testType = $resultTypeMap[(int) $row['testResultId']] ?? 'other';
} elseif (!empty($row['orderId'])) {
$testType = $orderTypeMap[(int) $row['orderId']] ?? 'other';
}
$row['testType'] = $testType;
$row['testTypeLabel'] = self::getTestTypeLabel($testType);
$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()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$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')
->join('wechat_users u', 'w.userId = u.id')
->field('w.*, u.nickname, u.avatar')
->where('u.enterpriseId', $enterpriseId);
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)
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$note = Request::param('note', '');
$now = time();
$record = Db::name('distribution_withdrawals')
->alias('w')
->join('wechat_users u', 'w.userId = u.id')
->where('w.id', $id)
->where('u.enterpriseId', $enterpriseId)
->field('w.*, u.openid')
->find();
// 仅允许处理审核中status=0的记录
if (!$record || (int)$record['status'] !== 0) {
return error('提现申请不存在、已处理或无权限', 400);
}
try {
// 生成商户明细单号TX + 时间戳 + 随机数示例TX202603121526520005
$outDetailNo = 'TX' . date('YmdHis') . mt_rand(1000, 9999);
// 调用微信商家转账到零钱接口
$service = new \app\common\service\WechatTransferService();
$result = $service->createTransfer([
'out_detail_no' => $outDetailNo,
'transfer_amount'=> (int) $record['amountFen'],
'transfer_remark'=> '推广佣金提现',
'openid' => $record['openid'],
'batch_name' => '推广佣金提现',
'batch_remark' => '用户提现',
]);
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' => $outDetailNo,
'transfer_bill_no' => $wechatData['batch_id'] ?? null,
'wechat_pay_state' => $wechatData['batch_status'] ?? 'PROCESSING',
'transfer_scene_id'=> $wechatData['transfer_scene_id'] ?? env('TRANSFER_SCENE_ID', '1005'),
'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)
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$note = Request::param('note', '');
$now = time();
$record = Db::name('distribution_withdrawals')
->alias('w')
->join('wechat_users u', 'w.userId = u.id')
->where('w.id', $id)
->where('u.enterpriseId', $enterpriseId)
->field('w.*')
->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()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
try {
$config = Db::name('system_config')
->where('key', 'distribution')
->where('enterprise_id', $enterpriseId)
->find();
$tsDefault = self::defaultTestSettings();
$default = [
'enabled' => true,
'promoCenterTitle' => '推广中心',
'bindingDays' => 30,
'testSettings' => $tsDefault,
];
if ($config && $config['value']) {
$settings = is_string($config['value']) ? json_decode($config['value'], true) : $config['value'];
$settings = array_merge($default, $settings ?? []);
} else {
$settings = $default;
}
// 附加前端可读的 commissionAmount
$settings['testSettings'] = self::appendTestSettingsAmount(
$settings['testSettings'] ?? $tsDefault
);
return success($settings);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// PUT distribution/settings 更新企业分销配置
// ─────────────────────────────────────────────────────────────
public function updateSettings()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$settings = Request::only(['enabled', 'promoCenterTitle', 'bindingDays', 'testSettings']);
$promoTitle = trim((string)($settings['promoCenterTitle'] ?? ''));
$toSave = [
'enabled' => (bool)($settings['enabled'] ?? true),
'promoCenterTitle' => $promoTitle !== '' ? $promoTitle : '推广中心',
'bindingDays' => (int)($settings['bindingDays'] ?? 30),
'testSettings' => self::sanitizeTestSettings($settings['testSettings'] ?? null),
];
try {
$now = time();
$existing = Db::name('system_config')
->where('key', 'distribution')
->where('enterprise_id', $enterpriseId)
->find();
if ($existing) {
Db::name('system_config')
->where('key', 'distribution')
->where('enterprise_id', $enterpriseId)
->update(['value' => json_encode($toSave, JSON_UNESCAPED_UNICODE), 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => 'distribution',
'enterprise_id' => $enterpriseId,
'value' => json_encode($toSave, JSON_UNESCAPED_UNICODE),
'createdAt' => $now,
'updatedAt' => $now,
]);
}
$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;
}
private static function buildProductCommissionSeries(int $enterpriseId): array
{
$records = Db::name('commission_records')
->where('enterpriseId', $enterpriseId)
->whereIn('status', ['paid', 'frozen'])
->field('orderId, testResultId, commissionSource, commissionFen')
->select()
->toArray();
$orderIds = [];
$testResultIds = [];
foreach ($records as $record) {
if (!empty($record['orderId'])) {
$orderIds[] = (int) $record['orderId'];
}
if (!empty($record['testResultId'])) {
$testResultIds[] = (int) $record['testResultId'];
}
}
$orderTypeMap = [];
if (!empty($orderIds)) {
$rows = Db::name('test_results')
->whereIn('orderId', array_values(array_unique($orderIds)))
->field('orderId, testType')
->select()
->toArray();
foreach ($rows as $row) {
$orderTypeMap[(int) $row['orderId']] = self::normalizeTestType($row['testType'] ?? '');
}
}
$resultTypeMap = [];
if (!empty($testResultIds)) {
$rows = Db::name('test_results')
->whereIn('id', array_values(array_unique($testResultIds)))
->field('id, testType')
->select()
->toArray();
foreach ($rows as $row) {
$resultTypeMap[(int) $row['id']] = self::normalizeTestType($row['testType'] ?? '');
}
}
$totals = [
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'other' => 0,
];
foreach ($records as $record) {
$type = 'other';
if (($record['commissionSource'] ?? '') === 'test_completion' && !empty($record['testResultId'])) {
$type = $resultTypeMap[(int) $record['testResultId']] ?? 'other';
} elseif (!empty($record['orderId'])) {
$type = $orderTypeMap[(int) $record['orderId']] ?? 'other';
}
if (!isset($totals[$type])) {
$type = 'other';
}
$totals[$type] += (int) ($record['commissionFen'] ?? 0);
}
return [
['label' => '人脸分析', 'value' => round($totals['face'] / 100, 2)],
['label' => 'MBTI', 'value' => round($totals['mbti'] / 100, 2)],
['label' => 'DISC', 'value' => round($totals['disc'] / 100, 2)],
['label' => 'PDP', 'value' => round($totals['pdp'] / 100, 2)],
['label' => '其他', 'value' => round($totals['other'] / 100, 2)],
];
}
private static function normalizeTestType(string $testType): string
{
$normalized = strtolower(trim($testType));
if ($normalized === 'ai') {
return 'face';
}
if (in_array($normalized, ['face', 'mbti', 'disc', 'pdp'], true)) {
return $normalized;
}
return 'other';
}
private static function getTestTypeLabel(string $testType): string
{
$map = [
'face' => '人脸',
'mbti' => 'MBTI',
'disc' => 'DISC',
'pdp' => 'PDP',
'other' => '其他',
];
return $map[$testType] ?? strtoupper($testType ?: '其他');
}
}

View File

@@ -0,0 +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;
}
}

View File

@@ -0,0 +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,
]);
}
}

View File

@@ -0,0 +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'] ?? '');
}
}

View File

@@ -0,0 +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();
}
}

View File

@@ -0,0 +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(), '状态更新成功');
}
}

View File

@@ -0,0 +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_configenterprise_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);
}
}
}

View File

@@ -0,0 +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_BUCKETOSS_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.phptry-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';
}
}

View File

@@ -0,0 +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, '操作成功');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use app\model\AiProvider as AiProviderModel;
use app\common\service\JwtService;
use think\facade\Db;
/**
* 小程序运行配置(定价 + AI 服务商)
* 个人用户读超管个人定价企业用户读超管企业定价AI 一律读超管配置,默认使用第一个启用的服务商。
*/
class AppConfig extends BaseController
{
/**
* GET api/config/runtime
* 可选 Header Authorization有 token 且用户属于企业则返回企业定价,否则个人定价。
* 返回pricingType(personal|enterprise), pricing(config), aiProviderId, aiProviderName
*/
public function runtime()
{
$pricingType = 'personal';
$enterpriseId = null;
$pricing = [];
$scope = (string) ($this->request->param('scope', '') ?? '');
$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' => $payload['user_id'] ?? $payload['userId'] ?? null,
];
}
}
}
if ($scope !== 'personal' && $user && ($user['source'] ?? '') === 'wechat') {
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId > 0) {
$row = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find();
if (!empty($row['enterpriseId'])) {
$enterpriseId = (int) $row['enterpriseId'];
$pricingType = 'enterprise';
}
}
}
$config = PricingConfigModel::getByTypeAndEnterprise($pricingType, $enterpriseId);
if ($config && !empty($config->config)) {
// PricingConfig 模型已对 config 做 JSON 转换,这里直接当数组/对象用即可
$rawConfig = $config->config;
$pricing = is_array($rawConfig) ? $rawConfig : (array) $rawConfig;
}
// 超管 AI 配置:第一个 enabled=1、visible=1显示且 apiKey 非空;隐藏的服务商不参与选用
$firstProvider = AiProviderModel::where('enabled', 1)
->whereRaw('(visible IS NULL OR visible = 1)')
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->order('id', 'asc')
->find();
$aiProviderId = null;
$aiProviderName = null;
if ($firstProvider) {
$aiProviderId = $firstProvider->providerId;
$aiProviderName = $firstProvider->name ?? $firstProvider->providerId;
}
// 报告付费开关:完全根据定价配置判断(价格 > 0 视为需要付费)
$reportRequiresPayment = ['face' => 0, 'mbti' => 0, 'disc' => 0, 'pdp' => 0];
foreach (['face', 'mbti', 'disc', 'pdp'] as $k) {
$key = $k === 'team_analysis' ? 'teamAnalysis' : $k;
if (isset($pricing[$key]) && (float) $pricing[$key] > 0) {
$reportRequiresPayment[$k] = 1;
}
}
// 站点信息(网站名称、小程序名称):供小程序导航栏等展示
$siteName = '';
$miniprogramName = '';
$siteInfo = Db::name('system_config')->where('key', 'site_info')->find();
if ($siteInfo && !empty($siteInfo['value'])) {
$val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
if (is_array($val)) {
$siteName = (string) ($val['siteName'] ?? '');
$miniprogramName = (string) ($val['miniprogramName'] ?? '');
}
}
if ($siteName === '' || $miniprogramName === '') {
$system = Db::name('system_config')->where('key', 'system')->find();
if ($system && !empty($system['value'])) {
$val = is_string($system['value']) ? json_decode($system['value'], true) : $system['value'];
if (is_array($val)) {
if ($siteName === '') $siteName = (string) ($val['siteName'] ?? '');
if ($miniprogramName === '') $miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? '');
}
}
}
$siteTitle = $miniprogramName !== '' ? $miniprogramName : ($siteName !== '' ? $siteName : '神仙团队AI性格测试');
// 小程序文案配置(分析中提示、按钮、报告标题等)
$textConfig = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析'
];
// 先读全局 text_configenterprise_id=0
$tcRow = Db::name('system_config')->where('key', 'text_config')->where('enterprise_id', 0)->find();
if ($tcRow && !empty($tcRow['value'])) {
$tcVal = is_string($tcRow['value']) ? json_decode($tcRow['value'], true) : $tcRow['value'];
if (is_array($tcVal)) {
$textConfig = array_merge($textConfig, array_intersect_key($tcVal, $textConfig));
}
}
// 企业专属文案 + 小程序名称 覆盖全局enterprise_id={eid}
if ($enterpriseId > 0) {
$eidTc = Db::name('system_config')->where('key', 'text_config')->where('enterprise_id', $enterpriseId)->find();
if ($eidTc && !empty($eidTc['value'])) {
$eidVal = is_string($eidTc['value']) ? json_decode($eidTc['value'], true) : $eidTc['value'];
if (is_array($eidVal)) {
$textConfig = array_merge($textConfig, array_intersect_key($eidVal, $textConfig));
if (!empty($eidVal['miniprogramName'])) {
$miniprogramName = (string) $eidVal['miniprogramName'];
$siteTitle = $miniprogramName;
}
}
}
}
return success([
'pricingType' => $pricingType,
'pricing' => $pricing,
'aiProviderId' => $aiProviderId,
'aiProviderName' => $aiProviderName,
'reportRequiresPayment' => $reportRequiresPayment,
'siteName' => $siteName,
'miniprogramName' => $miniprogramName,
'siteTitle' => $siteTitle,
'textConfig' => $textConfig,
]);
}
/**
* GET api/config/deep-pricing?scope=personal|enterprise
* 深度服务价格(开通会员页):个人版与企业版分别返回可配置的类目列表,支持后台新增类目
*/
public function deepPricing()
{
$scope = (string) ($this->request->param('scope', 'personal') ?? 'personal');
$type = $scope === 'enterprise' ? 'deep_enterprise' : 'deep_personal';
$config = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find();
$categories = [];
if ($config && !empty($config->config)) {
$raw = $config->config;
$data = is_array($raw) ? $raw : (array) $raw;
$categories = isset($data['categories']) && is_array($data['categories']) ? $data['categories'] : [];
// 兼容旧数据:补全可能缺失的字段,确保前端始终能读到完整结构
foreach ($categories as &$cat) {
// features旧数据只存 featuresText动态拆成数组
if (!isset($cat['features']) || !is_array($cat['features'])) {
if (!empty($cat['featuresText']) && is_string($cat['featuresText'])) {
$lines = preg_split('/\r?\n/', $cat['featuresText']);
$cat['features'] = array_values(array_filter(array_map('trim', $lines), static function ($s) {
return $s !== '';
}));
} else {
$cat['features'] = [];
}
}
// serviceWechat客服微信展示给用户的微信号
if (!isset($cat['serviceWechat'])) {
$cat['serviceWechat'] = '';
}
// consultWechat存客宝KEY旧类目可能没有该字段补空字符串
if (!isset($cat['consultWechat'])) {
$cat['consultWechat'] = '';
}
// promptText同样补全
if (!isset($cat['promptText'])) {
$cat['promptText'] = '';
}
// successMessage成功提示词补全
if (!isset($cat['successMessage'])) {
$cat['successMessage'] = '';
}
}
unset($cat);
}
return success(['scope' => $scope, 'categories' => $categories]);
}
}