chore: 首次提交 - 关联 GitHub fnvtk/MBTI_wang
Made-with: Cursor
This commit is contained in:
124
api/app/common/controller/BaseController.php
Normal file
124
api/app/common/controller/BaseController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
132
api/app/common/service/JwtService.php
Normal file
132
api/app/common/service/JwtService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
612
api/app/common/service/PosterService.php
Normal file
612
api/app/common/service/PosterService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
194
api/app/common/service/WechatService.php
Normal file
194
api/app/common/service/WechatService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
495
api/app/common/service/WechatTransferService.php
Normal file
495
api/app/common/service/WechatTransferService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user