chore: prepare anxinyan release
This commit is contained in:
@@ -8,6 +8,8 @@ use support\think\Db;
|
||||
|
||||
class SystemConfigsController
|
||||
{
|
||||
private const H5_OAUTH_REDIRECT_HASH_PATH = '/#/pages/auth/login';
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->bootstrapDefaults();
|
||||
@@ -23,6 +25,7 @@ class SystemConfigsController
|
||||
foreach ($configs as $item) {
|
||||
$configMap[$item['config_group'] . '.' . $item['config_key']] = $item['config_value'] ?? '';
|
||||
}
|
||||
$this->applyDerivedConfigValues($configMap);
|
||||
|
||||
$groups = [];
|
||||
foreach ($this->definitions() as $groupCode => $group) {
|
||||
@@ -38,6 +41,7 @@ class SystemConfigsController
|
||||
'placeholder' => $item['placeholder'],
|
||||
'remark' => $item['remark'],
|
||||
'is_secret' => (bool)$item['is_secret'],
|
||||
'read_only' => (bool)($item['read_only'] ?? false),
|
||||
'options' => $item['options'] ?? [],
|
||||
'visible_when' => $item['visible_when'] ?? null,
|
||||
'value' => $configMap[$groupCode . '.' . $item['config_key']] ?? '',
|
||||
@@ -74,6 +78,7 @@ class SystemConfigsController
|
||||
}
|
||||
}
|
||||
|
||||
$submittedConfigKeys = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
@@ -87,6 +92,18 @@ class SystemConfigsController
|
||||
}
|
||||
|
||||
$configValueMap[$mapKey] = (string)($item['config_value'] ?? '');
|
||||
$submittedConfigKeys[$mapKey] = [
|
||||
'config_group' => $groupCode,
|
||||
'config_key' => $configKey,
|
||||
];
|
||||
}
|
||||
|
||||
$this->applyDerivedConfigValues($configValueMap);
|
||||
if (isset($submittedConfigKeys['h5.page_base_url']) || isset($submittedConfigKeys['h5.oauth_redirect_url'])) {
|
||||
$submittedConfigKeys['h5.oauth_redirect_url'] = [
|
||||
'config_group' => 'h5',
|
||||
'config_key' => 'oauth_redirect_url',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -99,14 +116,10 @@ class SystemConfigsController
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupCode = trim((string)($item['config_group'] ?? ''));
|
||||
$configKey = trim((string)($item['config_key'] ?? ''));
|
||||
$configValue = (string)($item['config_value'] ?? '');
|
||||
foreach ($submittedConfigKeys as $mapKey => $configMeta) {
|
||||
$groupCode = $configMeta['config_group'];
|
||||
$configKey = $configMeta['config_key'];
|
||||
$configValue = (string)($configValueMap[$mapKey] ?? '');
|
||||
$mapKey = $groupCode . '.' . $configKey;
|
||||
|
||||
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
|
||||
@@ -405,8 +418,8 @@ class SystemConfigsController
|
||||
'items' => [
|
||||
['config_key' => 'app_id', 'title' => 'H5 AppID', 'field_type' => 'text', 'placeholder' => '请输入 H5 AppID', 'remark' => '用于 H5 登录与开放平台接入', 'is_secret' => false],
|
||||
['config_key' => 'app_secret', 'title' => 'H5 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入 H5 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
|
||||
['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '请输入 H5 授权回调地址', 'remark' => '用于 H5 登录或支付回调', 'is_secret' => false],
|
||||
['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false],
|
||||
['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '保存 H5 页面根地址后自动生成', 'remark' => '由 H5 页面根地址自动拼接,无需手动填写。', 'is_secret' => false, 'read_only' => true],
|
||||
['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinjianyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false],
|
||||
],
|
||||
],
|
||||
'payment' => [
|
||||
@@ -501,4 +514,38 @@ class SystemConfigsController
|
||||
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
|
||||
}
|
||||
}
|
||||
|
||||
private function applyDerivedConfigValues(array &$configValueMap): void
|
||||
{
|
||||
$configValueMap['h5.oauth_redirect_url'] = $this->buildH5OAuthRedirectUrl((string)($configValueMap['h5.page_base_url'] ?? ''));
|
||||
}
|
||||
|
||||
private function buildH5OAuthRedirectUrl(string $pageBaseUrl): string
|
||||
{
|
||||
$baseUrl = $this->normalizeH5PageBaseUrl($pageBaseUrl);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $baseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH;
|
||||
}
|
||||
|
||||
private function normalizeH5PageBaseUrl(string $value): string
|
||||
{
|
||||
$baseUrl = trim($value);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hashPos = strpos($baseUrl, '#');
|
||||
if ($hashPos !== false) {
|
||||
$baseUrl = substr($baseUrl, 0, $hashPos);
|
||||
}
|
||||
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,59 @@ class AuthController
|
||||
}
|
||||
}
|
||||
|
||||
public function wechatConfig(Request $request)
|
||||
{
|
||||
try {
|
||||
$payload = (new AppAuthService())->wechatConfig();
|
||||
return api_success($payload);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('微信授权配置获取失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function wechatExchange(Request $request)
|
||||
{
|
||||
$code = trim((string)$request->input('code', ''));
|
||||
$state = trim((string)$request->input('state', ''));
|
||||
if ($code === '') {
|
||||
return api_error('微信授权 code 不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new AppAuthService())->exchangeWechatCode($code, $state, $request);
|
||||
return api_success($payload, ($payload['status'] ?? '') === 'need_bind' ? '请绑定手机号' : '登录成功');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('微信授权登录失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function wechatBindMobile(Request $request)
|
||||
{
|
||||
$bindTicket = trim((string)$request->input('bind_ticket', ''));
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
$code = trim((string)$request->input('code', ''));
|
||||
if ($bindTicket === '' || $mobile === '' || $code === '') {
|
||||
return api_error('微信绑定凭证、手机号和验证码不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new AppAuthService())->bindWechatMobile($bindTicket, $mobile, $code, $request);
|
||||
return api_success($payload, '绑定成功');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('微信绑定手机号失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function me(Request $request)
|
||||
{
|
||||
$userInfo = (new AppAuthService())->current($request);
|
||||
|
||||
@@ -104,7 +104,13 @@ class ReportsController
|
||||
'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null),
|
||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
||||
];
|
||||
$productDisplay = $this->buildProductDisplay($reportData, $payload['product_snapshot'], $payload['result_snapshot'], $payload['valuation_snapshot']);
|
||||
$productDisplay = $this->buildProductDisplay(
|
||||
$reportData,
|
||||
$payload['product_snapshot'],
|
||||
$payload['result_snapshot'],
|
||||
$payload['valuation_snapshot'],
|
||||
$payload['appraisal_snapshot']
|
||||
);
|
||||
$reportMedia = [
|
||||
'images' => $this->filterAssetsByType($evidenceAttachments, 'image'),
|
||||
];
|
||||
@@ -118,7 +124,12 @@ class ReportsController
|
||||
)
|
||||
: ['visible' => false, 'nodes' => []];
|
||||
$traceInfo['visible'] = $traceInfoVisible;
|
||||
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: [], $productDisplay, $reportMedia);
|
||||
$pdfProductDisplay = $productDisplay;
|
||||
$pdfProductDisplay['items'] = array_values(array_filter(
|
||||
$productDisplay['items'] ?? [],
|
||||
fn (array $item) => ($item['label'] ?? '') !== '服务类型'
|
||||
));
|
||||
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: [], $pdfProductDisplay, $reportMedia);
|
||||
|
||||
return api_success([
|
||||
'report_header' => [
|
||||
@@ -128,6 +139,7 @@ class ReportsController
|
||||
'report_title' => $reportData['report_title'],
|
||||
'report_status' => $reportData['report_status'],
|
||||
'service_provider' => $reportData['service_provider'],
|
||||
'service_provider_text' => $this->serviceProviderText((string)$reportData['service_provider']),
|
||||
'institution_name' => $this->displayInstitutionName((string)$reportData['service_provider']),
|
||||
'publish_time' => $reportData['publish_time'],
|
||||
'zhongjian_report_no' => (string)($reportData['zhongjian_report_no'] ?? ''),
|
||||
@@ -283,7 +295,7 @@ class ReportsController
|
||||
$generator = new ReportPdfGenerator();
|
||||
$pdfBinary = $generator->generate([
|
||||
'report_title' => $report['report_title'] ?? '鉴定报告',
|
||||
'service_provider_text' => ($report['service_provider'] ?? 'anxinyan') === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'service_provider_text' => $this->serviceProviderText((string)($report['service_provider'] ?? 'anxinyan')),
|
||||
'institution_name' => $this->displayInstitutionName((string)($report['service_provider'] ?? 'anxinyan')),
|
||||
'report_no' => $report['report_no'] ?? '',
|
||||
'publish_time' => $publishTime,
|
||||
@@ -322,7 +334,7 @@ class ReportsController
|
||||
return $this->storage()->publicUrl($request, $relativePath);
|
||||
}
|
||||
|
||||
private function buildProductDisplay(array $report, array $productInfo, array $resultInfo, array $valuationInfo = []): array
|
||||
private function buildProductDisplay(array $report, array $productInfo, array $resultInfo, array $valuationInfo = [], array $appraisalInfo = []): array
|
||||
{
|
||||
$items = [];
|
||||
$this->appendDisplayItem(
|
||||
@@ -363,6 +375,16 @@ class ReportsController
|
||||
);
|
||||
}
|
||||
|
||||
$this->appendDisplayItem(
|
||||
$items,
|
||||
'服务类型',
|
||||
$this->serviceProviderText((string)($report['service_provider'] ?? 'anxinyan'))
|
||||
);
|
||||
$appraiserName = $this->textValue($appraisalInfo['appraiser_name'] ?? '')
|
||||
?: $this->textValue($appraisalInfo['reviewer_name'] ?? '')
|
||||
?: $this->textValue($report['report_entry_admin_name'] ?? '');
|
||||
$this->appendDisplayItem($items, '鉴定师', $appraiserName);
|
||||
|
||||
$conditionGrade = $this->textValue($valuationInfo['condition_grade'] ?? '');
|
||||
$conditionDesc = $this->textValue($valuationInfo['condition_desc'] ?? '');
|
||||
if ($conditionGrade !== '' || $conditionDesc !== '') {
|
||||
@@ -654,6 +676,11 @@ class ReportsController
|
||||
return trim((string)($value ?? ''));
|
||||
}
|
||||
|
||||
private function serviceProviderText(string $serviceProvider): string
|
||||
{
|
||||
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
|
||||
}
|
||||
|
||||
private function displayInstitutionName(string $serviceProvider): string
|
||||
{
|
||||
return $serviceProvider === 'zhongjian' ? '中检鉴定中心' : '安心检验';
|
||||
|
||||
@@ -12,7 +12,7 @@ class AppAuthMiddleware implements MiddlewareInterface
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$path = $request->path();
|
||||
if (!str_starts_with($path, '/api/app')) {
|
||||
if (strpos($path, '/api/app') !== 0) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ class AppAuthMiddleware implements MiddlewareInterface
|
||||
'/api/app/auth/send-code',
|
||||
'/api/app/auth/login/code',
|
||||
'/api/app/auth/login/password',
|
||||
'/api/app/auth/wechat/config',
|
||||
'/api/app/auth/wechat/exchange',
|
||||
'/api/app/auth/wechat/bind-mobile',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,33 @@ use support\think\Db;
|
||||
|
||||
class AppAuthService
|
||||
{
|
||||
private const WECHAT_H5_AUTH_TYPE = 'wechat_h5';
|
||||
private const H5_OAUTH_REDIRECT_HASH_PATH = '/#/pages/auth/login';
|
||||
private const WECHAT_BIND_TICKET_TTL = 600;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->ensurePasswordColumn();
|
||||
$this->ensureUserAuthsTable();
|
||||
$this->ensureTokenTable();
|
||||
$this->ensureSmsCodeTable();
|
||||
}
|
||||
|
||||
public function wechatConfig(): array
|
||||
{
|
||||
$appId = $this->systemConfig('h5', 'app_id');
|
||||
$appSecret = $this->systemConfig('h5', 'app_secret');
|
||||
$redirectUrl = $this->resolveH5OAuthRedirectUrl();
|
||||
|
||||
return [
|
||||
'appid' => $appId,
|
||||
'oauth_redirect_url' => $redirectUrl,
|
||||
'enabled' => $appId !== '' && $appSecret !== '' && $redirectUrl !== '',
|
||||
'scope' => 'snsapi_userinfo',
|
||||
'state' => $this->createWechatOAuthState(),
|
||||
];
|
||||
}
|
||||
|
||||
public function sendLoginCode(string $mobile, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
@@ -98,35 +118,8 @@ class AppAuthService
|
||||
public function loginByCode(string $mobile, string $code, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$code = trim($code);
|
||||
if (!preg_match('/^\d{6}$/', $code)) {
|
||||
throw new \RuntimeException('验证码格式不正确');
|
||||
}
|
||||
|
||||
$record = Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', 'login')
|
||||
->whereIn('send_status', ['success', 'mock'])
|
||||
->whereNull('used_at')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$record) {
|
||||
throw new \RuntimeException('验证码不存在或已失效');
|
||||
}
|
||||
|
||||
if (strtotime((string)$record['expire_time']) < time()) {
|
||||
throw new \RuntimeException('验证码已过期,请重新获取');
|
||||
}
|
||||
|
||||
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
|
||||
throw new \RuntimeException('验证码错误');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('sms_code_logs')->where('id', $record['id'])->update([
|
||||
'used_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->verifyLoginCode($mobile, $code, $now);
|
||||
|
||||
$user = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
@@ -152,6 +145,96 @@ class AppAuthService
|
||||
return $this->issueToken($userId, $request, 'sms_code');
|
||||
}
|
||||
|
||||
public function exchangeWechatCode(string $code, string $state, Request $request): array
|
||||
{
|
||||
$code = trim($code);
|
||||
if ($code === '') {
|
||||
throw new \RuntimeException('微信授权 code 不能为空');
|
||||
}
|
||||
$this->verifyWechatOAuthState($state);
|
||||
|
||||
$config = $this->wechatConfig();
|
||||
if (!$config['enabled']) {
|
||||
throw new \RuntimeException('微信授权登录未启用,请先在后台补全 H5 公众号配置和页面根地址');
|
||||
}
|
||||
|
||||
$oauthPayload = $this->fetchWechatOAuthAccessToken($code);
|
||||
$profilePayload = $this->fetchWechatUserInfoIfPossible($oauthPayload);
|
||||
$identity = $this->buildWechatIdentity($oauthPayload, $profilePayload, $state);
|
||||
|
||||
$auth = $this->findWechatAuth($identity['openid'], $identity['unionid']);
|
||||
if ($auth) {
|
||||
$user = Db::name('users')->where('id', $auth['user_id'])->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('微信已绑定账号不存在或已停用');
|
||||
}
|
||||
|
||||
$this->syncWechatAuth((int)$auth['user_id'], $identity, date('Y-m-d H:i:s'), (int)$auth['id']);
|
||||
return array_merge([
|
||||
'status' => 'logged_in',
|
||||
], $this->issueToken((int)$auth['user_id'], $request, self::WECHAT_H5_AUTH_TYPE));
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'need_bind',
|
||||
'bind_ticket' => $this->createWechatBindTicket($identity),
|
||||
'expire_seconds' => self::WECHAT_BIND_TICKET_TTL,
|
||||
'profile' => [
|
||||
'nickname' => $identity['nickname'],
|
||||
'avatar' => $identity['avatar'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function bindWechatMobile(string $bindTicket, string $mobile, string $code, Request $request): array
|
||||
{
|
||||
$identity = $this->verifyWechatBindTicket($bindTicket);
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$user = Db::name('users')->where('mobile', $mobile)->lock(true)->find();
|
||||
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号已停用,无法绑定微信');
|
||||
}
|
||||
|
||||
$this->assertWechatIdentityAvailable($identity, $user ? (int)$user['id'] : null);
|
||||
$this->verifyLoginCode($mobile, $code, $now);
|
||||
|
||||
if (!$user) {
|
||||
$userId = (int)Db::name('users')->insertGetId([
|
||||
'nickname' => $identity['nickname'] !== '' ? $this->truncateText($identity['nickname'], 64) : '安心验用户' . substr($mobile, -4),
|
||||
'avatar' => $this->truncateText($identity['avatar'], 255),
|
||||
'mobile' => $mobile,
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'last_login_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$userId = (int)$user['id'];
|
||||
$profilePatch = $this->buildWechatProfilePatch($user, $identity, $now);
|
||||
if ($profilePatch) {
|
||||
Db::name('users')->where('id', $userId)->update($profilePatch);
|
||||
}
|
||||
}
|
||||
|
||||
$this->syncMobileAuth($userId, $mobile, $now);
|
||||
$this->syncWechatAuth($userId, $identity, $now);
|
||||
$payload = array_merge([
|
||||
'status' => 'logged_in',
|
||||
], $this->issueToken($userId, $request, self::WECHAT_H5_AUTH_TYPE));
|
||||
|
||||
Db::commit();
|
||||
return $payload;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function loginByPassword(string $mobile, string $password, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
@@ -254,6 +337,41 @@ class AppAuthService
|
||||
];
|
||||
}
|
||||
|
||||
private function verifyLoginCode(string $mobile, string $code, ?string $now = null): void
|
||||
{
|
||||
$code = trim($code);
|
||||
if (!preg_match('/^\d{6}$/', $code)) {
|
||||
throw new \RuntimeException('验证码格式不正确');
|
||||
}
|
||||
|
||||
$record = Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', 'login')
|
||||
->whereIn('send_status', ['success', 'mock'])
|
||||
->whereNull('used_at')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$record) {
|
||||
throw new \RuntimeException('验证码不存在或已失效');
|
||||
}
|
||||
|
||||
if (strtotime((string)$record['expire_time']) < time()) {
|
||||
throw new \RuntimeException('验证码已过期,请重新获取');
|
||||
}
|
||||
|
||||
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
|
||||
throw new \RuntimeException('验证码错误');
|
||||
}
|
||||
|
||||
if ($now === null) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
}
|
||||
Db::name('sms_code_logs')->where('id', $record['id'])->update([
|
||||
'used_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function issueToken(int $userId, Request $request, string $authType): array
|
||||
{
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
@@ -329,6 +447,381 @@ class AppAuthService
|
||||
Db::name('user_auths')->insert($payload);
|
||||
}
|
||||
|
||||
private function syncWechatAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void
|
||||
{
|
||||
$openid = (string)($identity['openid'] ?? '');
|
||||
$unionid = (string)($identity['unionid'] ?? '');
|
||||
if ($openid === '') {
|
||||
throw new \RuntimeException('微信 openid 不能为空');
|
||||
}
|
||||
|
||||
$existing = null;
|
||||
if ($preferredAuthId) {
|
||||
$existing = Db::name('user_auths')->where('id', $preferredAuthId)->find();
|
||||
}
|
||||
if (!$existing) {
|
||||
$existing = Db::name('user_auths')
|
||||
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
|
||||
->where('auth_key', $openid)
|
||||
->find();
|
||||
}
|
||||
|
||||
if ($existing && (int)$existing['user_id'] !== $userId) {
|
||||
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
|
||||
}
|
||||
|
||||
if ($unionid !== '') {
|
||||
$unionAuth = Db::name('user_auths')
|
||||
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
|
||||
->where('auth_union_id', $unionid)
|
||||
->find();
|
||||
if ($unionAuth && (int)$unionAuth['user_id'] !== $userId) {
|
||||
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
|
||||
}
|
||||
if (!$existing && $unionAuth) {
|
||||
$existing = $unionAuth;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'user_id' => $userId,
|
||||
'auth_type' => self::WECHAT_H5_AUTH_TYPE,
|
||||
'auth_key' => $openid,
|
||||
'auth_open_id' => $openid,
|
||||
'auth_union_id' => $unionid,
|
||||
'auth_extra' => json_encode($identity['auth_extra'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
Db::name('user_auths')->where('id', $existing['id'])->update($payload);
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('user_auths')->insert($payload);
|
||||
}
|
||||
|
||||
private function assertWechatIdentityAvailable(array $identity, ?int $allowedUserId): void
|
||||
{
|
||||
$openid = (string)($identity['openid'] ?? '');
|
||||
$unionid = (string)($identity['unionid'] ?? '');
|
||||
|
||||
$openidAuth = Db::name('user_auths')
|
||||
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
|
||||
->where('auth_key', $openid)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($openidAuth && ($allowedUserId === null || (int)$openidAuth['user_id'] !== $allowedUserId)) {
|
||||
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
|
||||
}
|
||||
|
||||
if ($unionid === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$unionAuth = Db::name('user_auths')
|
||||
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
|
||||
->where('auth_union_id', $unionid)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($unionAuth && ($allowedUserId === null || (int)$unionAuth['user_id'] !== $allowedUserId)) {
|
||||
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
|
||||
}
|
||||
}
|
||||
|
||||
private function findWechatAuth(string $openid, string $unionid): ?array
|
||||
{
|
||||
$auth = Db::name('user_auths')
|
||||
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
|
||||
->where('auth_key', $openid)
|
||||
->find();
|
||||
if ($auth) {
|
||||
return $auth;
|
||||
}
|
||||
|
||||
if ($unionid === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$auth = Db::name('user_auths')
|
||||
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
|
||||
->where('auth_union_id', $unionid)
|
||||
->order('id', 'asc')
|
||||
->find();
|
||||
|
||||
return $auth ?: null;
|
||||
}
|
||||
|
||||
private function buildWechatProfilePatch(array $user, array $identity, string $now): array
|
||||
{
|
||||
$patch = [];
|
||||
$nickname = trim((string)($identity['nickname'] ?? ''));
|
||||
$avatar = trim((string)($identity['avatar'] ?? ''));
|
||||
$currentNickname = trim((string)($user['nickname'] ?? ''));
|
||||
$currentAvatar = trim((string)($user['avatar'] ?? ''));
|
||||
|
||||
if ($nickname !== '' && ($currentNickname === '' || preg_match('/^安心验用户\d{4}$/u', $currentNickname))) {
|
||||
$patch['nickname'] = $this->truncateText($nickname, 64);
|
||||
}
|
||||
if ($avatar !== '' && $currentAvatar === '') {
|
||||
$patch['avatar'] = $this->truncateText($avatar, 255);
|
||||
}
|
||||
if ($patch) {
|
||||
$patch['updated_at'] = $now;
|
||||
}
|
||||
|
||||
return $patch;
|
||||
}
|
||||
|
||||
private function fetchWechatOAuthAccessToken(string $code): array
|
||||
{
|
||||
if ($this->isWechatMockCode($code)) {
|
||||
return $this->mockWechatOAuthPayload($code);
|
||||
}
|
||||
|
||||
$appId = $this->systemConfig('h5', 'app_id');
|
||||
$appSecret = $this->systemConfig('h5', 'app_secret');
|
||||
$url = 'https://api.weixin.qq.com/sns/oauth2/access_token?' . http_build_query([
|
||||
'appid' => $appId,
|
||||
'secret' => $appSecret,
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
|
||||
return $this->wechatApiGet($url, '微信授权 code 换取失败');
|
||||
}
|
||||
|
||||
private function fetchWechatUserInfoIfPossible(array $oauthPayload): array
|
||||
{
|
||||
$scope = (string)($oauthPayload['scope'] ?? '');
|
||||
$accessToken = (string)($oauthPayload['access_token'] ?? '');
|
||||
$openid = (string)($oauthPayload['openid'] ?? '');
|
||||
if ($openid === '' || $accessToken === '' || strpos($scope, 'snsapi_userinfo') === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (strpos($accessToken, 'mock_access_token_') === 0) {
|
||||
return $this->mockWechatProfilePayload($oauthPayload);
|
||||
}
|
||||
|
||||
$url = 'https://api.weixin.qq.com/sns/userinfo?' . http_build_query([
|
||||
'access_token' => $accessToken,
|
||||
'openid' => $openid,
|
||||
'lang' => 'zh_CN',
|
||||
]);
|
||||
|
||||
try {
|
||||
return $this->wechatApiGet($url, '微信用户资料获取失败');
|
||||
} catch (\RuntimeException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildWechatIdentity(array $oauthPayload, array $profilePayload, string $state): array
|
||||
{
|
||||
$openid = trim((string)($oauthPayload['openid'] ?? $profilePayload['openid'] ?? ''));
|
||||
if ($openid === '') {
|
||||
throw new \RuntimeException('微信授权返回缺少 openid,请重新登录');
|
||||
}
|
||||
|
||||
$unionid = trim((string)($profilePayload['unionid'] ?? $oauthPayload['unionid'] ?? ''));
|
||||
$nickname = trim((string)($profilePayload['nickname'] ?? ''));
|
||||
$avatar = trim((string)($profilePayload['headimgurl'] ?? ''));
|
||||
|
||||
return [
|
||||
'openid' => $openid,
|
||||
'unionid' => $unionid,
|
||||
'nickname' => $nickname,
|
||||
'avatar' => $avatar,
|
||||
'auth_extra' => [
|
||||
'oauth' => $this->redactWechatOAuthPayload($oauthPayload),
|
||||
'profile' => $profilePayload,
|
||||
'state' => $this->truncateText($state, 128),
|
||||
'authorized_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function createWechatBindTicket(array $identity): string
|
||||
{
|
||||
$now = time();
|
||||
$payload = [
|
||||
'typ' => 'wechat_h5_bind',
|
||||
'openid' => (string)$identity['openid'],
|
||||
'unionid' => (string)($identity['unionid'] ?? ''),
|
||||
'nickname' => $this->truncateText((string)($identity['nickname'] ?? ''), 64),
|
||||
'avatar' => $this->truncateText((string)($identity['avatar'] ?? ''), 255),
|
||||
'auth_extra' => $identity['auth_extra'] ?? [],
|
||||
'iat' => $now,
|
||||
'exp' => $now + self::WECHAT_BIND_TICKET_TTL,
|
||||
'nonce' => bin2hex(random_bytes(8)),
|
||||
];
|
||||
|
||||
$body = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$signature = hash_hmac('sha256', $body, $this->ticketSecret());
|
||||
|
||||
return $body . '.' . $signature;
|
||||
}
|
||||
|
||||
private function createWechatOAuthState(): string
|
||||
{
|
||||
$expiresAt = str_pad(base_convert((string)(time() + 600), 10, 36), 8, '0', STR_PAD_LEFT);
|
||||
$base = 'w' . $expiresAt . bin2hex(random_bytes(4));
|
||||
$signature = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20);
|
||||
|
||||
return $base . $signature;
|
||||
}
|
||||
|
||||
private function verifyWechatOAuthState(string $state): void
|
||||
{
|
||||
$state = trim($state);
|
||||
if (!preg_match('/^w[a-z0-9]{36}$/i', $state)) {
|
||||
throw new \RuntimeException('微信授权状态不匹配,请重新登录');
|
||||
}
|
||||
|
||||
$base = substr($state, 0, 17);
|
||||
$signature = substr($state, 17);
|
||||
$expected = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20);
|
||||
if (!hash_equals($expected, strtolower($signature))) {
|
||||
throw new \RuntimeException('微信授权状态不匹配,请重新登录');
|
||||
}
|
||||
|
||||
$expiresAt = (int)base_convert(substr($state, 1, 8), 36, 10);
|
||||
if ($expiresAt < time()) {
|
||||
throw new \RuntimeException('微信授权状态已过期,请重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
private function verifyWechatBindTicket(string $ticket): array
|
||||
{
|
||||
$ticket = trim($ticket);
|
||||
if ($ticket === '' || strpos($ticket, '.') === false) {
|
||||
throw new \RuntimeException('微信绑定凭证无效,请重新授权');
|
||||
}
|
||||
|
||||
[$body, $signature] = explode('.', $ticket, 2);
|
||||
$expected = hash_hmac('sha256', $body, $this->ticketSecret());
|
||||
if (!hash_equals($expected, strtolower($signature))) {
|
||||
throw new \RuntimeException('微信绑定凭证签名无效,请重新授权');
|
||||
}
|
||||
|
||||
$decoded = json_decode($this->base64UrlDecode($body), true);
|
||||
if (!is_array($decoded) || ($decoded['typ'] ?? '') !== 'wechat_h5_bind') {
|
||||
throw new \RuntimeException('微信绑定凭证无效,请重新授权');
|
||||
}
|
||||
|
||||
if ((int)($decoded['exp'] ?? 0) < time()) {
|
||||
throw new \RuntimeException('微信绑定凭证已过期,请重新授权');
|
||||
}
|
||||
|
||||
$openid = trim((string)($decoded['openid'] ?? ''));
|
||||
if ($openid === '') {
|
||||
throw new \RuntimeException('微信绑定凭证缺少 openid,请重新授权');
|
||||
}
|
||||
|
||||
return [
|
||||
'openid' => $openid,
|
||||
'unionid' => trim((string)($decoded['unionid'] ?? '')),
|
||||
'nickname' => trim((string)($decoded['nickname'] ?? '')),
|
||||
'avatar' => trim((string)($decoded['avatar'] ?? '')),
|
||||
'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function wechatApiGet(string $url, string $fallbackMessage): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 4,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
throw new \RuntimeException($fallbackMessage . ':' . $error);
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300) {
|
||||
throw new \RuntimeException($fallbackMessage . ':微信接口 HTTP ' . $httpStatus);
|
||||
}
|
||||
|
||||
$payload = json_decode((string)$response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException($fallbackMessage . ':微信接口返回异常');
|
||||
}
|
||||
|
||||
$errcode = (int)($payload['errcode'] ?? 0);
|
||||
if ($errcode !== 0) {
|
||||
throw new \RuntimeException($this->wechatErrorMessage($errcode, (string)($payload['errmsg'] ?? ''), $fallbackMessage));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function wechatErrorMessage(int $errcode, string $errmsg, string $fallbackMessage): string
|
||||
{
|
||||
$messages = [
|
||||
40029 => '微信授权 code 无效或已过期,请重新登录',
|
||||
40163 => '微信授权 code 已被使用,请重新发起授权',
|
||||
40013 => 'H5 公众号 AppID 无效,请检查后台配置',
|
||||
40125 => 'H5 公众号 AppSecret 无效,请检查后台配置',
|
||||
];
|
||||
|
||||
return $messages[$errcode] ?? ($fallbackMessage . ($errmsg !== '' ? ':' . $errmsg : ''));
|
||||
}
|
||||
|
||||
private function redactWechatOAuthPayload(array $payload): array
|
||||
{
|
||||
unset($payload['access_token'], $payload['refresh_token']);
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function isWechatMockCode(string $code): bool
|
||||
{
|
||||
if (strpos($code, 'mock_') !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(strtolower((string)($_ENV['WECHAT_H5_AUTH_MOCK'] ?? '')), ['1', 'true', 'yes'], true)
|
||||
|| in_array(strtolower((string)($_ENV['APP_DEBUG'] ?? 'false')), ['1', 'true'], true);
|
||||
}
|
||||
|
||||
private function mockWechatOAuthPayload(string $code): array
|
||||
{
|
||||
if (strpos($code, 'expired') !== false || strpos($code, 'invalid') !== false) {
|
||||
throw new \RuntimeException('微信授权 code 无效或已过期,请重新登录');
|
||||
}
|
||||
|
||||
$suffix = preg_replace('/[^A-Za-z0-9]/', '', substr($code, 5)) ?: 'user';
|
||||
return [
|
||||
'access_token' => 'mock_access_token_' . $suffix,
|
||||
'expires_in' => 7200,
|
||||
'refresh_token' => 'mock_refresh_token_' . $suffix,
|
||||
'openid' => 'mock_openid_' . $suffix,
|
||||
'scope' => 'snsapi_userinfo',
|
||||
'unionid' => 'mock_unionid_' . $suffix,
|
||||
];
|
||||
}
|
||||
|
||||
private function mockWechatProfilePayload(array $oauthPayload): array
|
||||
{
|
||||
$openid = (string)($oauthPayload['openid'] ?? '');
|
||||
$suffix = str_replace('mock_openid_', '', $openid) ?: 'user';
|
||||
return [
|
||||
'openid' => $openid,
|
||||
'nickname' => '微信用户' . $suffix,
|
||||
'headimgurl' => 'https://thirdwx.qlogo.cn/mmopen/mock/' . rawurlencode($suffix) . '/132',
|
||||
'privilege' => [],
|
||||
'unionid' => (string)($oauthPayload['unionid'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeMobile(string $mobile): string
|
||||
{
|
||||
$mobile = preg_replace('/\D+/', '', $mobile) ?: '';
|
||||
@@ -353,6 +846,35 @@ class AppAuthService
|
||||
return hash('sha256', implode('|', [$mobile, $scene, $code]));
|
||||
}
|
||||
|
||||
private function ticketSecret(): string
|
||||
{
|
||||
$seed = trim((string)($_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? ''));
|
||||
if ($seed === '') {
|
||||
$seed = $this->systemConfig('h5', 'app_secret');
|
||||
}
|
||||
if ($seed === '') {
|
||||
$seed = 'anxinyan-app-auth-secret-key';
|
||||
}
|
||||
|
||||
return hash('sha256', $seed, true);
|
||||
}
|
||||
|
||||
private function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $value): string
|
||||
{
|
||||
$padding = strlen($value) % 4;
|
||||
if ($padding > 0) {
|
||||
$value .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
|
||||
return is_string($decoded) ? $decoded : '';
|
||||
}
|
||||
|
||||
private function systemConfig(string $groupCode, string $configKey): string
|
||||
{
|
||||
$row = Db::name('system_configs')
|
||||
@@ -363,6 +885,35 @@ class AppAuthService
|
||||
return trim((string)($row['config_value'] ?? ''));
|
||||
}
|
||||
|
||||
private function resolveH5OAuthRedirectUrl(): string
|
||||
{
|
||||
$pageBaseUrl = $this->normalizeH5PageBaseUrl($this->systemConfig('h5', 'page_base_url'));
|
||||
if ($pageBaseUrl !== '') {
|
||||
return $pageBaseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH;
|
||||
}
|
||||
|
||||
return $this->systemConfig('h5', 'oauth_redirect_url');
|
||||
}
|
||||
|
||||
private function normalizeH5PageBaseUrl(string $value): string
|
||||
{
|
||||
$baseUrl = trim($value);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hashPos = strpos($baseUrl, '#');
|
||||
if ($hashPos !== false) {
|
||||
$baseUrl = substr($baseUrl, 0, $hashPos);
|
||||
}
|
||||
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): string
|
||||
{
|
||||
$authorization = trim((string)$request->header('authorization', ''));
|
||||
@@ -382,6 +933,32 @@ class AppAuthService
|
||||
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
|
||||
}
|
||||
|
||||
private function ensureUserAuthsTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS user_auths (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
auth_type VARCHAR(32) NOT NULL,
|
||||
auth_open_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
auth_union_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
auth_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||
auth_extra JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key),
|
||||
KEY idx_user_auths_user_id (user_id),
|
||||
KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射';
|
||||
SQL);
|
||||
|
||||
$index = Db::query("SHOW INDEX FROM user_auths WHERE Key_name = 'idx_user_auths_auth_union_id'");
|
||||
if (!$index) {
|
||||
Db::execute("ALTER TABLE user_auths ADD KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)");
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureTokenTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
|
||||
@@ -154,6 +154,9 @@ Route::get('/api/app/help-article/detail', [AppHelpCenterController::class, 'det
|
||||
Route::post('/api/app/auth/send-code', [AppAuthController::class, 'sendCode']);
|
||||
Route::post('/api/app/auth/login/code', [AppAuthController::class, 'loginByCode']);
|
||||
Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByPassword']);
|
||||
Route::get('/api/app/auth/wechat/config', [AppAuthController::class, 'wechatConfig']);
|
||||
Route::post('/api/app/auth/wechat/exchange', [AppAuthController::class, 'wechatExchange']);
|
||||
Route::post('/api/app/auth/wechat/bind-mobile', [AppAuthController::class, 'wechatBindMobile']);
|
||||
Route::get('/api/app/auth/me', [AppAuthController::class, 'me']);
|
||||
Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']);
|
||||
Route::post('/api/app/auth/logout', [AppAuthController::class, 'logout']);
|
||||
|
||||
@@ -108,7 +108,8 @@ CREATE TABLE user_auths (
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key),
|
||||
KEY idx_user_auths_user_id (user_id)
|
||||
KEY idx_user_auths_user_id (user_id),
|
||||
KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射';
|
||||
|
||||
CREATE TABLE user_api_tokens (
|
||||
|
||||
@@ -51,6 +51,31 @@ function isPlaceholderApiBase(string $apiBase): bool
|
||||
return str_contains($normalized, 'example.com');
|
||||
}
|
||||
|
||||
function normalizeH5PageBaseUrl(string $value): string
|
||||
{
|
||||
$baseUrl = trim($value);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hashPos = strpos($baseUrl, '#');
|
||||
if ($hashPos !== false) {
|
||||
$baseUrl = substr($baseUrl, 0, $hashPos);
|
||||
}
|
||||
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
function buildH5OAuthRedirectUrl(string $pageBaseUrl): string
|
||||
{
|
||||
$baseUrl = normalizeH5PageBaseUrl($pageBaseUrl);
|
||||
return $baseUrl === '' ? '' : $baseUrl . '/#/pages/auth/login';
|
||||
}
|
||||
|
||||
function checkClientProductionApiBase(array &$issues, string $label, string $envPath): void
|
||||
{
|
||||
$env = @parse_ini_file($envPath);
|
||||
@@ -97,6 +122,7 @@ $configMap = [];
|
||||
foreach ($configRows as $row) {
|
||||
$configMap[$row['config_group'] . '.' . $row['config_key']] = (string)($row['config_value'] ?? '');
|
||||
}
|
||||
$configMap['h5.oauth_redirect_url'] = buildH5OAuthRedirectUrl((string)($configMap['h5.page_base_url'] ?? ''));
|
||||
|
||||
$requiredConfigKeys = [
|
||||
'mini_program.app_id',
|
||||
|
||||
66
server-api/tools/schema_upgrade_wechat_h5_auth.php
Normal file
66
server-api/tools/schema_upgrade_wechat_h5_auth.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||
$dotenv->safeLoad();
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
$_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||
$_ENV['DB_PORT'] ?? '3306',
|
||||
$_ENV['DB_DATABASE'] ?? '',
|
||||
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
|
||||
);
|
||||
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
$_ENV['DB_USERNAME'] ?? '',
|
||||
$_ENV['DB_PASSWORD'] ?? '',
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
|
||||
function hasTable(PDO $pdo, string $table): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?');
|
||||
$stmt->execute([$table]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function hasIndex(PDO $pdo, string $table, string $index): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?');
|
||||
$stmt->execute([$table, $index]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
if (!hasTable($pdo, 'user_auths')) {
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE user_auths (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
auth_type VARCHAR(32) NOT NULL,
|
||||
auth_open_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
auth_union_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
auth_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||
auth_extra JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key),
|
||||
KEY idx_user_auths_user_id (user_id),
|
||||
KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射'
|
||||
SQL);
|
||||
echo "CREATE_TABLE user_auths\n";
|
||||
} elseif (!hasIndex($pdo, 'user_auths', 'idx_user_auths_auth_union_id')) {
|
||||
$pdo->exec('ALTER TABLE user_auths ADD KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)');
|
||||
echo "ADD_INDEX user_auths.idx_user_auths_auth_union_id\n";
|
||||
}
|
||||
|
||||
echo "SCHEMA_UPGRADE_OK\n";
|
||||
206
server-api/tools/wechat_h5_auth_mock_test.php
Normal file
206
server-api/tools/wechat_h5_auth_mock_test.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||
$dotenv->safeLoad();
|
||||
|
||||
$_ENV['APP_DEBUG'] = 'true';
|
||||
$_ENV['WECHAT_H5_AUTH_MOCK'] = 'true';
|
||||
|
||||
use app\support\AppAuthService;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
|
||||
Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php');
|
||||
|
||||
function assertTrue(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function makeRequest(): Request
|
||||
{
|
||||
return new class("GET /api/app/auth/wechat/mock-test HTTP/1.1\r\nHost: 127.0.0.1\r\nUser-Agent: wechat-h5-auth-mock-test\r\n\r\n") extends Request {
|
||||
public function getRealIp(bool $safeMode = true): string
|
||||
{
|
||||
return '127.0.0.1';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function ensureConfig(string $group, string $key, string $value): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$exists = Db::name('system_configs')
|
||||
->where('config_group', $group)
|
||||
->where('config_key', $key)
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'config_group' => $group,
|
||||
'config_key' => $key,
|
||||
'config_value' => $value,
|
||||
'remark' => '微信 H5 授权测试配置',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('system_configs')->insert($payload);
|
||||
}
|
||||
|
||||
function latestDebugCode(string $mobile): string
|
||||
{
|
||||
$record = Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', 'login')
|
||||
->where('send_status', 'mock')
|
||||
->whereNull('used_at')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
assertTrue((bool)$record, 'mock sms code record missing');
|
||||
|
||||
for ($code = 100000; $code <= 999999; $code++) {
|
||||
$codeText = (string)$code;
|
||||
if (hash_equals((string)$record['code_hash'], hash('sha256', implode('|', [$mobile, 'login', $codeText])))) {
|
||||
return $codeText;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('mock sms code not found');
|
||||
}
|
||||
|
||||
function cleanupWechatMockData(): void
|
||||
{
|
||||
$userIds = Db::name('users')
|
||||
->whereLike('mobile', '1399900%')
|
||||
->column('id');
|
||||
if ($userIds) {
|
||||
Db::name('user_api_tokens')->whereIn('user_id', $userIds)->delete();
|
||||
Db::name('user_auths')->whereIn('user_id', $userIds)->delete();
|
||||
Db::name('users')->whereIn('id', $userIds)->delete();
|
||||
}
|
||||
|
||||
Db::name('sms_code_logs')->whereLike('mobile', '1399900%')->delete();
|
||||
Db::name('user_auths')->where('auth_type', 'wechat_h5')->whereLike('auth_key', 'mock_openid_%')->delete();
|
||||
}
|
||||
|
||||
$service = new AppAuthService();
|
||||
$request = makeRequest();
|
||||
$originalConfigs = [
|
||||
'h5.app_id' => Db::name('system_configs')->where('config_group', 'h5')->where('config_key', 'app_id')->value('config_value'),
|
||||
'h5.app_secret' => Db::name('system_configs')->where('config_group', 'h5')->where('config_key', 'app_secret')->value('config_value'),
|
||||
'h5.page_base_url' => Db::name('system_configs')->where('config_group', 'h5')->where('config_key', 'page_base_url')->value('config_value'),
|
||||
];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
cleanupWechatMockData();
|
||||
ensureConfig('h5', 'app_id', 'wx_mock_appid');
|
||||
ensureConfig('h5', 'app_secret', 'mock_secret');
|
||||
ensureConfig('h5', 'page_base_url', 'https://m.example.com');
|
||||
ensureConfig('sms', 'access_key_id', '');
|
||||
ensureConfig('sms', 'access_key_secret', '');
|
||||
ensureConfig('sms', 'sign_name', '');
|
||||
ensureConfig('sms', 'login_template_code', '');
|
||||
|
||||
$config = $service->wechatConfig();
|
||||
assertTrue($config['enabled'] === true, 'wechat config should be enabled');
|
||||
assertTrue($config['oauth_redirect_url'] === 'https://m.example.com/#/pages/auth/login', 'oauth redirect url mismatch');
|
||||
|
||||
$stateOne = (string)$config['state'];
|
||||
$exchange = $service->exchangeWechatCode('mock_newuser', $stateOne, $request);
|
||||
assertTrue(($exchange['status'] ?? '') === 'need_bind', 'new wechat user should need binding');
|
||||
assertTrue(!empty($exchange['bind_ticket']), 'bind ticket missing');
|
||||
|
||||
$mobile = '13999000001';
|
||||
$service->sendLoginCode($mobile, $request);
|
||||
$bind = $service->bindWechatMobile((string)$exchange['bind_ticket'], $mobile, latestDebugCode($mobile), $request);
|
||||
assertTrue(($bind['status'] ?? '') === 'logged_in' && !empty($bind['token']), 'bind should return token');
|
||||
|
||||
$stateTwo = (string)$service->wechatConfig()['state'];
|
||||
$linked = $service->exchangeWechatCode('mock_newuser', $stateTwo, $request);
|
||||
assertTrue(($linked['status'] ?? '') === 'logged_in' && !empty($linked['token']), 'linked wechat user should login');
|
||||
|
||||
$stateThree = (string)$service->wechatConfig()['state'];
|
||||
$existingUser = $service->exchangeWechatCode('mock_existingmobile', $stateThree, $request);
|
||||
$existingMobile = '13999000002';
|
||||
Db::name('users')->insert([
|
||||
'nickname' => '已有手机号用户',
|
||||
'avatar' => '',
|
||||
'mobile' => $existingMobile,
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$service->sendLoginCode($existingMobile, $request);
|
||||
$boundExisting = $service->bindWechatMobile((string)$existingUser['bind_ticket'], $existingMobile, latestDebugCode($existingMobile), $request);
|
||||
assertTrue(($boundExisting['status'] ?? '') === 'logged_in', 'existing mobile should bind');
|
||||
assertTrue((int)Db::name('users')->where('mobile', $existingMobile)->count() === 1, 'existing mobile should not duplicate user');
|
||||
|
||||
$otherUserId = (int)Db::name('users')->insertGetId([
|
||||
'nickname' => '占用微信用户',
|
||||
'avatar' => '',
|
||||
'mobile' => '13999000003',
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
Db::name('user_auths')->insert([
|
||||
'user_id' => $otherUserId,
|
||||
'auth_type' => 'wechat_h5',
|
||||
'auth_key' => 'mock_openid_conflict',
|
||||
'auth_open_id' => 'mock_openid_conflict',
|
||||
'auth_union_id' => 'mock_unionid_conflict',
|
||||
'auth_extra' => json_encode(['mock' => true]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$conflictUser = $service->exchangeWechatCode('mock_conflict', (string)$service->wechatConfig()['state'], $request);
|
||||
assertTrue(($conflictUser['status'] ?? '') === 'logged_in', 'existing conflicting openid should login owner directly');
|
||||
|
||||
try {
|
||||
$service->exchangeWechatCode('mock_expired', (string)$service->wechatConfig()['state'], $request);
|
||||
throw new RuntimeException('expired code should fail');
|
||||
} catch (RuntimeException $e) {
|
||||
assertTrue(strpos($e->getMessage(), 'code 无效') !== false, 'expired code message mismatch');
|
||||
}
|
||||
|
||||
try {
|
||||
$service->bindWechatMobile('invalid.ticket', '13999000004', '123456', $request);
|
||||
throw new RuntimeException('invalid bind ticket should fail');
|
||||
} catch (RuntimeException $e) {
|
||||
assertTrue(strpos($e->getMessage(), '绑定凭证') !== false, 'invalid bind ticket message mismatch');
|
||||
}
|
||||
|
||||
try {
|
||||
$service->exchangeWechatCode('mock_statefail', 'wrongstate', $request);
|
||||
throw new RuntimeException('invalid state should fail');
|
||||
} catch (RuntimeException $e) {
|
||||
assertTrue(strpos($e->getMessage(), '状态') !== false, 'invalid state message mismatch');
|
||||
}
|
||||
|
||||
echo "WECHAT_H5_AUTH_MOCK_TEST_OK\n";
|
||||
Db::rollback();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
fwrite(STDERR, "WECHAT_H5_AUTH_MOCK_TEST_FAIL: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
} finally {
|
||||
foreach ($originalConfigs as $mapKey => $value) {
|
||||
[$group, $key] = explode('.', $mapKey, 2);
|
||||
ensureConfig($group, $key, (string)$value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user