初始化本地仓库
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\auth;
|
||||
|
||||
use app\dao\member\MemberDao;
|
||||
use app\enum\common\EnabledStatus;
|
||||
use app\model\member\Member;
|
||||
use app\service\admin\system\ConfigService;
|
||||
use core\base\BaseService;
|
||||
use core\email\MailService;
|
||||
use core\jwt\JwtToken;
|
||||
use support\Container;
|
||||
use support\Redis;
|
||||
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*/
|
||||
class AuthService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(MemberDao $dao){
|
||||
$this->dao = $dao;
|
||||
}
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function login(array $data): array
|
||||
{
|
||||
// 验证参数
|
||||
if (empty($data['username']) || empty($data['password'])) {
|
||||
throw new \Exception('用户名和密码不能为空', 400);
|
||||
}
|
||||
|
||||
// 查找用户(支持用户名、手机号、邮箱登录)
|
||||
$member = $this->dao->query()
|
||||
->where(function ($query) use ($data) {
|
||||
$query->where('username', $data['username'])
|
||||
->orWhere('phone', $data['username'])
|
||||
->orWhere('email', $data['username']);
|
||||
})->first();
|
||||
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 401);
|
||||
}
|
||||
|
||||
// 验证用户状态
|
||||
if ($member->enabled !== EnabledStatus::ENABLED->value) {
|
||||
throw new \Exception('用户已被禁用', 401);
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!$member->verifyPassword($data['password'])) {
|
||||
throw new \Exception('密码错误', 401);
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
$this->updateLoginInfo($member);
|
||||
$userInfo = [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'client' => 'web',
|
||||
'access_exp' => 7200,
|
||||
'refresh_exp' => 604800,
|
||||
];
|
||||
// 使用新的 JwtToken 生成 token
|
||||
$jwt = new JwtToken();
|
||||
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
|
||||
$token = [
|
||||
'access_token' => $tokenObj->accessToken,
|
||||
'refresh_token' => $tokenObj->refreshToken,
|
||||
'expires_in' => $tokenObj->expiresIn
|
||||
];
|
||||
return array_merge($token, ['user_info' => $userInfo]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机验证码登录
|
||||
*/
|
||||
public function loginWithMobile(array $data): array
|
||||
{
|
||||
if (empty($data['mobile']) || empty($data['code'])) {
|
||||
throw new \Exception('手机号和验证码不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
$this->verifySmsCode($data['mobile'], $data['code']);
|
||||
|
||||
// 查找用户
|
||||
$member = $this->dao->query()->where('phone', $data['mobile'])->first();
|
||||
|
||||
if (!$member) {
|
||||
// 自动注册
|
||||
$member = $this->autoRegisterWithMobile($data['mobile']);
|
||||
}
|
||||
|
||||
// 验证用户状态
|
||||
if ($member->enabled !== EnabledStatus::ENABLED->value) {
|
||||
throw new \Exception('用户已被禁用', 401);
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
$this->updateLoginInfo($member);
|
||||
|
||||
// 生成token
|
||||
$userInfo = [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'client' => 'web',
|
||||
'access_exp' => 7200,
|
||||
'refresh_exp' => 604800,
|
||||
];
|
||||
// 使用新的 JwtToken 生成 token
|
||||
$jwt = new JwtToken();
|
||||
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
|
||||
$token = [
|
||||
'access_token' => $tokenObj->accessToken,
|
||||
'refresh_token' => $tokenObj->refreshToken,
|
||||
'expires_in' => $tokenObj->expiresIn
|
||||
];
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'user_info' => [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'points' => $member->points,
|
||||
'balance' => $member->balance,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户退出登录
|
||||
*/
|
||||
public function logout(): array
|
||||
{
|
||||
$request = \request();
|
||||
$token = $request->header('Authorization');
|
||||
|
||||
if ($token) {
|
||||
// 处理 token 前缀
|
||||
if (str_starts_with($token, 'Bearer ')) {
|
||||
$token = substr($token, 7);
|
||||
}
|
||||
// 使用新的 JwtToken 实现退出登录
|
||||
try {
|
||||
(new JwtToken())->logout($token);
|
||||
} catch (\Exception $e) {
|
||||
// 忽略异常,确保退出成功
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'message' => '退出成功',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
public function register(array $data): array
|
||||
{
|
||||
// 创建用户
|
||||
$memberData = [
|
||||
'username' => $data['username'],
|
||||
'password' => $data['password'],
|
||||
'nickname' => $data['username'],
|
||||
'phone' => $data['mobile'] ?? '',
|
||||
'email' => $data['email'] ?? null,
|
||||
'enabled' => EnabledStatus::ENABLED->value,
|
||||
];
|
||||
$member = $this->dao->save($memberData);
|
||||
|
||||
// 更新登录信息
|
||||
$this->updateLoginInfo($member);
|
||||
$userInfo = [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'client' => 'web',
|
||||
'access_exp' => 7200,
|
||||
'refresh_exp' => 604800,
|
||||
];
|
||||
// 使用新的 JwtToken 生成 token
|
||||
$jwt = new JwtToken();
|
||||
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
|
||||
$token = [
|
||||
'access_token' => $tokenObj->accessToken,
|
||||
'refresh_token' => $tokenObj->refreshToken,
|
||||
'expires_in' => $tokenObj->expiresIn
|
||||
];
|
||||
return array_merge($token, [
|
||||
'user_info' => [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'points' => $member->points,
|
||||
'balance' => $member->balance,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号注册
|
||||
*/
|
||||
public function registerWithMobile(array $data): array
|
||||
{
|
||||
if (empty($data['mobile']) || empty($data['code']) || empty($data['password'])) {
|
||||
throw new \Exception('手机号、验证码和密码不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
$this->verifySmsCode($data['mobile'], $data['code']);
|
||||
|
||||
// 检查手机号是否已存在
|
||||
if ($this->dao->query()->where('phone', $data['mobile'])->exists()) {
|
||||
throw new \Exception('手机号已存在', 400);
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
$memberData = [
|
||||
'username' => 'user_' . substr($data['mobile'], -4),
|
||||
'password' => $data['password'],
|
||||
'nickname' => '用户_' . substr($data['mobile'], -4),
|
||||
'phone' => $data['mobile'],
|
||||
'enabled' => EnabledStatus::ENABLED->value,
|
||||
];
|
||||
|
||||
$member = $this->dao->create($memberData);
|
||||
|
||||
return [
|
||||
'user_id' => $member->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号
|
||||
*/
|
||||
public function bindMobile(array $data): array
|
||||
{
|
||||
if (empty($data['mobile']) || empty($data['code'])) {
|
||||
throw new \Exception('手机号和验证码不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
$this->verifySmsCode($data['mobile'], $data['code']);
|
||||
|
||||
// 获取当前用户
|
||||
$memberId = $this->getCurrentMemberId();
|
||||
$member = $this->dao->find($memberId);
|
||||
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 401);
|
||||
}
|
||||
|
||||
// 检查手机号是否已被其他用户绑定
|
||||
$existingMember = $this->dao->query()
|
||||
->where('phone', $data['mobile'])
|
||||
->where('id', '!=', $memberId)
|
||||
->first();
|
||||
|
||||
if ($existingMember) {
|
||||
throw new \Exception('手机号已被其他用户绑定', 400);
|
||||
}
|
||||
|
||||
// 更新手机号
|
||||
$member->phone = $data['mobile'];
|
||||
$member->save();
|
||||
|
||||
return [
|
||||
'message' => '绑定成功',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新登录信息
|
||||
*/
|
||||
private function updateLoginInfo(Member $member): void
|
||||
{
|
||||
$member->last_login_time = time();
|
||||
$member->last_login_ip = \request()->getRealIp();
|
||||
$member->login_count = $member->login_count + 1;
|
||||
$member->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成token
|
||||
*/
|
||||
private function generateToken(int $memberId): string
|
||||
{
|
||||
$token = md5(uniqid() . $memberId . microtime(true));
|
||||
$expireTime = 7 * 24 * 3600; // 7天过期
|
||||
|
||||
// 存储token到Redis
|
||||
Redis::setex('user_token:' . md5($token), $expireTime, json_encode([
|
||||
'member_id' => $memberId,
|
||||
'login_time' => time(),
|
||||
]));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证短信验证码
|
||||
*/
|
||||
private function verifySmsCode(string $mobile, string $code): void
|
||||
{
|
||||
$cacheKey = 'sms_code:' . $mobile;
|
||||
$cachedCode = Redis::get($cacheKey);
|
||||
|
||||
if (!$cachedCode || $cachedCode !== $code) {
|
||||
throw new \Exception('验证码错误或已过期', 400);
|
||||
}
|
||||
|
||||
// 验证成功后删除验证码
|
||||
Redis::del($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱和验证码(忘记密码第一步)
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function verifyEmail(array $data): array
|
||||
{
|
||||
// 验证参数
|
||||
if (empty($data['email']) || empty($data['code'])) {
|
||||
throw new \Exception('邮箱和验证码不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
if (isset($data['captcha_key']) && isset($data['captcha_code'])) {
|
||||
$this->verifyCaptcha($data['captcha_key'], $data['captcha_code']);
|
||||
}
|
||||
|
||||
// 检查邮箱是否存在
|
||||
$member = $this->dao->query()->where('email', $data['email'])->first();
|
||||
|
||||
// 生成临时token用于第二步密码重置
|
||||
$resetToken = $this->generateResetToken($member->id);
|
||||
|
||||
return [
|
||||
'reset_token' => $resetToken,
|
||||
'email' => $member->email,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(忘记密码第二步)
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function forgetPassword(array $data): void
|
||||
{
|
||||
// 验证参数
|
||||
if (empty($data['email']) || empty($data['code']) || empty($data['new_password'])) {
|
||||
throw new \Exception('邮箱、验证码和新密码不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证新密码和确认密码是否一致
|
||||
if (isset($data['confirm_password']) && $data['new_password'] !== $data['confirm_password']) {
|
||||
throw new \Exception('两次输入的密码不一致', 400);
|
||||
}
|
||||
|
||||
// 验证重置token(如果使用)
|
||||
if (isset($data['reset_token'])) {
|
||||
$this->validateResetToken($data['reset_token']);
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
$member = $this->dao->query()->where('email', $data['email'])->first();
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 404);
|
||||
}
|
||||
|
||||
// 验证用户状态
|
||||
if ($member->enabled !== EnabledStatus::ENABLED->value) {
|
||||
throw new \Exception('用户已被禁用', 401);
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
$cacheKey = 'email_code:' . $data['email'];
|
||||
$cachedCode = Redis::get($cacheKey);
|
||||
|
||||
if (!$cachedCode || $cachedCode !== $data['code']) {
|
||||
throw new \Exception('邮箱验证码错误或已过期', 400);
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
$member->password = $data['new_password'];
|
||||
$member->save();
|
||||
|
||||
// 清除验证码
|
||||
Redis::del($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证图片验证码
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function verifyCaptcha(string $key, string $code): void
|
||||
{
|
||||
$cachedCode = Redis::get('captcha:' . $key);
|
||||
|
||||
if (!$cachedCode || strtoupper($cachedCode) !== strtoupper($code)) {
|
||||
throw new \Exception('验证码错误', -1);
|
||||
}
|
||||
// 验证成功后删除验证码
|
||||
Redis::del('captcha:' . $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成密码重置token
|
||||
*/
|
||||
private function generateResetToken(int $memberId): string
|
||||
{
|
||||
$token = md5(uniqid() . $memberId . microtime(true));
|
||||
|
||||
// 存储token到Redis,有效期30分钟
|
||||
Redis::setex('password_reset:' . $token, 1800, json_encode([
|
||||
'member_id' => $memberId,
|
||||
'expire' => time() + 1800,
|
||||
]));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码重置token
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function validateResetToken(string $token): void
|
||||
{
|
||||
$tokenData = Redis::get('password_reset:' . $token);
|
||||
if (!$tokenData) {
|
||||
throw new \Exception('重置链接已过期,请重新获取', 400);
|
||||
}
|
||||
$data = json_decode($tokenData, true);
|
||||
if ($data['expire'] < time()) {
|
||||
Redis::del('password_reset:' . $token);
|
||||
throw new \Exception('重置链接已过期,请重新获取', 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function sendEmailCode(string $email): void
|
||||
{
|
||||
// 验证邮箱格式
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \Exception('邮箱格式不正确', 400);
|
||||
}
|
||||
|
||||
// 检查邮箱是否存在
|
||||
$member = $this->dao->query()->where('email', $email)->first();
|
||||
if (!$member) {
|
||||
throw new \Exception('该邮箱未注册', 404);
|
||||
}
|
||||
|
||||
// 检查是否频繁发送
|
||||
$cacheKey = 'email_code_limit:' . $email;
|
||||
$lastSend = Redis::get($cacheKey);
|
||||
|
||||
if ($lastSend && (time() - $lastSend) < 60) {
|
||||
throw new \Exception('发送过于频繁,请稍后再试', 400);
|
||||
}
|
||||
|
||||
// 生成验证码
|
||||
$code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
// 存储验证码到Redis,有效期5分钟
|
||||
Redis::setex('email_code:' . $email, 300, $code);
|
||||
|
||||
// 记录发送时间
|
||||
Redis::setex('email_code_limit:' . $email, 60, time());
|
||||
|
||||
// 获取系统配置
|
||||
$configService = Container::make(ConfigService::class);
|
||||
$siteConfig = $configService->config('web_site_setting');
|
||||
$siteName = $siteConfig['site_name'] ?? 'madong.tech';
|
||||
|
||||
// 构建邮件主题和内容
|
||||
$subject = $siteName . '社区找回密码';
|
||||
$content = $this->buildResetPasswordEmailTemplate($code, $member->nickname ?? $member->username);
|
||||
|
||||
// 发送邮件
|
||||
/** @var MailService $mailService */
|
||||
$mailService = Container::make(MailService::class);
|
||||
$mailService->send($email, $subject, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建密码重置邮件模板
|
||||
*
|
||||
* @param string $code 验证码
|
||||
* @param string $nickname 用户昵称
|
||||
*
|
||||
* @return string HTML邮件内容
|
||||
*/
|
||||
private function buildResetPasswordEmailTemplate(string $code, string $nickname): string
|
||||
{
|
||||
// 获取系统配置
|
||||
$configService = Container::make(ConfigService::class);
|
||||
$siteConfig = $configService->config('web_site_setting');
|
||||
$siteName = $siteConfig['site_name'] ?? "madong.tech";
|
||||
$year = date('Y');
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>密码重置验证码</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px 40px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.email-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.email-body {
|
||||
padding: 40px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.message {
|
||||
font-size: 14px;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
color: #555555;
|
||||
}
|
||||
.code-section {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #4a90e2;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.code-label {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.code {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.expiry-notice {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.security-tip {
|
||||
background-color: #e8f4f8;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.security-tip h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.security-tip ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px 40px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
.site-name {
|
||||
font-weight: 600;
|
||||
color: #4a90e2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<h1>密码重置验证</h1>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<div class="greeting">尊敬的 {$nickname},</div>
|
||||
<div class="message">
|
||||
您正在请求重置 <span class="site-name">{$siteName}</span> 账户的密码。请使用以下验证码完成身份验证:
|
||||
</div>
|
||||
<div class="code-section">
|
||||
<div class="code-label">验证码</div>
|
||||
<div class="code">{$code}</div>
|
||||
<div class="expiry-notice">验证码有效期为 5 分钟</div>
|
||||
</div>
|
||||
<div class="security-tip">
|
||||
<h3>安全提示</h3>
|
||||
<ul>
|
||||
<li>请勿将此验证码透露给任何第三方</li>
|
||||
<li>如果您没有发起此请求,请忽略此邮件</li>
|
||||
<li>为了账户安全,建议定期更换密码</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>此邮件由 <span class="site-name">{$siteName}</span> 系统自动发送,请勿直接回复</p>
|
||||
<p>© {$year} {$siteName}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过手机号自动注册
|
||||
*/
|
||||
private function autoRegisterWithMobile(string $mobile): Member
|
||||
{
|
||||
$memberData = [
|
||||
'username' => 'user_' . substr($mobile, -4),
|
||||
'password' => substr(md5(uniqid()), 0, 8),
|
||||
'nickname' => '用户_' . substr($mobile, -4),
|
||||
'phone' => $mobile,
|
||||
'enabled' => EnabledStatus::ENABLED->value,
|
||||
];
|
||||
|
||||
return $this->dao->create($memberData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*/
|
||||
private function getCurrentMemberId(): int
|
||||
{
|
||||
try {
|
||||
// 使用新的 JwtToken 获取当前用户ID
|
||||
$jwt = new JwtToken();
|
||||
$mid = $jwt->id();
|
||||
return $mid ?? 0;
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('登录已过期', 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\auth;
|
||||
|
||||
use app\api\CurrentMember;
|
||||
use app\dao\member\MemberThirdPartyDao;
|
||||
use app\enum\member\ThirdPartyPlatform;
|
||||
use core\base\BaseService;
|
||||
use support\Container;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 第三方认证服务
|
||||
*/
|
||||
class ThirdPartyAuthService extends BaseService
|
||||
{
|
||||
public function __construct(private readonly MemberThirdPartyDao $thirdPartyDao)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第三方绑定列表
|
||||
*/
|
||||
public function getThirdPartyList(): array
|
||||
{
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
$thirdParties = $this->thirdPartyDao->getMemberThirdParties($memberId);
|
||||
$platforms = [
|
||||
[
|
||||
'type' => 'qq',
|
||||
'name' => 'QQ',
|
||||
'platform' => ThirdPartyPlatform::QQ->value,
|
||||
'binded' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'wechat',
|
||||
'name' => '微信',
|
||||
'platform' => ThirdPartyPlatform::WECHAT->value,
|
||||
'binded' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'douyin',
|
||||
'name' => '抖音',
|
||||
'platform' => ThirdPartyPlatform::DOUYIN->value,
|
||||
'binded' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'weibo',
|
||||
'name' => '微博',
|
||||
'platform' => ThirdPartyPlatform::WEIBO->value,
|
||||
'binded' => false,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($thirdParties as $party) {
|
||||
foreach ($platforms as &$platform) {
|
||||
if ($platform['platform'] == $party['platform']) {
|
||||
$platform['binded'] = true;
|
||||
$platform['info'] = [
|
||||
'account' => $party['nickname'] ?? $party['openid'] ?? '',
|
||||
'avatar' => $party['avatar'] ?? '',
|
||||
'gender' => $party['gender'] ?? 0,
|
||||
'openid' => $party['openid'] ?? '',
|
||||
];
|
||||
|
||||
$bindTime = $party['updated_at'] ?? '';
|
||||
if ($bindTime) {
|
||||
$platform['bind_time'] = date('Y-m-d H:i:s', strtotime($bindTime));
|
||||
} else {
|
||||
$platform['bind_time'] = '';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $platforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定第三方账号
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function bindThirdParty(array $data): void
|
||||
{
|
||||
if (empty($data['platform']) || empty($data['openid'])) {
|
||||
throw new \Exception('平台和OpenID不能为空', 400);
|
||||
}
|
||||
|
||||
$platform = ThirdPartyPlatform::tryFrom((int)$data['platform']);
|
||||
if (!$platform) {
|
||||
throw new \Exception('无效的平台类型', 400);
|
||||
}
|
||||
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
if ($this->thirdPartyDao->isBound($platform->value, $data['openid'])) {
|
||||
throw new \Exception('该第三方账号已被其他用户绑定', 400);
|
||||
}
|
||||
|
||||
$bindData = [
|
||||
'openid' => $data['openid'],
|
||||
'unionid' => $data['unionid'] ?? '',
|
||||
'nickname' => $data['nickname'] ?? '',
|
||||
'avatar' => $data['avatar'] ?? '',
|
||||
'gender' => $data['gender'] ?? 0,
|
||||
'country' => $data['country'] ?? '',
|
||||
'province' => $data['province'] ?? '',
|
||||
'city' => $data['city'] ?? '',
|
||||
'access_token' => $data['access_token'] ?? '',
|
||||
'refresh_token' => $data['refresh_token'] ?? '',
|
||||
];
|
||||
|
||||
$this->thirdPartyDao->bind($memberId, $platform->value, $data['openid'], $bindData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑第三方账号
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function unbindThirdParty(int $platform): void
|
||||
{
|
||||
$platformEnum = ThirdPartyPlatform::tryFrom($platform);
|
||||
if (!$platformEnum) {
|
||||
throw new \Exception('无效的平台类型', 400);
|
||||
}
|
||||
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
$thirdParty = $this->thirdPartyDao->findByMemberAndPlatform($memberId, $platform);
|
||||
if (!$thirdParty) {
|
||||
throw new \Exception('未找到绑定记录', 404);
|
||||
}
|
||||
|
||||
$this->thirdPartyDao->unbind($thirdParty);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成第三方绑定二维码
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function generateBindQrCode(array $data): array
|
||||
{
|
||||
if (empty($data['platform'])) {
|
||||
throw new \Exception('平台不能为空');
|
||||
}
|
||||
|
||||
$platform = ThirdPartyPlatform::tryFrom((int)$data['platform']);
|
||||
if (!$platform) {
|
||||
throw new \Exception('无效的平台类型');
|
||||
}
|
||||
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
$sceneId = 'bind_' . $platform->value . '_' . $memberId . '_' . time() . '_' . mt_rand(1000, 9999);
|
||||
|
||||
$bindUrl = $this->buildBindUrl($platform->value, $sceneId);
|
||||
|
||||
$bindInfo = [
|
||||
'scene_id' => $sceneId,
|
||||
'member_id' => $memberId,
|
||||
'platform' => $platform->value,
|
||||
'status' => 'pending',
|
||||
'created_at' => time(),
|
||||
];
|
||||
Redis::setex('third_party_bind:' . $sceneId, 300, json_encode($bindInfo));
|
||||
|
||||
return [
|
||||
'scene_id' => $sceneId,
|
||||
'qr_code' => $bindUrl,
|
||||
'expires_in' => 300,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查绑定二维码状态
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function checkBindQrStatus(string $sceneId): array
|
||||
{
|
||||
if (empty($sceneId)) {
|
||||
throw new \Exception('场景ID不能为空', 400);
|
||||
}
|
||||
|
||||
$cacheKey = 'third_party_bind:' . $sceneId;
|
||||
$bindInfo = Redis::get($cacheKey);
|
||||
|
||||
if (!$bindInfo) {
|
||||
throw new \Exception('二维码已过期或不存在', 400);
|
||||
}
|
||||
|
||||
$bindInfo = json_decode($bindInfo, true);
|
||||
|
||||
return [
|
||||
'scene_id' => $sceneId,
|
||||
'status' => $bindInfo['status'] ?? 'pending',
|
||||
'platform' => $bindInfo['platform'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建绑定URL
|
||||
*/
|
||||
private function buildBindUrl(int $platform, string $sceneId): string
|
||||
{
|
||||
$baseUrl = config('app.url', 'http://localhost');
|
||||
|
||||
$bindUrls = [
|
||||
1 => 'https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=' . config('third_party.qq.app_id', '') . '&redirect_uri=' . urlencode($baseUrl . '/api/auth/qq/callback?scene=' . $sceneId),
|
||||
2 => 'https://open.weixin.qq.com/connect/qrconnect?appid=' . config('third_party.wechat.app_id', '') . '&redirect_uri=' . urlencode($baseUrl . '/api/auth/wechat/callback?scene=' . $sceneId) . '&response_type=code&scope=snsapi_login',
|
||||
3 => 'https://api.weibo.com/oauth2/authorize?client_id=' . config('third_party.weibo.app_id', '') . '&response_type=code&redirect_uri=' . urlencode($baseUrl . '/api/auth/weibo/callback?scene=' . $sceneId),
|
||||
4 => 'https://open.douyin.com/oauth2/authorize?client_key=' . config('third_party.douyin.app_id', '') . '&response_type=code&redirect_uri=' . urlencode($baseUrl . '/api/auth/douyin/callback?scene=' . $sceneId) . '&scope=user_info',
|
||||
];
|
||||
|
||||
$authUrl = $bindUrls[$platform] ?? '';
|
||||
|
||||
if (empty($authUrl)) {
|
||||
return '';
|
||||
}
|
||||
return 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' . urlencode($authUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理QQ回调
|
||||
*/
|
||||
public function handleQqCallback(string $scene, string $code): void
|
||||
{
|
||||
$bindInfo = $this->getBindInfo($scene);
|
||||
if (!$bindInfo) {
|
||||
throw new \Exception('二维码已过期或不存在', 400);
|
||||
}
|
||||
|
||||
$userInfo = $this->getQqUserInfo($code);
|
||||
if (!$userInfo) {
|
||||
throw new \Exception('获取QQ用户信息失败', 400);
|
||||
}
|
||||
|
||||
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::QQ->value, $userInfo);
|
||||
$this->updateBindStatus($scene, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理微信回调
|
||||
*/
|
||||
public function handleWechatCallback(string $scene, string $code): void
|
||||
{
|
||||
$bindInfo = $this->getBindInfo($scene);
|
||||
if (!$bindInfo) {
|
||||
throw new \Exception('二维码已过期或不存在', 400);
|
||||
}
|
||||
|
||||
$userInfo = $this->getWechatUserInfo($code);
|
||||
if (!$userInfo) {
|
||||
throw new \Exception('获取微信用户信息失败', 400);
|
||||
}
|
||||
|
||||
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::WECHAT->value, $userInfo);
|
||||
$this->updateBindStatus($scene, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理微博回调
|
||||
*/
|
||||
public function handleWeiboCallback(string $scene, string $code): void
|
||||
{
|
||||
$bindInfo = $this->getBindInfo($scene);
|
||||
if (!$bindInfo) {
|
||||
throw new \Exception('二维码已过期或不存在', 400);
|
||||
}
|
||||
|
||||
$userInfo = $this->getWeiboUserInfo($code);
|
||||
if (!$userInfo) {
|
||||
throw new \Exception('获取微博用户信息失败', 400);
|
||||
}
|
||||
|
||||
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::WEIBO->value, $userInfo);
|
||||
$this->updateBindStatus($scene, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理抖音回调
|
||||
*/
|
||||
public function handleDouyinCallback(string $scene, string $code): void
|
||||
{
|
||||
$bindInfo = $this->getBindInfo($scene);
|
||||
if (!$bindInfo) {
|
||||
throw new \Exception('二维码已过期或不存在', 400);
|
||||
}
|
||||
|
||||
$userInfo = $this->getDouyinUserInfo($code);
|
||||
if (!$userInfo) {
|
||||
throw new \Exception('获取抖音用户信息失败', 400);
|
||||
}
|
||||
|
||||
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::DOUYIN->value, $userInfo);
|
||||
$this->updateBindStatus($scene, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取绑定信息
|
||||
*/
|
||||
private function getBindInfo(string $scene): ?array
|
||||
{
|
||||
$cacheKey = 'third_party_bind:' . $scene;
|
||||
$bindInfo = Redis::get($cacheKey);
|
||||
if (!$bindInfo) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($bindInfo, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新绑定状态
|
||||
*/
|
||||
private function updateBindStatus(string $scene, string $status): void
|
||||
{
|
||||
$cacheKey = 'third_party_bind:' . $scene;
|
||||
$bindInfo = Redis::get($cacheKey);
|
||||
|
||||
if ($bindInfo) {
|
||||
$bindInfo = json_decode($bindInfo, true);
|
||||
$bindInfo['status'] = $status;
|
||||
Redis::setex($cacheKey, 300, json_encode($bindInfo));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存第三方绑定信息
|
||||
*/
|
||||
private function saveThirdPartyBind(int $memberId, int $platform, array $userInfo): void
|
||||
{
|
||||
$bindData = [
|
||||
'openid' => $userInfo['openid'] ?? '',
|
||||
'unionid' => $userInfo['unionid'] ?? '',
|
||||
'nickname' => $userInfo['nickname'] ?? '',
|
||||
'avatar' => $userInfo['avatar'] ?? '',
|
||||
'gender' => $userInfo['gender'] ?? 0,
|
||||
'country' => $userInfo['country'] ?? '',
|
||||
'province' => $userInfo['province'] ?? '',
|
||||
'city' => $userInfo['city'] ?? '',
|
||||
'access_token' => $userInfo['access_token'] ?? '',
|
||||
'refresh_token' => $userInfo['refresh_token'] ?? '',
|
||||
];
|
||||
|
||||
$this->thirdPartyDao->bind($memberId, $platform, $bindData['openid'], $bindData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取QQ用户信息
|
||||
*/
|
||||
private function getQqUserInfo(string $code): ?array
|
||||
{
|
||||
$appId = config('third_party.qq.app_id', '');
|
||||
$appSecret = config('third_party.qq.app_secret', '');
|
||||
$redirectUri = config('third_party.qq.redirect_uri', '');
|
||||
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
throw new \Exception('QQ配置不完整', 400);
|
||||
}
|
||||
|
||||
$tokenUrl = "https://graph.qq.com/oauth2.0/token";
|
||||
$params = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $appId,
|
||||
'client_secret' => $appSecret,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
];
|
||||
|
||||
$tokenResponse = $this->httpGet($tokenUrl, $params);
|
||||
if (strpos($tokenResponse, 'access_token') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parse_str($tokenResponse, $tokenData);
|
||||
$accessToken = $tokenData['access_token'] ?? '';
|
||||
$openIdUrl = "https://graph.qq.com/oauth2.0/me?access_token={$accessToken}";
|
||||
|
||||
$openIdResponse = $this->httpGet($openIdUrl);
|
||||
if (strpos($openIdResponse, 'openid') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$openIdData = json_decode(str_replace('callback(', '', str_replace(');', '', $openIdResponse)), true);
|
||||
$openid = $openIdData['openid'] ?? '';
|
||||
|
||||
$userInfoUrl = "https://graph.qq.com/user/get_user_info";
|
||||
$userParams = [
|
||||
'access_token' => $accessToken,
|
||||
'oauth_consumer_key' => $appId,
|
||||
'openid' => $openid,
|
||||
];
|
||||
|
||||
$userResponse = $this->httpGet($userInfoUrl, $userParams);
|
||||
$userData = json_decode($userResponse, true);
|
||||
|
||||
if ($userData['ret'] != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'openid' => $openid,
|
||||
'unionid' => '',
|
||||
'nickname' => $userData['nickname'] ?? '',
|
||||
'avatar' => $userData['figureurl_qq_2'] ?? '',
|
||||
'gender' => $this->convertGender($userData['gender'] ?? ''),
|
||||
'country' => '',
|
||||
'province' => $userData['province'] ?? '',
|
||||
'city' => $userData['city'] ?? '',
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信用户信息
|
||||
*/
|
||||
private function getWechatUserInfo(string $code): ?array
|
||||
{
|
||||
$appId = config('third_party.wechat.app_id', '');
|
||||
$appSecret = config('third_party.wechat.app_secret', '');
|
||||
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
throw new \Exception('微信配置不完整', 400);
|
||||
}
|
||||
|
||||
$tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token";
|
||||
$params = [
|
||||
'appid' => $appId,
|
||||
'secret' => $appSecret,
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
];
|
||||
|
||||
$tokenResponse = $this->httpGet($tokenUrl, $params);
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
|
||||
if (isset($tokenData['errcode'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessToken = $tokenData['access_token'] ?? '';
|
||||
$openid = $tokenData['openid'] ?? '';
|
||||
$unionid = $tokenData['unionid'] ?? '';
|
||||
|
||||
$userInfoUrl = "https://api.weixin.qq.com/sns/userinfo";
|
||||
$userParams = [
|
||||
'access_token' => $accessToken,
|
||||
'openid' => $openid,
|
||||
];
|
||||
|
||||
$userResponse = $this->httpGet($userInfoUrl, $userParams);
|
||||
$userData = json_decode($userResponse, true);
|
||||
|
||||
if (isset($userData['errcode'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'openid' => $openid,
|
||||
'unionid' => $unionid,
|
||||
'nickname' => $userData['nickname'] ?? '',
|
||||
'avatar' => $userData['headimgurl'] ?? '',
|
||||
'gender' => $userData['sex'] ?? 0,
|
||||
'country' => $userData['country'] ?? '',
|
||||
'province' => $userData['province'] ?? '',
|
||||
'city' => $userData['city'] ?? '',
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $tokenData['refresh_token'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微博用户信息
|
||||
*/
|
||||
private function getWeiboUserInfo(string $code): ?array
|
||||
{
|
||||
$appId = config('third_party.weibo.app_id', '');
|
||||
$appSecret = config('third_party.weibo.app_secret', '');
|
||||
$redirectUri = config('third_party.weibo.redirect_uri', '');
|
||||
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
throw new \Exception('微博配置不完整', 400);
|
||||
}
|
||||
|
||||
$tokenUrl = "https://api.weibo.com/oauth2/access_token";
|
||||
$params = [
|
||||
'client_id' => $appId,
|
||||
'client_secret' => $appSecret,
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
];
|
||||
|
||||
$tokenResponse = $this->httpPost($tokenUrl, $params);
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
|
||||
if (isset($tokenData['error'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessToken = $tokenData['access_token'] ?? '';
|
||||
$uid = $tokenData['uid'] ?? '';
|
||||
|
||||
$userInfoUrl = "https://api.weibo.com/2/users/show.json";
|
||||
$userParams = [
|
||||
'access_token' => $accessToken,
|
||||
'uid' => $uid,
|
||||
];
|
||||
|
||||
$userResponse = $this->httpGet($userInfoUrl, $userParams);
|
||||
$userData = json_decode($userResponse, true);
|
||||
|
||||
if (isset($userData['error'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'openid' => $uid,
|
||||
'unionid' => '',
|
||||
'nickname' => $userData['screen_name'] ?? '',
|
||||
'avatar' => $userData['avatar_large'] ?? '',
|
||||
'gender' => $this->convertGender($userData['gender'] ?? ''),
|
||||
'country' => '',
|
||||
'province' => $userData['province'] ?? '',
|
||||
'city' => $userData['city'] ?? '',
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取抖音用户信息
|
||||
*/
|
||||
private function getDouyinUserInfo(string $code): ?array
|
||||
{
|
||||
$appId = config('third_party.douyin.app_id', '');
|
||||
$appSecret = config('third_party.douyin.app_secret', '');
|
||||
|
||||
if (empty($appId) || empty($appSecret)) {
|
||||
throw new \Exception('抖音配置不完整', 400);
|
||||
}
|
||||
|
||||
$tokenUrl = "https://open.douyin.com/oauth/access_token/";
|
||||
$params = [
|
||||
'client_key' => $appId,
|
||||
'client_secret' => $appSecret,
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
];
|
||||
|
||||
$tokenResponse = $this->httpPost($tokenUrl, $params);
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
|
||||
if (isset($tokenData['error_code'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessToken = $tokenData['access_token'] ?? '';
|
||||
$openId = $tokenData['open_id'] ?? '';
|
||||
|
||||
$userInfoUrl = "https://open.douyin.com/oauth/userinfo/";
|
||||
$userParams = [
|
||||
'access_token' => $accessToken,
|
||||
'open_id' => $openId,
|
||||
];
|
||||
|
||||
$userResponse = $this->httpGet($userInfoUrl, $userParams);
|
||||
$userData = json_decode($userResponse, true);
|
||||
|
||||
if (isset($userData['error_code'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'openid' => $openId,
|
||||
'unionid' => $userData['union_id'] ?? '',
|
||||
'nickname' => $userData['nickname'] ?? '',
|
||||
'avatar' => $userData['avatar'] ?? '',
|
||||
'gender' => $userData['gender'] ?? 0,
|
||||
'country' => $userData['country'] ?? '',
|
||||
'province' => $userData['province'] ?? '',
|
||||
'city' => $userData['city'] ?? '',
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $tokenData['refresh_token'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换性别
|
||||
*/
|
||||
private function convertGender(string $gender): int
|
||||
{
|
||||
$genderMap = [
|
||||
'男' => 1,
|
||||
'女' => 2,
|
||||
'm' => 1,
|
||||
'f' => 2,
|
||||
'male' => 1,
|
||||
'female' => 2,
|
||||
];
|
||||
|
||||
return $genderMap[$gender] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP GET请求
|
||||
*/
|
||||
private function httpGet(string $url, array $params = []): string
|
||||
{
|
||||
if (!empty($params)) {
|
||||
$url .= '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
$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_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $response ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP POST请求
|
||||
*/
|
||||
private function httpPost(string $url, array $params = []): string
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $response ?: '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\auth;
|
||||
|
||||
use app\model\member\Member;
|
||||
use app\model\member\MemberThirdParty;
|
||||
use app\enum\common\EnabledStatus;
|
||||
use app\enum\member\ThirdPartyType;
|
||||
use core\base\BaseService;
|
||||
use core\jwt\JwtToken;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 微信认证服务
|
||||
*/
|
||||
class WechatService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 微信授权登录
|
||||
*/
|
||||
public function wechatLogin(array $data): array
|
||||
{
|
||||
if (empty($data['code'])) {
|
||||
throw new \Exception('授权码不能为空', 400);
|
||||
}
|
||||
|
||||
// 获取微信用户信息
|
||||
$wechatUserInfo = $this->getWechatUserInfo($data['code'], $data['encrypted_data'] ?? '', $data['iv'] ?? '');
|
||||
|
||||
// 查找或创建用户
|
||||
$member = $this->findOrCreateMemberByWechat($wechatUserInfo);
|
||||
|
||||
// 验证用户状态
|
||||
if ($member->enabled !== EnabledStatus::ENABLED->value) {
|
||||
throw new \Exception('用户已被禁用', 401);
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
$this->updateLoginInfo($member);
|
||||
|
||||
// 生成token
|
||||
$token = $this->generateToken($member->id);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '登录成功',
|
||||
'data' => [
|
||||
'token' => $token,
|
||||
'user_info' => [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'points' => $member->points,
|
||||
'balance' => $member->balance,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
*/
|
||||
public function weappLogin(array $data): array
|
||||
{
|
||||
if (empty($data['code'])) {
|
||||
throw new \Exception('登录code不能为空', 400);
|
||||
}
|
||||
|
||||
// 获取小程序用户信息
|
||||
$weappUserInfo = $this->getWeappUserInfo($data['code'], $data['encrypted_data'] ?? '', $data['iv'] ?? '');
|
||||
|
||||
// 查找或创建用户
|
||||
$member = $this->findOrCreateMemberByWeapp($weappUserInfo);
|
||||
|
||||
// 验证用户状态
|
||||
if ($member->enabled !== EnabledStatus::ENABLED->value) {
|
||||
throw new \Exception('用户已被禁用', 401);
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
$this->updateLoginInfo($member);
|
||||
|
||||
// 生成token
|
||||
$token = $this->generateToken($member->id);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '登录成功',
|
||||
'data' => [
|
||||
'token' => $token,
|
||||
'user_info' => [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'points' => $member->points,
|
||||
'balance' => $member->balance,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成微信扫码登录二维码
|
||||
*/
|
||||
public function generateWechatQrCode(): array
|
||||
{
|
||||
$sceneId = uniqid('wechat_scan_');
|
||||
$qrCodeUrl = $this->generateQrCodeUrl($sceneId);
|
||||
|
||||
// 存储扫码状态
|
||||
Redis::setex('wechat_scan:' . $sceneId, 300, json_encode([
|
||||
'status' => 'waiting',
|
||||
'created_at' => time(),
|
||||
]));
|
||||
|
||||
return [
|
||||
'scene_id' => $sceneId,
|
||||
'qr_code_url' => $qrCodeUrl,
|
||||
'expire_time' => 300,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
*/
|
||||
public function checkWechatScanStatus(string $sceneId): array
|
||||
{
|
||||
if (empty($sceneId)) {
|
||||
throw new \Exception('场景ID不能为空');
|
||||
}
|
||||
|
||||
$scanData = Redis::get('wechat_scan:' . $sceneId);
|
||||
if (!$scanData) {
|
||||
throw new \Exception('二维码已过期');
|
||||
}
|
||||
|
||||
$data = json_decode($scanData, true);
|
||||
|
||||
if ($data['status'] === 'scanned') {
|
||||
// 扫码成功,返回用户信息
|
||||
$memberId = $data['member_id'] ?? 0;
|
||||
$member = Member::find($memberId);
|
||||
|
||||
if ($member) {
|
||||
// 更新登录信息
|
||||
$this->updateLoginInfo($member);
|
||||
|
||||
// 生成token
|
||||
$userInfo = [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
'phone' => $member->phone,
|
||||
'email' => $member->email,
|
||||
'client' => 'web',
|
||||
'access_exp' => 7200,
|
||||
'refresh_exp' => 604800,
|
||||
];
|
||||
// 使用新的 JwtToken 生成 token
|
||||
$jwt = new JwtToken();
|
||||
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
|
||||
$token = [
|
||||
'access_token' => $tokenObj->accessToken,
|
||||
'refresh_token' => $tokenObj->refreshToken,
|
||||
'expires_in' => $tokenObj->expiresIn
|
||||
];
|
||||
// 删除扫码记录
|
||||
Redis::del('wechat_scan:' . $sceneId);
|
||||
$msg = '扫描成功';
|
||||
$data = [
|
||||
'status' => 'success',
|
||||
'token' => $token,
|
||||
'user_info' => [
|
||||
'id' => $member->id,
|
||||
'username' => $member->username,
|
||||
'nickname' => $member->nickname,
|
||||
'avatar' => $member->avatar,
|
||||
],
|
||||
];
|
||||
return compact('msg', 'data');
|
||||
}
|
||||
}
|
||||
$msg = '查询成功';
|
||||
$data = [
|
||||
'status' => $data['status'],
|
||||
];
|
||||
return compact('msg', 'data');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查微信登录可用性
|
||||
*/
|
||||
public function checkWechatAvailability(): array
|
||||
{
|
||||
$config = config('wechat');
|
||||
return [
|
||||
'wechat_enabled' => !empty($config['app_id']) && !empty($config['app_secret']),
|
||||
'weapp_enabled' => !empty($config['mini_program']['app_id']) && !empty($config['mini_program']['app_secret']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信用户信息
|
||||
*/
|
||||
private function getWechatUserInfo(string $code, string $encryptedData = '', string $iv = ''): array
|
||||
{
|
||||
$config = config('wechat');
|
||||
|
||||
if (empty($config['app_id']) || empty($config['app_secret'])) {
|
||||
throw new \Exception('微信配置未完成', 500);
|
||||
}
|
||||
|
||||
// 获取access_token
|
||||
$accessToken = $this->getWechatAccessToken($code);
|
||||
|
||||
// 获取用户信息
|
||||
$userInfo = $this->getWechatUserInfoByToken($accessToken);
|
||||
|
||||
return [
|
||||
'openid' => $userInfo['openid'] ?? '',
|
||||
'unionid' => $userInfo['unionid'] ?? '',
|
||||
'nickname' => $userInfo['nickname'] ?? '',
|
||||
'avatar' => $userInfo['headimgurl'] ?? '',
|
||||
'gender' => $userInfo['sex'] ?? 0,
|
||||
'type' => ThirdPartyType::WECHAT->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小程序用户信息
|
||||
*/
|
||||
private function getWeappUserInfo(string $code, string $encryptedData = '', string $iv = ''): array
|
||||
{
|
||||
$config = config('wechat.mini_program');
|
||||
|
||||
if (empty($config['app_id']) || empty($config['app_secret'])) {
|
||||
throw new \Exception('小程序配置未完成', 500);
|
||||
}
|
||||
|
||||
// 获取session_key
|
||||
$sessionInfo = $this->getWeappSession($code);
|
||||
|
||||
// 解密用户信息
|
||||
$userInfo = $this->decryptWeappData($encryptedData, $sessionInfo['session_key'], $iv);
|
||||
|
||||
return [
|
||||
'openid' => $sessionInfo['openid'] ?? '',
|
||||
'unionid' => $sessionInfo['unionid'] ?? '',
|
||||
'nickname' => $userInfo['nickName'] ?? '',
|
||||
'avatar' => $userInfo['avatarUrl'] ?? '',
|
||||
'gender' => $userInfo['gender'] ?? 0,
|
||||
'type' => ThirdPartyType::WEAPP->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据微信信息查找或创建用户
|
||||
*/
|
||||
private function findOrCreateMemberByWechat(array $wechatInfo): Member
|
||||
{
|
||||
// 查找第三方绑定
|
||||
$thirdParty = MemberThirdParty::where('openid', $wechatInfo['openid'])
|
||||
->where('type', ThirdPartyType::WECHAT->value)
|
||||
->first();
|
||||
|
||||
if ($thirdParty) {
|
||||
// 返回已绑定的用户
|
||||
return Member::find($thirdParty->member_id);
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
$memberData = [
|
||||
'username' => 'wx_' . substr($wechatInfo['openid'], -8),
|
||||
'password' => substr(md5(uniqid()), 0, 8),
|
||||
'nickname' => $wechatInfo['nickname'] ?? '微信用户',
|
||||
'avatar' => $wechatInfo['avatar'] ?? '',
|
||||
'enabled' => EnabledStatus::ENABLED->value,
|
||||
];
|
||||
|
||||
$member = Member::create($memberData);
|
||||
|
||||
// 创建第三方绑定
|
||||
MemberThirdParty::create([
|
||||
'member_id' => $member->id,
|
||||
'type' => ThirdPartyType::WECHAT->value,
|
||||
'openid' => $wechatInfo['openid'],
|
||||
'unionid' => $wechatInfo['unionid'] ?? '',
|
||||
'nickname' => $wechatInfo['nickname'] ?? '',
|
||||
'avatar' => $wechatInfo['avatar'] ?? '',
|
||||
]);
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据小程序信息查找或创建用户
|
||||
*/
|
||||
private function findOrCreateMemberByWeapp(array $weappInfo): Member
|
||||
{
|
||||
// 查找第三方绑定
|
||||
$thirdParty = MemberThirdParty::where('openid', $weappInfo['openid'])
|
||||
->where('type', ThirdPartyType::WEAPP->value)
|
||||
->first();
|
||||
|
||||
if ($thirdParty) {
|
||||
// 返回已绑定的用户
|
||||
return Member::find($thirdParty->member_id);
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
$memberData = [
|
||||
'username' => 'wxapp_' . substr($weappInfo['openid'], -8),
|
||||
'password' => substr(md5(uniqid()), 0, 8),
|
||||
'nickname' => $weappInfo['nickname'] ?? '小程序用户',
|
||||
'avatar' => $weappInfo['avatar'] ?? '',
|
||||
'enabled' => EnabledStatus::ENABLED->value,
|
||||
];
|
||||
|
||||
$member = Member::create($memberData);
|
||||
|
||||
// 创建第三方绑定
|
||||
MemberThirdParty::create([
|
||||
'member_id' => $member->id,
|
||||
'type' => ThirdPartyType::WEAPP->value,
|
||||
'openid' => $weappInfo['openid'],
|
||||
'unionid' => $weappInfo['unionid'] ?? '',
|
||||
'nickname' => $weappInfo['nickname'] ?? '',
|
||||
'avatar' => $weappInfo['avatar'] ?? '',
|
||||
]);
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码URL
|
||||
*/
|
||||
private function generateQrCodeUrl(string $sceneId): string
|
||||
{
|
||||
$config = config('wechat');
|
||||
$appId = $config['app_id'] ?? '';
|
||||
|
||||
// 这里应该调用微信API生成二维码,暂时返回模拟URL
|
||||
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={$sceneId}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信access_token
|
||||
*/
|
||||
private function getWechatAccessToken(string $code): string
|
||||
{
|
||||
// 这里应该调用微信API获取access_token
|
||||
// 暂时返回模拟数据
|
||||
return 'mock_access_token_' . $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据token获取微信用户信息
|
||||
*/
|
||||
private function getWechatUserInfoByToken(string $accessToken): array
|
||||
{
|
||||
// 这里应该调用微信API获取用户信息
|
||||
// 暂时返回模拟数据
|
||||
return [
|
||||
'openid' => 'mock_openid_' . uniqid(),
|
||||
'nickname' => '微信用户',
|
||||
'headimgurl' => '',
|
||||
'sex' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小程序session
|
||||
*/
|
||||
private function getWeappSession(string $code): array
|
||||
{
|
||||
// 这里应该调用微信小程序API获取session
|
||||
// 暂时返回模拟数据
|
||||
return [
|
||||
'openid' => 'mock_weapp_openid_' . uniqid(),
|
||||
'session_key' => 'mock_session_key',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密小程序数据
|
||||
*/
|
||||
private function decryptWeappData(string $encryptedData, string $sessionKey, string $iv): array
|
||||
{
|
||||
// 这里应该解密小程序数据
|
||||
// 暂时返回模拟数据
|
||||
return [
|
||||
'nickName' => '小程序用户',
|
||||
'avatarUrl' => '',
|
||||
'gender' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新登录信息
|
||||
*/
|
||||
private function updateLoginInfo(Member $member): void
|
||||
{
|
||||
$member->last_login_time = date('Y-m-d H:i:s');
|
||||
$member->last_login_ip = \request()->getRealIp();
|
||||
$member->login_count = $member->login_count + 1;
|
||||
$member->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成token
|
||||
*/
|
||||
private function generateToken(int $memberId): string
|
||||
{
|
||||
$token = md5(uniqid() . $memberId . microtime(true));
|
||||
$expireTime = 7 * 24 * 3600; // 7天过期
|
||||
|
||||
// 存储token到Redis
|
||||
Redis::setex('user_token:' . md5($token), $expireTime, json_encode([
|
||||
'member_id' => $memberId,
|
||||
'login_time' => time(),
|
||||
]));
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\member;
|
||||
|
||||
use app\api\CurrentMember;
|
||||
use app\dao\member\MemberThirdPartyDao;
|
||||
use app\enum\member\ThirdPartyPlatform;
|
||||
use core\base\BaseService;
|
||||
use support\Container;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 会员绑定服务
|
||||
*/
|
||||
class MemberBindService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberThirdPartyDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第三方绑定列表
|
||||
*/
|
||||
public function getThirdPartyList(): array
|
||||
{
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
$thirdParties = $this->dao->getMemberThirdParties($memberId);
|
||||
$platforms = [
|
||||
[
|
||||
'type' => 'qq',
|
||||
'name' => 'QQ',
|
||||
'platform' => ThirdPartyPlatform::QQ->value,
|
||||
'binded' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'wechat',
|
||||
'name' => '微信',
|
||||
'platform' => ThirdPartyPlatform::WECHAT->value,
|
||||
'binded' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'douyin',
|
||||
'name' => '抖音',
|
||||
'platform' => ThirdPartyPlatform::DOUYIN->value,
|
||||
'binded' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'weibo',
|
||||
'name' => '微博',
|
||||
'platform' => ThirdPartyPlatform::WEIBO->value,
|
||||
'binded' => false,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($thirdParties as $party) {
|
||||
foreach ($platforms as &$platform) {
|
||||
if ($platform['platform'] == $party['platform']) {
|
||||
$platform['binded'] = true;
|
||||
$platform['info'] = [
|
||||
'account' => $party['nickname'] ?? $party['openid'] ?? '',
|
||||
'avatar' => $party['avatar'] ?? '',
|
||||
'gender' => $party['gender'] ?? 0,
|
||||
'openid' => $party['openid'] ?? '',
|
||||
];
|
||||
|
||||
$bindTime = $party['updated_at'] ?? '';
|
||||
if ($bindTime) {
|
||||
$platform['bind_time'] = date('Y-m-d H:i:s', strtotime($bindTime));
|
||||
} else {
|
||||
$platform['bind_time'] = '';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $platforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定第三方账号
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function bindThirdParty(array $data): void
|
||||
{
|
||||
if (empty($data['platform']) || empty($data['openid'])) {
|
||||
throw new \Exception('平台和OpenID不能为空', 400);
|
||||
}
|
||||
|
||||
$platform = ThirdPartyPlatform::tryFrom((int)$data['platform']);
|
||||
if (!$platform) {
|
||||
throw new \Exception('无效的平台类型', 400);
|
||||
}
|
||||
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
if ($this->dao->isBound($platform->value, $data['openid'])) {
|
||||
throw new \Exception('该第三方账号已被其他用户绑定', 400);
|
||||
}
|
||||
|
||||
$bindData = [
|
||||
'openid' => $data['openid'],
|
||||
'unionid' => $data['unionid'] ?? '',
|
||||
'nickname' => $data['nickname'] ?? '',
|
||||
'avatar' => $data['avatar'] ?? '',
|
||||
'gender' => $data['gender'] ?? 0,
|
||||
'country' => $data['country'] ?? '',
|
||||
'province' => $data['province'] ?? '',
|
||||
'city' => $data['city'] ?? '',
|
||||
'access_token' => $data['access_token'] ?? '',
|
||||
'refresh_token' => $data['refresh_token'] ?? '',
|
||||
];
|
||||
|
||||
$this->dao->bind($memberId, $platform->value, $data['openid'], $bindData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑第三方账号
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function unbindThirdParty(int $platform): void
|
||||
{
|
||||
$platformEnum = ThirdPartyPlatform::tryFrom($platform);
|
||||
if (!$platformEnum) {
|
||||
throw new \Exception('无效的平台类型', 400);
|
||||
}
|
||||
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
$thirdParty = $this->dao->findByMemberAndPlatform($memberId, $platform);
|
||||
if (!$thirdParty) {
|
||||
throw new \Exception('未找到绑定记录', 404);
|
||||
}
|
||||
|
||||
$this->dao->unbind($thirdParty);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成第三方绑定二维码
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function generateBindQrCode(array $data): array
|
||||
{
|
||||
if (empty($data['platform'])) {
|
||||
throw new \Exception('平台不能为空');
|
||||
}
|
||||
|
||||
$platform = ThirdPartyPlatform::tryFrom((int)$data['platform']);
|
||||
if (!$platform) {
|
||||
throw new \Exception('无效的平台类型');
|
||||
}
|
||||
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$memberId = $currentMember->id();
|
||||
|
||||
if (empty($memberId)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
$sceneId = 'bind_' . $platform->value . '_' . $memberId . '_' . time() . '_' . mt_rand(1000, 9999);
|
||||
|
||||
$bindUrl = $this->buildBindUrl($platform->value, $sceneId);
|
||||
|
||||
$bindInfo = [
|
||||
'scene_id' => $sceneId,
|
||||
'member_id' => $memberId,
|
||||
'platform' => $platform->value,
|
||||
'status' => 'pending',
|
||||
'created_at' => time(),
|
||||
];
|
||||
Redis::setex('third_party_bind:' . $sceneId, 300, json_encode($bindInfo));
|
||||
|
||||
return [
|
||||
'scene_id' => $sceneId,
|
||||
'qr_code' => $bindUrl,
|
||||
'expires_in' => 300,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查绑定二维码状态
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function checkBindQrStatus(string $sceneId): array
|
||||
{
|
||||
if (empty($sceneId)) {
|
||||
throw new \Exception('场景ID不能为空', 400);
|
||||
}
|
||||
|
||||
$cacheKey = 'third_party_bind:' . $sceneId;
|
||||
$bindInfo = Redis::get($cacheKey);
|
||||
|
||||
if (!$bindInfo) {
|
||||
throw new \Exception('二维码已过期或不存在', 400);
|
||||
}
|
||||
|
||||
$bindInfo = json_decode($bindInfo, true);
|
||||
|
||||
return [
|
||||
'scene_id' => $sceneId,
|
||||
'status' => $bindInfo['status'] ?? 'pending',
|
||||
'platform' => $bindInfo['platform'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建绑定URL
|
||||
*/
|
||||
private function buildBindUrl(int $platform, string $sceneId): string
|
||||
{
|
||||
$baseUrl = config('app.url', 'http://localhost');
|
||||
|
||||
$bindUrls = [
|
||||
1 => 'https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=' . config('third_party.qq.app_id', '') . '&redirect_uri=' . urlencode($baseUrl . '/api/auth/qq/callback?scene=' . $sceneId),
|
||||
2 => 'https://open.weixin.qq.com/connect/qrconnect?appid=' . config('third_party.wechat.app_id', '') . '&redirect_uri=' . urlencode($baseUrl . '/api/auth/wechat/callback?scene=' . $sceneId) . '&response_type=code&scope=snsapi_login',
|
||||
3 => 'https://api.weibo.com/oauth2/authorize?client_id=' . config('third_party.weibo.app_id', '') . '&response_type=code&redirect_uri=' . urlencode($baseUrl . '/api/auth/weibo/callback?scene=' . $sceneId),
|
||||
4 => 'https://open.douyin.com/oauth2/authorize?client_key=' . config('third_party.douyin.app_id', '') . '&response_type=code&redirect_uri=' . urlencode($baseUrl . '/api/auth/douyin/callback?scene=' . $sceneId) . '&scope=user_info',
|
||||
];
|
||||
|
||||
$authUrl = $bindUrls[$platform] ?? '';
|
||||
|
||||
if (empty($authUrl)) {
|
||||
return '';
|
||||
}
|
||||
// 使用第三方二维码生成API
|
||||
return 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' . urlencode($authUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\member;
|
||||
|
||||
use app\dao\member\MemberDao;
|
||||
use app\dao\member\MemberPointsDao;
|
||||
use app\adminapi\event\PointsChangedEvent;
|
||||
use app\enum\member\PointSource;
|
||||
use app\enum\member\PointType;
|
||||
use core\base\BaseService;
|
||||
use support\Container;
|
||||
use support\Log;
|
||||
|
||||
class MemberPointsService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly MemberPointsDao $memberPointsDao,
|
||||
MemberDao $dao
|
||||
)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加会员积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $points 积分数量
|
||||
* @param string $remark 备注
|
||||
* @param PointSource $source 积分来源
|
||||
* @param int|string|null $relatedId 关联ID
|
||||
*
|
||||
* @return float 新积分余额
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function addPoints(
|
||||
int|string $memberId,
|
||||
int $points,
|
||||
string $remark = '',
|
||||
PointSource $source = PointSource::OTHER,
|
||||
int|string|null $relatedId = null,
|
||||
?array $extra = null
|
||||
): float
|
||||
{
|
||||
$member = $this->dao->find($memberId);
|
||||
if (!$member) {
|
||||
throw new \Exception('会员不存在');
|
||||
}
|
||||
|
||||
$oldPoints = $member->points;
|
||||
$newPoints = $oldPoints + $points;
|
||||
|
||||
$event = new PointsChangedEvent(
|
||||
$memberId,
|
||||
$points,
|
||||
$source,
|
||||
PointType::INCREASE,
|
||||
$oldPoints,
|
||||
$newPoints,
|
||||
$remark,
|
||||
$relatedId,
|
||||
$extra
|
||||
);
|
||||
$event->dispatch();
|
||||
|
||||
return $this->getPointsBalance($memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少会员积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $points 积分数量
|
||||
* @param string $remark 备注
|
||||
* @param PointSource $source 积分来源
|
||||
* @param int|string|null $relatedId 关联ID
|
||||
*
|
||||
* @return float 新积分余额
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deductPoints(
|
||||
int|string $memberId,
|
||||
int $points,
|
||||
string $remark = '',
|
||||
PointSource $source = PointSource::OTHER,
|
||||
int|string|null $relatedId = null,
|
||||
?array $extra = null
|
||||
): float
|
||||
{
|
||||
$member = $this->dao->find($memberId);
|
||||
if (!$member) {
|
||||
throw new \Exception('会员不存在');
|
||||
}
|
||||
|
||||
$oldPoints = $member->points;
|
||||
if ($oldPoints < $points) {
|
||||
throw new \Exception('积分不足');
|
||||
}
|
||||
$newPoints = $oldPoints - $points;
|
||||
|
||||
$event = new PointsChangedEvent(
|
||||
$memberId,
|
||||
$points,
|
||||
$source,
|
||||
PointType::DECREASE,
|
||||
$oldPoints,
|
||||
$newPoints,
|
||||
$remark,
|
||||
$relatedId,
|
||||
$extra
|
||||
);
|
||||
$event->dispatch();
|
||||
|
||||
return $this->getPointsBalance($memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员积分记录
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $limit 记录数量
|
||||
*
|
||||
* @return array 积分记录列表
|
||||
*/
|
||||
public function getPointsRecords(int|string $memberId, int $limit = 20): array
|
||||
{
|
||||
return $this->memberPointsDao->getMemberPoints($memberId, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员积分余额
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
*
|
||||
* @return float 积分余额
|
||||
*/
|
||||
public function getPointsBalance(int|string $memberId): float
|
||||
{
|
||||
return $this->memberPointsDao->getMemberBalance($memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会员积分是否足够
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $points 需要的积分数量
|
||||
*
|
||||
* @return bool 积分是否足够
|
||||
*/
|
||||
public function isPointsEnough(int|string $memberId, int $points): bool
|
||||
{
|
||||
$balance = $this->getPointsBalance($memberId);
|
||||
return $balance >= $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取积分流水记录(分页)
|
||||
*
|
||||
* @param array $params 查询参数
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getPointsRecord(array $params): array
|
||||
{
|
||||
$memberId = $params['member_id'] ?? 0;
|
||||
$page = $params['page'] ?? 1;
|
||||
$limit = $params['limit'] ?? 20;
|
||||
$type = $params['type'] ?? null;
|
||||
|
||||
if (!$memberId) {
|
||||
throw new \Exception('会员ID不能为空');
|
||||
}
|
||||
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$query = $this->memberPointsDao->query()
|
||||
->where('member_id', '=', $memberId);
|
||||
|
||||
if ($type) {
|
||||
$query->where('type', '=', $type);
|
||||
}
|
||||
|
||||
$total = $query->count();
|
||||
$items = $query->orderBy('created_at', 'desc')
|
||||
->offset($offset)
|
||||
->limit($limit)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员积分总额统计
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getPointsTotal(int|string $memberId): array
|
||||
{
|
||||
if (!$memberId) {
|
||||
throw new \Exception('会员ID不能为空');
|
||||
}
|
||||
|
||||
$currentPoints = $this->getPointsBalance($memberId);
|
||||
|
||||
$monthStart = date('Y-m-01 00:00:00');
|
||||
$monthEnd = date('Y-m-t 23:59:59');
|
||||
|
||||
$monthlyPoints = $this->memberPointsDao->query()
|
||||
->where('member_id', '=', $memberId)
|
||||
->where('type', '=', PointType::INCREASE->value)
|
||||
->whereBetween('created_at', [$monthStart, $monthEnd])
|
||||
->sum('points');
|
||||
|
||||
$totalPoints = $this->memberPointsDao->query()
|
||||
->where('member_id', '=', $memberId)
|
||||
->where('type', '=', PointType::INCREASE->value)
|
||||
->sum('points');
|
||||
|
||||
return [
|
||||
'current_points' => (int)$currentPoints,
|
||||
'monthly_points' => (int)($monthlyPoints ?? 0),
|
||||
'total_points' => (int)($totalPoints ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员等级信息
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getMemberLevel(int|string $memberId): array
|
||||
{
|
||||
if (!$memberId) {
|
||||
throw new \Exception('会员ID不能为空');
|
||||
}
|
||||
|
||||
$member = $this->dao->find($memberId);
|
||||
|
||||
if (!$member) {
|
||||
throw new \Exception('会员不存在');
|
||||
}
|
||||
|
||||
return [
|
||||
'level_id' => $member->level_id,
|
||||
'points' => $member->points,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员签到获取积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $continuousDays 连续签到天数
|
||||
*
|
||||
* @return int 获得的积分数量
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getSignPoints(int|string $memberId, int $continuousDays): int
|
||||
{
|
||||
// 每次签到固定+1积分
|
||||
$points = 1;
|
||||
|
||||
// 触发积分增加事件
|
||||
$this->addPoints(
|
||||
$memberId,
|
||||
$points,
|
||||
'每日签到',
|
||||
PointSource::SIGN_IN
|
||||
);
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员购物获取积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param float $amount 购物金额
|
||||
* @param int|string $orderId 订单ID
|
||||
*
|
||||
* @return int 获得的积分数量
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getShoppingPoints(int|string $memberId, float $amount, int|string $orderId): int
|
||||
{
|
||||
// 计算购物积分(例如:每消费10元获得1积分)
|
||||
$points = (int)($amount / 10);
|
||||
|
||||
if ($points > 0) {
|
||||
$this->addPoints(
|
||||
$memberId,
|
||||
$points,
|
||||
'购物获得积分',
|
||||
PointSource::SHOPPING,
|
||||
$orderId
|
||||
);
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员邀请获取积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int|string $invitedMemberId 被邀请的会员ID
|
||||
*
|
||||
* @return int 获得的积分数量
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getInvitePoints(int|string $memberId, int|string $invitedMemberId): int
|
||||
{
|
||||
// 邀请奖励积分
|
||||
$points = 50;
|
||||
|
||||
$this->addPoints(
|
||||
$memberId,
|
||||
$points,
|
||||
'邀请好友注册',
|
||||
PointSource::INVITE,
|
||||
$invitedMemberId
|
||||
);
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员注册获取积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
*
|
||||
* @return int 获得的积分数量
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getRegisterPoints(int|string $memberId): int
|
||||
{
|
||||
// 注册奖励积分
|
||||
$points = 100;
|
||||
|
||||
$this->addPoints(
|
||||
$memberId,
|
||||
$points,
|
||||
'新会员注册奖励',
|
||||
PointSource::REGISTER
|
||||
);
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员兑换商品扣减积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $points 兑换需要的积分
|
||||
* @param int|string $productId 兑换商品ID
|
||||
*
|
||||
* @return float 扣减后的积分余额
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deductExchangePoints(int|string $memberId, int $points, int|string $productId): float
|
||||
{
|
||||
return $this->deductPoints(
|
||||
$memberId,
|
||||
$points,
|
||||
'兑换商品',
|
||||
PointSource::EXCHANGE,
|
||||
$productId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分过期扣减
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $points 过期的积分数量
|
||||
*
|
||||
* @return float 扣减后的积分余额
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deductExpiredPoints(int|string $memberId, int $points): float
|
||||
{
|
||||
return $this->deductPoints(
|
||||
$memberId,
|
||||
$points,
|
||||
'积分过期',
|
||||
PointSource::EXPIRED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 违规处罚扣减积分
|
||||
*
|
||||
* @param int|string $memberId 会员ID
|
||||
* @param int $points 处罚的积分数量
|
||||
* @param string $reason 处罚原因
|
||||
*
|
||||
* @return float 扣减后的积分余额
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deductPenaltyPoints(int|string $memberId, int $points, string $reason): float
|
||||
{
|
||||
return $this->deductPoints(
|
||||
$memberId,
|
||||
$points,
|
||||
'违规处罚: ' . $reason,
|
||||
PointSource::PENALTY
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\member;
|
||||
|
||||
use app\api\CurrentMember;
|
||||
use app\dao\member\MemberDao;
|
||||
use app\dao\member\MemberThirdPartyDao;
|
||||
use app\model\member\Member;
|
||||
use app\model\member\MemberAddress;
|
||||
use app\enum\common\EnabledStatus;
|
||||
use core\base\BaseService;
|
||||
use support\Container;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 会员用户服务
|
||||
*/
|
||||
class MemberService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会员信息
|
||||
*
|
||||
* @throws \Exception
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function updateInfo(array $data): void
|
||||
{
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$id = $currentMember->id();
|
||||
|
||||
if (empty($id)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
$model = $this->dao->get($id);
|
||||
if (!$model) {
|
||||
throw new \Exception('用户不存在', 401);
|
||||
}
|
||||
|
||||
$allowedFields = ['nickname', 'avatar', 'gender', 'birthday','bio'];
|
||||
$updateData = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
throw new \Exception('没有可更新的数据', 400);
|
||||
}
|
||||
|
||||
$model->fill($updateData);
|
||||
|
||||
if (!$model->isDirty()) {
|
||||
throw new \Exception('数据没有变化', 400);
|
||||
}
|
||||
|
||||
$model->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定会员手机号
|
||||
*/
|
||||
public function bindPhone(array $data): array
|
||||
{
|
||||
if (empty($data['phone']) || empty($data['verify_code'])) {
|
||||
throw new \Exception('手机号和验证码不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证短信验证码
|
||||
$this->verifySmsCode($data['phone'], $data['verify_code']);
|
||||
|
||||
$memberId = $this->getCurrentMemberId();
|
||||
$member = Member::find($memberId);
|
||||
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 401);
|
||||
}
|
||||
|
||||
// 检查手机号是否已被其他用户绑定
|
||||
$existingMember = Member::where('phone', $data['phone'])
|
||||
->where('id', '!=', $memberId)
|
||||
->first();
|
||||
|
||||
if ($existingMember) {
|
||||
throw new \Exception('手机号已被其他用户绑定', 400);
|
||||
}
|
||||
|
||||
// 更新手机号
|
||||
$member->phone = $data['phone'];
|
||||
$member->save();
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '绑定成功',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function changePassword(array $data): void
|
||||
{
|
||||
if (empty($data['old_password']) || empty($data['new_password'])) {
|
||||
throw new \Exception('旧密码和新密码不能为空', 400);
|
||||
}
|
||||
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
$id = $currentMember->id();
|
||||
|
||||
if (empty($id)) {
|
||||
throw new \Exception('用户未登录', 401);
|
||||
}
|
||||
|
||||
$model = $this->dao->get($id);
|
||||
if (!$model) {
|
||||
throw new \Exception('用户不存在', 401);
|
||||
}
|
||||
|
||||
if (!$model->verifyPassword($data['old_password'])) {
|
||||
throw new \Exception('旧密码错误', 400);
|
||||
}
|
||||
|
||||
$model->password = $data['new_password'];
|
||||
$model->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取地址列表
|
||||
*/
|
||||
public function getAddressList(): array
|
||||
{
|
||||
$memberId = $this->getCurrentMemberId();
|
||||
$addresses = MemberAddress::where('member_id', $memberId)
|
||||
->where('enabled', EnabledStatus::ENABLED->value)
|
||||
->orderBy('is_default', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
return $addresses->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建地址
|
||||
*/
|
||||
public function createAddress(array $data): array
|
||||
{
|
||||
if (empty($data['name']) || empty($data['phone']) || empty($data['province']) ||
|
||||
empty($data['city']) || empty($data['district']) || empty($data['address'])) {
|
||||
throw new \Exception('请填写完整的地址信息', 400);
|
||||
}
|
||||
|
||||
$memberId = $this->getCurrentMemberId();
|
||||
|
||||
// 如果设置为默认地址,取消其他默认地址
|
||||
if ($data['is_default'] ?? false) {
|
||||
MemberAddress::where('member_id', $memberId)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
$addressData = [
|
||||
'member_id' => $memberId,
|
||||
'name' => $data['name'],
|
||||
'phone' => $data['phone'],
|
||||
'province' => $data['province'],
|
||||
'city' => $data['city'],
|
||||
'district' => $data['district'],
|
||||
'address' => $data['address'],
|
||||
'is_default' => $data['is_default'] ?? false,
|
||||
'enabled' => EnabledStatus::ENABLED->value,
|
||||
];
|
||||
|
||||
$address = MemberAddress::create($addressData);
|
||||
|
||||
return ['id' => $address->id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新地址
|
||||
*/
|
||||
public function updateAddress(int $id, array $data): array
|
||||
{
|
||||
$memberId = $this->getCurrentMemberId();
|
||||
$address = MemberAddress::where('id', $id)
|
||||
->where('member_id', $memberId)
|
||||
->first();
|
||||
|
||||
if (!$address) {
|
||||
throw new \Exception('地址不存在', 404);
|
||||
}
|
||||
|
||||
// 如果设置为默认地址,取消其他默认地址
|
||||
if ($data['is_default'] ?? false) {
|
||||
MemberAddress::where('member_id', $memberId)
|
||||
->where('id', '!=', $id)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
// 允许更新的字段
|
||||
$allowedFields = ['name', 'phone', 'province', 'city', 'district', 'address', 'is_default'];
|
||||
$updateData = [];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
throw new \Exception('没有可更新的数据', 400);
|
||||
}
|
||||
|
||||
$address->update($updateData);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除地址
|
||||
*/
|
||||
public function deleteAddress(int $id): array
|
||||
{
|
||||
$memberId = $this->getCurrentMemberId();
|
||||
$address = MemberAddress::where('id', $id)
|
||||
->where('member_id', $memberId)
|
||||
->first();
|
||||
|
||||
if (!$address) {
|
||||
throw new \Exception('地址不存在', 404);
|
||||
}
|
||||
|
||||
// 软删除
|
||||
$address->enabled = EnabledStatus::DISABLED->value;
|
||||
$address->save();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证短信验证码
|
||||
*/
|
||||
private function verifySmsCode(string $mobile, string $code): void
|
||||
{
|
||||
$cacheKey = 'sms_code:' . $mobile;
|
||||
$cachedCode = Redis::get($cacheKey);
|
||||
|
||||
if (!$cachedCode || $cachedCode !== $code) {
|
||||
throw new \Exception('验证码错误或已过期', 400);
|
||||
}
|
||||
|
||||
// 验证成功后删除验证码
|
||||
Redis::del($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*/
|
||||
private function getCurrentMemberId(): int
|
||||
{
|
||||
$request = \request();
|
||||
$token = $request->header('Authorization');
|
||||
|
||||
if (!$token) {
|
||||
throw new \Exception('未登录', 401);
|
||||
}
|
||||
|
||||
$tokenData = Redis::get('user_token:' . md5($token));
|
||||
if (!$tokenData) {
|
||||
throw new \Exception('登录已过期', 401);
|
||||
}
|
||||
|
||||
$data = json_decode($tokenData, true);
|
||||
return $data['member_id'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
*/
|
||||
public function uploadAvatar(\Webman\Http\UploadFile $file): array
|
||||
{
|
||||
$memberId = $this->getCurrentMemberId();
|
||||
|
||||
// 验证文件类型
|
||||
$allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!in_array($file->getUploadMimeType(), $allowedTypes)) {
|
||||
throw new \Exception('只支持jpg、png、gif、webp格式的图片', 400);
|
||||
}
|
||||
|
||||
// 验证文件大小(最大2MB)
|
||||
$maxSize = 2 * 1024 * 1024;
|
||||
if ($file->getSize() > $maxSize) {
|
||||
throw new \Exception('图片大小不能超过2MB', 400);
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
$extension = pathinfo($file->getUploadName(), PATHINFO_EXTENSION);
|
||||
$filename = 'avatar_' . $memberId . '_' . time() . '.' . $extension;
|
||||
|
||||
// 保存文件
|
||||
$uploadPath = public_path() . '/uploads/avatar/';
|
||||
if (!is_dir($uploadPath)) {
|
||||
mkdir($uploadPath, 0755, true);
|
||||
}
|
||||
|
||||
$file->moveTo($uploadPath . $filename);
|
||||
|
||||
// 生成访问URL
|
||||
$url = '/uploads/avatar/' . $filename;
|
||||
|
||||
// 更新会员头像
|
||||
$member = Member::find($memberId);
|
||||
if ($member) {
|
||||
$member->avatar = $url;
|
||||
$member->save();
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'filename' => $filename
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃用户
|
||||
*/
|
||||
public function getActiveUsers(int $limit = 10): array
|
||||
{
|
||||
$member = $this->dao->getActiveUsers($limit);
|
||||
return $member->map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'username' => $item->username,
|
||||
'nickname' => $item->nickname,
|
||||
'avatar' => $item->avatar,
|
||||
'intro' => $item->intro ?? "",
|
||||
'last_active' => $item->last_time,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否是管理员
|
||||
* 暂时返回 false,只有问题作者可以设置最佳答案
|
||||
*/
|
||||
public function isAdmin(int|string $userId): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\api\member;
|
||||
|
||||
use app\dao\member\MemberDao;
|
||||
use app\dao\member\MemberSignDao;
|
||||
use app\service\api\member\MemberPointsService;
|
||||
use app\model\member\MemberSign;
|
||||
use app\enum\member\PointSource;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\BadRequestHttpException;
|
||||
use core\exception\handler\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* 会员签到服务
|
||||
*/
|
||||
class MemberSignService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
MemberDao $dao,
|
||||
private MemberSignDao $memberSignDao,
|
||||
private MemberPointsService $memberPointsService
|
||||
)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行签到
|
||||
*
|
||||
* @throws \Exception
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function sign(int|string $memberId, array $deviceInfo = []): array
|
||||
{
|
||||
return $this->transaction(function () use ($memberId, $deviceInfo) {
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// 检查今日是否已签到
|
||||
if ($this->memberSignDao->isSignedToday($memberId, $today)) {
|
||||
throw new BadRequestHttpException('今日已签到');
|
||||
}
|
||||
|
||||
// 获取最近一次签到记录
|
||||
$lastSign = $this->memberSignDao->getLastSign($memberId);
|
||||
|
||||
// 计算连续签到天数
|
||||
$continuousDays = 1;
|
||||
if ($lastSign) {
|
||||
$lastSignDate = $lastSign->sign_date;
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||
|
||||
if ($lastSignDate === $yesterday) {
|
||||
$continuousDays = $lastSign->continuous_days + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建签到记录
|
||||
$signRecord = $this->memberSignDao->created(
|
||||
$memberId,
|
||||
$today,
|
||||
0, // 暂时设为0,后续会通过积分服务更新
|
||||
$continuousDays,
|
||||
$deviceInfo
|
||||
);
|
||||
|
||||
// 增加会员积分(使用积分服务)
|
||||
$points = $this->memberPointsService->getSignPoints((int)$memberId, $continuousDays);
|
||||
|
||||
return [
|
||||
'points' => $points,
|
||||
'continuous_days' => $continuousDays,
|
||||
'sign_date' => $today,
|
||||
'sign_record' => $signRecord,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到状态
|
||||
*/
|
||||
public function getStatus(int|string $memberId): array
|
||||
{
|
||||
$statistics = $this->memberSignDao->getSignStatistics($memberId);
|
||||
|
||||
return [
|
||||
'is_signed_today' => $statistics['today_signed'],
|
||||
'continuous_days' => $statistics['continuous_days'],
|
||||
'total_sign_days' => $statistics['total_sign_days'],
|
||||
'month_sign_days' => $statistics['month_sign_days'],
|
||||
'today' => date('Y-m-d'),
|
||||
'points' => $statistics['points'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到日历
|
||||
*/
|
||||
public function getCalendar(int|string $memberId, int $year = null, int $month = null): array
|
||||
{
|
||||
$year = $year ?? (int)date('Y');
|
||||
$month = $month ?? (int)date('m');
|
||||
|
||||
$calendar = $this->memberSignDao->getSignCalendar($memberId, $year, $month);
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'calendar' => $calendar,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到统计
|
||||
*/
|
||||
public function getStatistics(int|string $memberId, string $type = 'month'): array
|
||||
{
|
||||
$statistics = [];
|
||||
|
||||
switch ($type) {
|
||||
case 'week':
|
||||
$statistics = $this->memberSignDao->getWeekSignStatistics($memberId);
|
||||
break;
|
||||
case 'year':
|
||||
$statistics = $this->memberSignDao->getYearSignStatistics($memberId);
|
||||
break;
|
||||
case 'month':
|
||||
default:
|
||||
$basicStats = $this->memberSignDao->getSignStatistics($memberId);
|
||||
$statistics = [
|
||||
'total_sign_days' => $basicStats['total_sign_days'],
|
||||
'month_sign_days' => $basicStats['month_sign_days'],
|
||||
'continuous_days' => $basicStats['continuous_days'],
|
||||
'today_signed' => $basicStats['today_signed'],
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查今日是否已签到
|
||||
*/
|
||||
public function isSignedToday(int|string $memberId): bool
|
||||
{
|
||||
return $this->memberSignDao->isSignedToday($memberId, date('Y-m-d'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连续签到天数
|
||||
*/
|
||||
public function getContinuousDays(int|string $memberId): int
|
||||
{
|
||||
$lastSign = $this->memberSignDao->getLastSign($memberId);
|
||||
|
||||
if (!$lastSign) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$lastSignDate = $lastSign->sign_date;
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||
|
||||
if ($lastSignDate === date('Y-m-d')) {
|
||||
// 今日已签到
|
||||
return $lastSign->continuous_days;
|
||||
} elseif ($lastSignDate === $yesterday) {
|
||||
// 昨日已签到
|
||||
return $lastSign->continuous_days;
|
||||
} else {
|
||||
// 连续签到中断
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取总签到天数
|
||||
*/
|
||||
public function getTotalSignDays(int|string $memberId): int
|
||||
{
|
||||
$statistics = $this->memberSignDao->getSignStatistics($memberId);
|
||||
return $statistics['total_sign_days'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本月签到天数
|
||||
*/
|
||||
public function getMonthSignDays(int|string $memberId): int
|
||||
{
|
||||
return $this->memberSignDao->getMonthSignDays($memberId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\system;
|
||||
|
||||
use app\model\system\Config;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 协议服务
|
||||
*/
|
||||
class AgreementService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 获取协议内容
|
||||
*/
|
||||
public function getAgreementContent(string $key): array
|
||||
{
|
||||
$agreements = [
|
||||
'privacy' => [
|
||||
'title' => '隐私政策',
|
||||
'content' => $this->getPrivacyPolicyContent()
|
||||
],
|
||||
'user' => [
|
||||
'title' => '用户协议',
|
||||
'content' => $this->getUserAgreementContent()
|
||||
],
|
||||
'service' => [
|
||||
'title' => '服务条款',
|
||||
'content' => $this->getServiceTermsContent()
|
||||
]
|
||||
];
|
||||
|
||||
if (!isset($agreements[$key])) {
|
||||
throw new \Exception('协议不存在', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '获取成功',
|
||||
'data' => $agreements[$key]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取隐私政策内容
|
||||
*/
|
||||
private function getPrivacyPolicyContent(): string
|
||||
{
|
||||
return "# 隐私政策
|
||||
|
||||
## 1. 信息收集
|
||||
我们收集您在使用服务时主动提供的信息,包括但不限于姓名、联系方式等。
|
||||
|
||||
## 2. 信息使用
|
||||
我们使用收集的信息来提供和改进服务,保护用户安全,并遵守法律法规。
|
||||
|
||||
## 3. 信息共享
|
||||
除非获得您的明确同意,否则我们不会与第三方分享您的个人信息。
|
||||
|
||||
## 4. 信息安全
|
||||
我们采取合理的安全措施保护您的个人信息安全。
|
||||
|
||||
## 5. 政策变更
|
||||
我们可能会适时更新本隐私政策,请定期查看最新版本。";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户协议内容
|
||||
*/
|
||||
private function getUserAgreementContent(): string
|
||||
{
|
||||
return "# 用户协议
|
||||
|
||||
## 1. 接受条款
|
||||
通过使用我们的服务,您同意遵守本协议的所有条款和条件。
|
||||
|
||||
## 2. 用户注册
|
||||
您需要提供真实、准确、完整的注册信息,并保持信息的及时更新。
|
||||
|
||||
## 3. 使用规则
|
||||
您承诺遵守法律法规,不得利用服务从事任何违法或不正当的活动。
|
||||
|
||||
## 4. 服务变更
|
||||
我们保留随时修改、暂停或终止服务的权利,恕不另行通知。
|
||||
|
||||
## 5. 责任限制
|
||||
在法律允许的范围内,我们对服务的适用性、可靠性不作任何保证。";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务条款内容
|
||||
*/
|
||||
private function getServiceTermsContent(): string
|
||||
{
|
||||
return "# 服务条款
|
||||
|
||||
## 1. 服务内容
|
||||
我们提供在线服务,包括但不限于信息展示、用户交互等功能。
|
||||
|
||||
## 2. 服务费用
|
||||
基础服务免费提供,部分增值服务可能需要支付相应费用。
|
||||
|
||||
## 3. 服务中断
|
||||
因系统维护、升级等原因,服务可能会暂时中断,我们将尽力减少影响。
|
||||
|
||||
## 4. 用户责任
|
||||
用户应妥善保管账户信息,对其账户下的所有活动承担全部责任。
|
||||
|
||||
## 5. 终止服务
|
||||
如用户违反协议条款,我们有权终止提供服务。";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\system;
|
||||
|
||||
use app\model\system\Config;
|
||||
use core\base\BaseService;
|
||||
use support\Redis;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 验证码服务
|
||||
*/
|
||||
class CaptchaService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 获取验证码图片
|
||||
*/
|
||||
public function getCaptchaImage(): array
|
||||
{
|
||||
// 生成验证码
|
||||
$captcha = $this->generateCaptcha();
|
||||
|
||||
// 存储验证码到Redis,5分钟过期
|
||||
Redis::setex('captcha:' . $captcha['key'], 300, $captcha['code']);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '获取成功',
|
||||
'data' => [
|
||||
'captcha_key' => $captcha['key'],
|
||||
'captcha_image' => $captcha['image'],
|
||||
'expire_time' => 300
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*/
|
||||
public function sendSmsCode(string $type): array
|
||||
{
|
||||
$request = \request();
|
||||
$mobile = $request->post('mobile');
|
||||
|
||||
if (empty($mobile)) {
|
||||
throw new \Exception('手机号不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
if (!preg_match('/^1[3-9]\\d{9}$/', $mobile)) {
|
||||
throw new \Exception('手机号格式不正确', 400);
|
||||
}
|
||||
|
||||
// 检查发送频率
|
||||
$lastSendTime = Redis::get('sms_last_send:' . $mobile);
|
||||
if ($lastSendTime && time() - $lastSendTime < 60) {
|
||||
throw new \Exception('发送过于频繁,请稍后再试', 429);
|
||||
}
|
||||
|
||||
// 生成验证码
|
||||
$code = sprintf('%06d', mt_rand(0, 999999));
|
||||
|
||||
// 存储验证码到Redis,10分钟过期
|
||||
$cacheKey = 'sms_code:' . $mobile;
|
||||
Redis::setex($cacheKey, 600, $code);
|
||||
|
||||
// 记录发送时间
|
||||
Redis::setex('sms_last_send:' . $mobile, 60, time());
|
||||
|
||||
// 发送短信(这里应该调用短信服务商API)
|
||||
$this->sendSms($mobile, $code, $type);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '验证码发送成功'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
*/
|
||||
private function generateCaptcha(): array
|
||||
{
|
||||
// 生成随机验证码
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$code = '';
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$code .= $chars[mt_rand(0, strlen($chars) - 1)];
|
||||
}
|
||||
|
||||
// 生成唯一key
|
||||
$key = md5(uniqid() . microtime(true));
|
||||
|
||||
// 这里应该生成验证码图片,暂时返回模拟数据
|
||||
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'code' => $code,
|
||||
'image' => $image
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信
|
||||
*/
|
||||
private function sendSms(string $mobile, string $code, string $type): void
|
||||
{
|
||||
// 这里应该调用短信服务商API
|
||||
// 暂时模拟发送成功
|
||||
$templates = [
|
||||
'login' => '您的登录验证码是:{code},有效期10分钟',
|
||||
'register' => '您的注册验证码是:{code},有效期10分钟',
|
||||
'bind' => '您的绑定验证码是:{code},有效期10分钟',
|
||||
'reset' => '您的密码重置验证码是:{code},有效期10分钟'
|
||||
];
|
||||
|
||||
$template = $templates[$type] ?? '您的验证码是:{code},有效期10分钟';
|
||||
$content = str_replace('{code}', $code, $template);
|
||||
|
||||
// 记录发送日志
|
||||
\Log::info("短信发送: {$mobile} - {$content}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\api\system;
|
||||
|
||||
use app\dao\system\ConfigDao;
|
||||
use app\scope\global\AccessPermissionScope;
|
||||
use core\base\BaseService;
|
||||
|
||||
|
||||
class ConfigService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(ConfigDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置项
|
||||
*
|
||||
* @param string $code 配置code
|
||||
* @param mixed $default 默认值
|
||||
* @param array $options 选项,如 ['group_code' => '...']
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function config(string $code, mixed $default = [], array $options = []): mixed
|
||||
{
|
||||
$map = ['code' => $code];
|
||||
// 如果选项中有分组,则添加到查询条件
|
||||
if (!empty($options['group_code'])) {
|
||||
$map['group_code'] = $options['group_code'];
|
||||
}
|
||||
|
||||
// 查询配置
|
||||
$configModel = $this->dao->get($map, ['*'], [], '', [AccessPermissionScope::class]);
|
||||
|
||||
// 如果配置不存在,自动创建
|
||||
if (!$configModel) {
|
||||
$configData = [
|
||||
'code' => $code,
|
||||
'name' => $options['name'] ?? $code,
|
||||
'group_code' => $options['group_code'] ?? 'default',
|
||||
'content' => is_array($default) ? json_encode($default, JSON_UNESCAPED_UNICODE) : $default,
|
||||
'type' => $options['type'] ?? (is_array($default) ? 'json' : 'string'),
|
||||
'enabled' => 1,
|
||||
'sort' => 0,
|
||||
'remark' => $options['remark'] ?? '',
|
||||
];
|
||||
|
||||
try {
|
||||
$this->dao->save($configData);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
$content = $configModel->getOriginal('content',null);
|
||||
|
||||
// 如果content是数组/JSON格式,返回整个数组;否则返回原值
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
}
|
||||
|
||||
return $content ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置分组详情
|
||||
*
|
||||
* @param string $groupCode 配置分组
|
||||
* @param array $defaults 默认配置项
|
||||
* @param array $options 选项参数
|
||||
* - enabled_only: bool 是否只获取启用的配置项,默认true
|
||||
* - with_metadata: bool 是否包含配置元数据,默认false
|
||||
*
|
||||
* @return array
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function getByGroup(string $groupCode, array $defaults = [], array $options = []): array
|
||||
{
|
||||
// 验证groupCode参数,防止SQL注入
|
||||
if (empty($groupCode)) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$enabledOnly = $options['enabled_only'] ?? true;
|
||||
$withMetadata = $options['with_metadata'] ?? false;
|
||||
|
||||
$map = ['group_code' => $groupCode];
|
||||
if ($enabledOnly) {
|
||||
$map['enabled'] = 1;
|
||||
}
|
||||
|
||||
if ($withMetadata) {
|
||||
// 获取完整配置信息
|
||||
$configs = $this->dao->getList($map);
|
||||
$processedData = [];
|
||||
foreach ($configs as $config) {
|
||||
$content = $config['content'] ?? null;
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
$config['content'] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
}
|
||||
|
||||
if (isset($options['key_by']) && $options['key_by'] === 'code') {
|
||||
$processedData[$config['code']] = $config;
|
||||
} else {
|
||||
$processedData[] = $config;
|
||||
}
|
||||
}
|
||||
|
||||
return $processedData;
|
||||
} else {
|
||||
// 只获取配置内容
|
||||
$data = $this->dao->getColumn($map, 'content', 'code');
|
||||
// 处理每个配置项的内容,如果是JSON格式则解码
|
||||
$processedData = [];
|
||||
foreach ($data as $code => $content) {
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
$processedData[$code] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
} else {
|
||||
$processedData[$code] = $content;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并默认配置
|
||||
return array_merge($defaults, $processedData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有配置并按分组整理
|
||||
*
|
||||
* @param array $options 选项参数
|
||||
* - enabled_only: bool 是否只获取启用的配置项,默认true
|
||||
* - group_filter: array 分组过滤,只返回指定分组的配置
|
||||
* - with_metadata: bool 是否包含配置项的完整元数据,默认false
|
||||
* - key_by: string 按指定字段作为键名('code' 或 'id'),默认null
|
||||
* - sort_groups: bool 是否对分组进行排序,默认false
|
||||
* - sort_configs: bool 是否对配置项进行排序,默认false
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAllGrouped(array $options = []): array
|
||||
{
|
||||
$enabledOnly = $options['enabled_only'] ?? true;
|
||||
$groupFilter = $options['group_filter'] ?? [];
|
||||
$withMetadata = $options['with_metadata'] ?? false;
|
||||
$keyBy = $options['key_by'] ?? null;
|
||||
$sortGroups = $options['sort_groups'] ?? false;
|
||||
$sortConfigs = $options['sort_configs'] ?? false;
|
||||
|
||||
$map = [];
|
||||
if ($enabledOnly) {
|
||||
$map['enabled'] = 1;
|
||||
}
|
||||
if (!empty($groupFilter)) {
|
||||
$map['group_code'] = $groupFilter;
|
||||
}
|
||||
|
||||
$configs = $this->dao->getList($map);
|
||||
$grouped = [];
|
||||
|
||||
foreach ($configs as $config) {
|
||||
$groupCode = $config['group_code'] ?? 'default';
|
||||
|
||||
// 处理内容
|
||||
$content = $config['content'];
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
$config['content'] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
}
|
||||
|
||||
// 初始化分组
|
||||
if (!isset($grouped[$groupCode])) {
|
||||
$grouped[$groupCode] = [];
|
||||
}
|
||||
|
||||
// 根据选项决定返回的数据结构
|
||||
if ($withMetadata) {
|
||||
// 返回完整配置信息
|
||||
if ($keyBy && isset($config[$keyBy])) {
|
||||
$grouped[$groupCode][$config[$keyBy]] = $config;
|
||||
} else {
|
||||
$grouped[$groupCode][] = $config;
|
||||
}
|
||||
} else {
|
||||
// 只返回配置内容
|
||||
if ($keyBy && isset($config[$keyBy])) {
|
||||
$grouped[$groupCode][$config[$keyBy]] = $config['content'];
|
||||
} else {
|
||||
$grouped[$groupCode][$config['code']] = $config['content'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排序处理
|
||||
if ($sortGroups) {
|
||||
ksort($grouped);
|
||||
}
|
||||
|
||||
if ($sortConfigs) {
|
||||
foreach ($grouped as &$configs) {
|
||||
if ($withMetadata) {
|
||||
// 对元数据模式按code排序
|
||||
uasort($configs, function ($a, $b) {
|
||||
return strcmp($a['code'] ?? '', $b['code'] ?? '');
|
||||
});
|
||||
} else {
|
||||
// 对内容模式按键名排序
|
||||
ksort($configs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置项中的某个键值
|
||||
*
|
||||
* @param string $code 配置项
|
||||
* @param string $key 要获取的键,支持多级键,如 key1.key2.key3
|
||||
* @param mixed $default 默认值,默认null
|
||||
* @param array $options 选项参数
|
||||
* - group_code: string 配置分组,默认null(在所有分组中查找)
|
||||
* - require_group: bool 是否必须指定分组,默认false
|
||||
* - fallback_to_all: bool 当未找到分组配置时,是否在所有分组中查找,默认true
|
||||
* - search_groups: array 搜索的分组列表,按顺序查找
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getValue(string $code, string $key, mixed $default = null, array $options = []): mixed
|
||||
{
|
||||
$groupCode = $options['group_code'] ?? null;
|
||||
$requireGroup = $options['require_group'] ?? false;
|
||||
$fallbackToAll = $options['fallback_to_all'] ?? true;
|
||||
$searchGroups = $options['search_groups'] ?? [];
|
||||
|
||||
// 如果指定了搜索分组列表,按顺序查找
|
||||
if (!empty($searchGroups) && is_array($searchGroups)) {
|
||||
foreach ($searchGroups as $searchGroupCode) {
|
||||
$config = $this->config($code, null, ['group_code' => $searchGroupCode]);
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
// 如果指定了分组,优先在该分组中查找
|
||||
if ($groupCode !== null) {
|
||||
$config = $this->config($code, null, ['group_code' => $groupCode]);
|
||||
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果未找到且允许回退到所有分组,则尝试在所有分组中查找
|
||||
if ($fallbackToAll && !$requireGroup) {
|
||||
$config = $this->config($code, null);
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
// 默认在所有分组中查找
|
||||
$config = $this->config($code, null);
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据多级键获取嵌套数组中的值
|
||||
*
|
||||
* @param array $array 要查找的数组
|
||||
* @param string $key 多级键,如 key1.key2.key3
|
||||
*
|
||||
* @return mixed|null 找到的值或null
|
||||
*/
|
||||
private function getNestedValue(array $array, string $key): mixed
|
||||
{
|
||||
// 如果key不包含点号,直接返回对应值
|
||||
if (!str_contains($key, '.')) {
|
||||
return $array[$key] ?? null;
|
||||
}
|
||||
|
||||
// 拆分多级键
|
||||
$keys = explode('.', $key);
|
||||
$value = $array;
|
||||
|
||||
// 逐级查找
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !isset($value[$k])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置项是否存在
|
||||
*
|
||||
* @param string $code 配置code
|
||||
* @param array $options 扩展选项参数
|
||||
* - group_code: string 配置分组
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function exists(string $code, array $options = []): bool
|
||||
{
|
||||
$map = ['code' => $code];
|
||||
if (isset($options['group_code'])) {
|
||||
// 验证group_code格式
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
|
||||
throw new \Exception('Invalid group_code format');
|
||||
}
|
||||
$map['group_code'] = $options['group_code'];
|
||||
}
|
||||
|
||||
$config = $this->dao->get($map);
|
||||
return !empty($config);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\api\system;
|
||||
|
||||
use app\model\system\Config;
|
||||
use core\base\BaseService;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 微信服务
|
||||
*/
|
||||
class WechatService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 获取微信授权码
|
||||
*/
|
||||
public function getWechatAuthCode(array $params): array
|
||||
{
|
||||
$redirectUrl = $params['redirect_url'] ?? '';
|
||||
|
||||
if (empty($redirectUrl)) {
|
||||
throw new \Exception('回调URL不能为空', 400);
|
||||
}
|
||||
|
||||
$config = config('wechat');
|
||||
$appId = $config['app_id'] ?? '';
|
||||
|
||||
if (empty($appId)) {
|
||||
throw new \Exception('微信配置未完成', 500);
|
||||
}
|
||||
|
||||
// 生成授权URL
|
||||
$scope = 'snsapi_userinfo'; // 需要获取用户信息
|
||||
$state = md5(uniqid() . microtime(true));
|
||||
|
||||
$authUrl = "https://open.weixin.qq.com/connect/oauth2/authorize?" . http_build_query([
|
||||
'appid' => $appId,
|
||||
'redirect_uri' => urlencode($redirectUrl),
|
||||
'response_type' => 'code',
|
||||
'scope' => $scope,
|
||||
'state' => $state
|
||||
]) . "#wechat_redirect";
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '获取成功',
|
||||
'data' => [
|
||||
'auth_url' => $authUrl,
|
||||
'state' => $state
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步微信用户信息
|
||||
*/
|
||||
public function syncWechatUserInfo(array $params): array
|
||||
{
|
||||
$code = $params['code'] ?? '';
|
||||
|
||||
if (empty($code)) {
|
||||
throw new \Exception('授权码不能为空', 400);
|
||||
}
|
||||
|
||||
$config = config('wechat');
|
||||
|
||||
if (empty($config['app_id']) || empty($config['app_secret'])) {
|
||||
throw new \Exception('微信配置未完成', 500);
|
||||
}
|
||||
|
||||
// 获取access_token
|
||||
$accessToken = $this->getAccessTokenByCode($code, $config['app_id'], $config['app_secret']);
|
||||
|
||||
// 获取用户信息
|
||||
$userInfo = $this->getUserInfoByToken($accessToken['access_token'], $accessToken['openid']);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '同步成功',
|
||||
'data' => $userInfo
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信 JSSDK 配置
|
||||
*/
|
||||
public function getWechatJssdkConfig(array $params): array
|
||||
{
|
||||
$url = $params['url'] ?? '';
|
||||
|
||||
if (empty($url)) {
|
||||
throw new \Exception('URL不能为空', 400);
|
||||
}
|
||||
|
||||
$config = config('wechat');
|
||||
$appId = $config['app_id'] ?? '';
|
||||
|
||||
if (empty($appId)) {
|
||||
throw new \Exception('微信配置未完成', 500);
|
||||
}
|
||||
|
||||
// 获取jsapi_ticket
|
||||
$ticket = $this->getJsapiTicket();
|
||||
|
||||
// 生成签名
|
||||
$nonceStr = $this->generateNonceStr();
|
||||
$timestamp = time();
|
||||
$signature = $this->generateSignature($ticket, $nonceStr, $timestamp, $url);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '获取成功',
|
||||
'data' => [
|
||||
'appId' => $appId,
|
||||
'timestamp' => $timestamp,
|
||||
'nonceStr' => $nonceStr,
|
||||
'signature' => $signature,
|
||||
'url' => $url
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过code获取access_token
|
||||
*/
|
||||
private function getAccessTokenByCode(string $code, string $appId, string $appSecret): array
|
||||
{
|
||||
$url = "https://api.weixin.qq.com/sns/oauth2/access_token?" . http_build_query([
|
||||
'appid' => $appId,
|
||||
'secret' => $appSecret,
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code'
|
||||
]);
|
||||
|
||||
// 这里应该调用微信API,暂时返回模拟数据
|
||||
return [
|
||||
'access_token' => 'mock_access_token',
|
||||
'expires_in' => 7200,
|
||||
'refresh_token' => 'mock_refresh_token',
|
||||
'openid' => 'mock_openid_' . uniqid(),
|
||||
'scope' => 'snsapi_userinfo'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过token获取用户信息
|
||||
*/
|
||||
private function getUserInfoByToken(string $accessToken, string $openid): array
|
||||
{
|
||||
$url = "https://api.weixin.qq.com/sns/userinfo?" . http_build_query([
|
||||
'access_token' => $accessToken,
|
||||
'openid' => $openid,
|
||||
'lang' => 'zh_CN'
|
||||
]);
|
||||
|
||||
// 这里应该调用微信API,暂时返回模拟数据
|
||||
return [
|
||||
'openid' => $openid,
|
||||
'nickname' => '微信用户',
|
||||
'sex' => 1,
|
||||
'province' => '广东省',
|
||||
'city' => '深圳市',
|
||||
'country' => '中国',
|
||||
'headimgurl' => '',
|
||||
'privilege' => [],
|
||||
'unionid' => ''
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取jsapi_ticket
|
||||
*/
|
||||
private function getJsapiTicket(): string
|
||||
{
|
||||
// 检查缓存
|
||||
$cacheKey = 'wechat_jsapi_ticket';
|
||||
$ticket = Redis::get($cacheKey);
|
||||
|
||||
if ($ticket) {
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
// 获取access_token
|
||||
$accessToken = $this->getAccessToken();
|
||||
|
||||
// 获取ticket
|
||||
$url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token={$accessToken}&type=jsapi";
|
||||
|
||||
// 这里应该调用微信API,暂时返回模拟数据
|
||||
$mockTicket = 'mock_jsapi_ticket_' . uniqid();
|
||||
|
||||
// 缓存2小时
|
||||
Redis::setex($cacheKey, 7200, $mockTicket);
|
||||
|
||||
return $mockTicket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取access_token
|
||||
*/
|
||||
private function getAccessToken(): string
|
||||
{
|
||||
// 检查缓存
|
||||
$cacheKey = 'wechat_access_token';
|
||||
$accessToken = Redis::get($cacheKey);
|
||||
|
||||
if ($accessToken) {
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
$config = config('wechat');
|
||||
$url = "https://api.weixin.qq.com/cgi-bin/token?" . http_build_query([
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $config['app_id'],
|
||||
'secret' => $config['app_secret']
|
||||
]);
|
||||
|
||||
// 这里应该调用微信API,暂时返回模拟数据
|
||||
$mockToken = 'mock_access_token_' . uniqid();
|
||||
|
||||
// 缓存2小时
|
||||
Redis::setex($cacheKey, 7200, $mockToken);
|
||||
|
||||
return $mockToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
*/
|
||||
private function generateNonceStr(int $length = 16): string
|
||||
{
|
||||
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
$str = "";
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
|
||||
}
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
*/
|
||||
private function generateSignature(string $ticket, string $nonceStr, int $timestamp, string $url): string
|
||||
{
|
||||
$string = "jsapi_ticket={$ticket}&noncestr={$nonceStr}×tamp={$timestamp}&url={$url}";
|
||||
return sha1($string);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\api\upload;
|
||||
|
||||
use app\dao\system\UploadDao;
|
||||
use app\service\admin\system\ConfigService;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\upload\UploadFile;
|
||||
use madong\helper\Arr;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 上传服务类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class UploadService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(UploadDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传
|
||||
*
|
||||
* @param string $upload
|
||||
* @param bool $isLocal
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function uploadImage(string $upload = '', bool $isLocal = false): mixed
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($upload, $isLocal) {
|
||||
$config = $this->getUploadConfig();
|
||||
if ($isLocal) {
|
||||
$config['mode'] = 'local';
|
||||
}
|
||||
return $this->handleUpload($config, $upload, $isLocal);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频上传
|
||||
*
|
||||
* @param string $upload
|
||||
* @param bool $isLocal
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function uploadVideo(string $upload = '', bool $isLocal = false): mixed
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($upload, $isLocal) {
|
||||
$config = $this->getUploadConfig();
|
||||
if ($isLocal) {
|
||||
$config['mode'] = 'local';
|
||||
}
|
||||
return $this->handleUpload($config, $upload, $isLocal);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传
|
||||
*
|
||||
* @param string $upload
|
||||
* @param bool $isLocal
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function uploadFile(string $upload = '', bool $isLocal = false): mixed
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($upload, $isLocal) {
|
||||
$baseConfig = $this->getUploadConfig();
|
||||
$config = [
|
||||
'mode' => $baseConfig['mode'] ?? 'local',
|
||||
'single_limit' => 50 * 1024 * 1024,
|
||||
'total_limit' => 50 * 1024 * 1024,
|
||||
'nums' => 1,
|
||||
'include' => ['doc', 'docx', 'xls', 'xlsx', 'pdf', 'txt', 'zip', 'rar', '7z'],
|
||||
'exclude' => $baseConfig['exclude'] ?? ['php', 'js', 'html', 'sh', 'exe'],
|
||||
];
|
||||
if ($isLocal) {
|
||||
$config['mode'] = 'local';
|
||||
}
|
||||
return $this->handleUpload($config, $upload, $isLocal);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程图片拉取
|
||||
*
|
||||
* @param string $url
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function fetchImage(string $url): array
|
||||
{
|
||||
$config = UploadFile::config('local');
|
||||
$data = file_get_contents($url);
|
||||
if ($data === false) {
|
||||
throw new AdminException('获取文件资源失败');
|
||||
}
|
||||
$image_resource = imagecreatefromstring($data);
|
||||
if (!$image_resource) {
|
||||
throw new AdminException('创建图片资源失败');
|
||||
}
|
||||
$filename = basename($url);
|
||||
$file_extension = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
$full_dir = runtime_path() . '/resource/';
|
||||
if (!is_dir($full_dir)) {
|
||||
mkdir($full_dir, 0777, true);
|
||||
}
|
||||
$save_path = $full_dir . $filename;
|
||||
$content_type = 'image/';
|
||||
switch ($file_extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
$content_type = 'image/jpeg';
|
||||
$result = imagejpeg($image_resource, $save_path);
|
||||
break;
|
||||
case 'png':
|
||||
$content_type = 'image/png';
|
||||
$result = imagepng($image_resource, $save_path);
|
||||
break;
|
||||
case 'gif':
|
||||
$content_type = 'image/gif';
|
||||
$result = imagegif($image_resource, $save_path);
|
||||
break;
|
||||
case 'webp':
|
||||
$content_type = 'image/webp';
|
||||
$result = imagewebp($image_resource, $save_path);
|
||||
break;
|
||||
default:
|
||||
imagedestroy($image_resource);
|
||||
throw new AdminException('文件格式错误');
|
||||
}
|
||||
imagedestroy($image_resource);
|
||||
if (!$result) {
|
||||
throw new AdminException('文件保存失败');
|
||||
}
|
||||
$hash = md5_file($save_path);
|
||||
$size = filesize($save_path);
|
||||
$result = $this->dao->get(['hash' => $hash]);
|
||||
if (!empty($result)) {
|
||||
unlink($save_path);
|
||||
return $result->toArray();
|
||||
}
|
||||
$root = Arr::fetchConfigValue($config, 'root');
|
||||
$folder = date('Ymd');
|
||||
$full_dir = base_path() . DIRECTORY_SEPARATOR . $root . $folder . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($full_dir)) {
|
||||
mkdir($full_dir, 0777, true);
|
||||
}
|
||||
$object_name = bin2hex(pack('Nn', time(), random_int(1, 65535))) . ".{$file_extension}";
|
||||
$newPath = $full_dir . $object_name;
|
||||
copy($save_path, $newPath);
|
||||
unlink($save_path);
|
||||
$domain = Arr::fetchConfigValue($config, 'domain');
|
||||
$dirname = Arr::fetchConfigValue($config, 'dirname');
|
||||
$baseUrl = $dirname . $folder . '/';
|
||||
$info['platform'] = 'local';
|
||||
$info['original_filename'] = $filename;
|
||||
$info['filename'] = $object_name;
|
||||
$info['hash'] = $hash;
|
||||
$info['content_type'] = $content_type;
|
||||
$info['base_path'] = $root . $folder . '/' . $object_name;
|
||||
$info['ext'] = $file_extension;
|
||||
$info['size'] = $size;
|
||||
$info['size_info'] = formatBytes($size);
|
||||
$info['url'] = $baseUrl . $object_name;
|
||||
$result = $this->dao->save($info);
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64图片上传
|
||||
*
|
||||
* @param string $base64Data
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uploadBase64Image(string $base64Data): array
|
||||
{
|
||||
if (str_starts_with($base64Data, 'data:image/')) {
|
||||
$base64Data = substr($base64Data, strpos($base64Data, ',') + 1);
|
||||
}
|
||||
$data = base64_decode($base64Data);
|
||||
if ($data === false) {
|
||||
throw new AdminException('Base64解码失败');
|
||||
}
|
||||
$image_resource = imagecreatefromstring($data);
|
||||
if (!$image_resource) {
|
||||
throw new AdminException('创建图片资源失败');
|
||||
}
|
||||
$file_extension = 'png';
|
||||
$content_type = 'image/png';
|
||||
$file_info = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($file_info) {
|
||||
$mime_type = finfo_buffer($file_info, $data);
|
||||
finfo_close($file_info);
|
||||
switch ($mime_type) {
|
||||
case 'image/jpeg':
|
||||
case 'image/jpg':
|
||||
$file_extension = 'jpg';
|
||||
$content_type = 'image/jpeg';
|
||||
break;
|
||||
case 'image/png':
|
||||
$file_extension = 'png';
|
||||
$content_type = 'image/png';
|
||||
break;
|
||||
case 'image/gif':
|
||||
$file_extension = 'gif';
|
||||
$content_type = 'image/gif';
|
||||
break;
|
||||
case 'image/webp':
|
||||
$file_extension = 'webp';
|
||||
$content_type = 'image/webp';
|
||||
break;
|
||||
}
|
||||
}
|
||||
$full_dir = runtime_path() . '/resource/';
|
||||
if (!is_dir($full_dir)) {
|
||||
mkdir($full_dir, 0777, true);
|
||||
}
|
||||
$filename = 'upload_' . time() . '_' . random_int(1000, 9999) . '.' . $file_extension;
|
||||
$save_path = $full_dir . $filename;
|
||||
switch ($file_extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
$result = imagejpeg($image_resource, $save_path, 90);
|
||||
break;
|
||||
case 'png':
|
||||
$result = imagepng($image_resource, $save_path, 9);
|
||||
break;
|
||||
case 'gif':
|
||||
$result = imagegif($image_resource, $save_path);
|
||||
break;
|
||||
case 'webp':
|
||||
$result = imagewebp($image_resource, $save_path, 90);
|
||||
break;
|
||||
default:
|
||||
imagedestroy($image_resource);
|
||||
throw new AdminException('文件格式错误');
|
||||
}
|
||||
imagedestroy($image_resource);
|
||||
if (!$result) {
|
||||
throw new AdminException('文件保存失败');
|
||||
}
|
||||
$hash = md5_file($save_path);
|
||||
$size = filesize($save_path);
|
||||
$result = $this->dao->get(['hash' => $hash]);
|
||||
if (!empty($result)) {
|
||||
unlink($save_path);
|
||||
return $result->toArray();
|
||||
}
|
||||
$config = UploadFile::getConfig('local');
|
||||
$root = Arr::fetchConfigValue($config, 'root');
|
||||
$folder = date('Ymd');
|
||||
$full_dir = base_path() . DIRECTORY_SEPARATOR . $root . $folder . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($full_dir)) {
|
||||
mkdir($full_dir, 0777, true);
|
||||
}
|
||||
$object_name = bin2hex(pack('Nn', time(), random_int(1, 65535))) . ".{$file_extension}";
|
||||
$newPath = $full_dir . $object_name;
|
||||
copy($save_path, $newPath);
|
||||
unlink($save_path);
|
||||
$dirname = Arr::fetchConfigValue($config, 'dirname');
|
||||
$baseUrl = $dirname . $folder . '/';
|
||||
$info['platform'] = 'local';
|
||||
$info['original_filename'] = $filename;
|
||||
$info['filename'] = $object_name;
|
||||
$info['hash'] = $hash;
|
||||
$info['content_type'] = $content_type;
|
||||
$info['base_path'] = $root . $folder . '/' . $object_name;
|
||||
$info['ext'] = $file_extension;
|
||||
$info['size'] = $size;
|
||||
$info['size_info'] = formatBytes($size);
|
||||
$info['url'] = $baseUrl . $object_name;
|
||||
$result = $this->dao->save($info);
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件上传
|
||||
*
|
||||
* @param array $config
|
||||
* @param string $upload
|
||||
* @param bool $isLocal
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
private function handleUpload(array $config, string $upload = '', bool $isLocal = false): mixed
|
||||
{
|
||||
$options = [];
|
||||
if (!empty($upload)) {
|
||||
$options['sub_dir'] = $upload;
|
||||
}
|
||||
$result = UploadFile::uploadFile($options);
|
||||
$data = $result[0];
|
||||
$url = str_replace('\\', '/', $data['url']);
|
||||
$path = str_replace('\\', '/', $data['save_path']);
|
||||
|
||||
// 检查文件是否已存在
|
||||
if ($filesInfo = $this->dao->get(['hash' => $data['unique_id']])) {
|
||||
return $filesInfo;
|
||||
}
|
||||
|
||||
$inData = [
|
||||
'platform' => $config['mode'],
|
||||
'original_filename' => $data['origin_name'] ?? '',
|
||||
'filename' => $data['save_name'],
|
||||
'hash' => $data['unique_id'],
|
||||
'content_type' => $data['mime_type'],
|
||||
'base_path' => $data['base_path'],
|
||||
'ext' => $data['extension'],
|
||||
'size' => $data['size'],
|
||||
'size_info' => formatBytes($data['size']),
|
||||
'url' => full_url($url),
|
||||
'path' => $path,
|
||||
];
|
||||
return $this->dao->save($inData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传配置
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getUploadConfig(): array
|
||||
{
|
||||
return UploadFile::config('upload', [
|
||||
'mode' => 'local',
|
||||
'single_limit' => 1024 * 1024,
|
||||
'total_limit' => 1024 * 1024,
|
||||
'nums' => 10,
|
||||
'exclude' => ['php', 'ext', 'exe'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\api\web;
|
||||
|
||||
use app\dao\web\AdvDao;
|
||||
use core\base\BaseService;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 广告服务
|
||||
*/
|
||||
class AdvertisementService extends BaseService
|
||||
{
|
||||
public function __construct(AdvDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广告列表
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAds(): array
|
||||
{
|
||||
$map = [
|
||||
['enabled', 'eq', 1],
|
||||
];
|
||||
|
||||
$advs = $this->dao->selectList($map, ['*'], 0, 0, 'sort asc, id asc');
|
||||
|
||||
// 过滤时间范围内有效的广告
|
||||
$now = time();
|
||||
return $advs->filter(function($adv) use ($now) {
|
||||
$startTime = $adv->getOriginal('start_time');
|
||||
$endTime = $adv->getOriginal('end_time');
|
||||
return $startTime <= $now && $endTime >= $now;
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广告位信息(兼容旧版本,已废弃)
|
||||
*
|
||||
* @param array $_params 参数
|
||||
* @return array
|
||||
*/
|
||||
public function getAdvertisementInfo(array $_params): array
|
||||
{
|
||||
return $this->getAds();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除广告缓存
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearAdCache(): void
|
||||
{
|
||||
// 清除所有广告缓存
|
||||
$keys = Redis::keys('web_ads_*');
|
||||
if (!empty($keys)) {
|
||||
Redis::del($keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\api\web;
|
||||
|
||||
use app\dao\web\LinkDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 友情链接服务
|
||||
*/
|
||||
class LinkService extends BaseService
|
||||
{
|
||||
public function __construct(LinkDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取链接
|
||||
*
|
||||
* @param string $type 链接类型
|
||||
*
|
||||
* @return array|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getLinksByType(string $type): ?array
|
||||
{
|
||||
$map = [
|
||||
['category', 'eq', $type],
|
||||
['enabled', 'eq', 1],
|
||||
];
|
||||
return $this->dao->selectList($map, ['*'], 0, 0, 'sort asc')->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有链接
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAllLinks(): array
|
||||
{
|
||||
$map = [
|
||||
['enabled', '=', 1],
|
||||
['deleted_at', '=', null],
|
||||
];
|
||||
|
||||
return $this->dao->selectList($map, ['*'], 0, 0, 'category asc, sort asc')->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\api\web;
|
||||
|
||||
use app\api\CurrentMember;
|
||||
use app\dao\web\MenuDao;
|
||||
use app\enum\common\EnabledStatus;
|
||||
use core\base\BaseService;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 菜单服务
|
||||
*/
|
||||
class MenuService extends BaseService
|
||||
{
|
||||
|
||||
// 缓存键前缀
|
||||
public const CACHE_PREFIX = 'web_navigation_list';
|
||||
|
||||
// 缓存过期时间(秒)
|
||||
public const CACHE_EXPIRE = 3600;
|
||||
|
||||
public function __construct(MenuDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导航菜单列表(带权限过滤)
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getNavigationList(): array
|
||||
{
|
||||
$map = [
|
||||
['enabled', 'eq', EnabledStatus::ENABLED->value],
|
||||
['is_show', 'eq', 1],
|
||||
];
|
||||
$allMenus = $this->dao->selectList($map, ['*'], 0, 0, 'sort asc, id asc');
|
||||
$allMenus = $allMenus->toArray();
|
||||
|
||||
$userPermissions = $this->getUserPermissions();
|
||||
$isLogin = !empty($userPermissions);
|
||||
|
||||
$filteredMenus = [];
|
||||
foreach ($allMenus as $menu) {
|
||||
$code = $menu['code'] ?? '';
|
||||
if (empty($code)) {
|
||||
$filteredMenus[] = $menu;
|
||||
continue;
|
||||
}
|
||||
if (!$isLogin) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($code, $userPermissions, true)) {
|
||||
$filteredMenus[] = $menu;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parseExtra($filteredMenus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的权限码列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getUserPermissions(): array
|
||||
{
|
||||
try {
|
||||
/** @var CurrentMember $currentMember */
|
||||
$currentMember = Container::make(CurrentMember::class);
|
||||
return $currentMember->getPermissions();
|
||||
} catch (\Exception) {
|
||||
// 未登录或获取权限失败,返回空数组,只显示公开菜单
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 extra 扩展字段
|
||||
* 将 JSON 字符串转换为数组并合并到菜单数据
|
||||
*
|
||||
* @param array $menus
|
||||
* @return array
|
||||
*/
|
||||
protected function parseExtra(array $menus): array
|
||||
{
|
||||
return array_map(function ($menu) {
|
||||
// 解析 extra JSON
|
||||
if (!empty($menu['extra']) && is_string($menu['extra'])) {
|
||||
$extra = json_decode($menu['extra'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$menu['extra'] = $extra;
|
||||
} else {
|
||||
$menu['extra'] = [];
|
||||
}
|
||||
} elseif (empty($menu['extra'])) {
|
||||
$menu['extra'] = [];
|
||||
}
|
||||
|
||||
// 将常用字段映射到 extra(便于前端使用)
|
||||
$menu['extra'] = array_merge([
|
||||
'type' => $menu['type'] ?? 1,
|
||||
'target' => $menu['target'] ?? 1,
|
||||
], $menu['extra']);
|
||||
|
||||
return $menu;
|
||||
}, $menus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除菜单缓存
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearMenuCache(): void
|
||||
{
|
||||
$this->cacheDriver()->delete(self::CACHE_PREFIX . '_all');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user