diff --git a/admin-web/src/api/admin.ts b/admin-web/src/api/admin.ts index 3a12789..a415067 100644 --- a/admin-web/src/api/admin.ts +++ b/admin-web/src/api/admin.ts @@ -1346,6 +1346,7 @@ export interface AdminSystemConfigGroupItem { placeholder: string; remark: string; is_secret: boolean; + read_only?: boolean; value: string; options?: Array<{ label: string; diff --git a/admin-web/src/pages/system-config/index.vue b/admin-web/src/pages/system-config/index.vue index 01072cb..b23c3ac 100644 --- a/admin-web/src/pages/system-config/index.vue +++ b/admin-web/src/pages/system-config/index.vue @@ -36,6 +36,7 @@ async function fetchConfigs() { try { const response = await adminApi.getSystemConfigs(); groups.value = sortGroups(response.data.groups); + groups.value.forEach(applyDerivedConfigValues); groupSnapshots.value = cloneSnapshot(groups.value); } catch (error) { console.error(error); @@ -63,9 +64,52 @@ function markFieldSnapshot(groupCode: string, configKey: string, value: string) }; } +function normalizeH5PageBaseUrl(value: string) { + let baseUrl = value.trim(); + if (!baseUrl) return ""; + + const hashIndex = baseUrl.indexOf("#"); + if (hashIndex !== -1) { + baseUrl = baseUrl.slice(0, hashIndex); + } + + if (!/^https?:\/\//i.test(baseUrl)) { + baseUrl = `https://${baseUrl.replace(/^\/+/, "")}`; + } + + return baseUrl.replace(/\/+$/, ""); +} + +function buildH5OAuthRedirectUrl(pageBaseUrl: string) { + const baseUrl = normalizeH5PageBaseUrl(pageBaseUrl); + return baseUrl ? `${baseUrl}/#/pages/auth/login` : ""; +} + +function applyDerivedConfigValues(group: AdminSystemConfigGroupItem) { + if (group.group_code !== "h5") return; + + const pageBaseUrl = group.items.find((item) => item.config_key === "page_base_url"); + const oauthRedirectUrl = group.items.find((item) => item.config_key === "oauth_redirect_url"); + if (!oauthRedirectUrl) return; + + oauthRedirectUrl.value = buildH5OAuthRedirectUrl(pageBaseUrl?.value || ""); +} + +function handleFieldValueChange( + group: AdminSystemConfigGroupItem, + item: AdminSystemConfigGroupItem["items"][number], + value: string | number, +) { + if (item.read_only) return; + + item.value = String(value ?? ""); + applyDerivedConfigValues(group); +} + async function saveGroup(group: AdminSystemConfigGroupItem) { savingGroupCode.value = group.group_code; try { + applyDerivedConfigValues(group); const items = group.items.map((item) => ({ config_group: group.group_code, config_key: item.config_key, @@ -216,17 +260,21 @@ onMounted(fetchConfigs);
{{ item.remark }} diff --git a/docs/deploy/release-checklist.md b/docs/deploy/release-checklist.md index 7a957d4..a350fe1 100644 --- a/docs/deploy/release-checklist.md +++ b/docs/deploy/release-checklist.md @@ -10,7 +10,7 @@ ## 2. 后台系统配置 - 在后台 `系统配置` 中填写并保存: - 小程序 `AppID / AppSecret / 原始ID` - - H5 `AppID / AppSecret / OAuth 回调地址 / H5 页面根地址` + - H5 `AppID / AppSecret / H5 页面根地址`,`OAuth 回调地址` 会由 H5 页面根地址自动生成 - 短信 `阿里云 AccessKey ID / AccessKey Secret / 短信签名 / 登录模板 Code / Region ID` - 支付 `MchID / APIv3 Key / 商户证书序列号 / 商户私钥 / 平台证书序列号 / 支付回调地址` - 严禁保留演示值: @@ -50,6 +50,7 @@ - `cd user-app && npm run build:mp-weixin` - 构建前确认 [user-app/src/manifest.json](/Users/wushumin/www/biyou/anxinyan/user-app/src/manifest.json) 中 `mp-weixin.appid` 已同步为正式值 - 确认后台 `H5 页面根地址` 指向正式 H5 域名,例如 `https://m.example.com`,用于生成扫码查看报告和验真页链接 +- 后台 `H5 授权回调地址` 应自动显示为 `H5 页面根地址 + /#/pages/auth/login` - H5 授权域名、支付域名、回调域名已在微信平台完成配置 - 微信支付商户平台证书与 APIv3 Key 已完成正式部署 diff --git a/server-api/app/controller/admin/SystemConfigsController.php b/server-api/app/controller/admin/SystemConfigsController.php index 8e412b1..1c02f32 100644 --- a/server-api/app/controller/admin/SystemConfigsController.php +++ b/server-api/app/controller/admin/SystemConfigsController.php @@ -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, '/'); + } } diff --git a/server-api/app/controller/app/AuthController.php b/server-api/app/controller/app/AuthController.php index 4259c02..9e5dc8d 100644 --- a/server-api/app/controller/app/AuthController.php +++ b/server-api/app/controller/app/AuthController.php @@ -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); diff --git a/server-api/app/controller/app/ReportsController.php b/server-api/app/controller/app/ReportsController.php index 3b097bd..2906a2c 100644 --- a/server-api/app/controller/app/ReportsController.php +++ b/server-api/app/controller/app/ReportsController.php @@ -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' ? '中检鉴定中心' : '安心检验'; diff --git a/server-api/app/middleware/AppAuthMiddleware.php b/server-api/app/middleware/AppAuthMiddleware.php index e50c980..ea8f028 100644 --- a/server-api/app/middleware/AppAuthMiddleware.php +++ b/server-api/app/middleware/AppAuthMiddleware.php @@ -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); } } diff --git a/server-api/app/support/AppAuthService.php b/server-api/app/support/AppAuthService.php index 3e234c8..ece972d 100644 --- a/server-api/app/support/AppAuthService.php +++ b/server-api/app/support/AppAuthService.php @@ -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' diff --git a/server-api/config/route.php b/server-api/config/route.php index f8112fd..f863d29 100644 --- a/server-api/config/route.php +++ b/server-api/config/route.php @@ -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']); diff --git a/server-api/database/schema.sql b/server-api/database/schema.sql index aa708f0..d2408fa 100644 --- a/server-api/database/schema.sql +++ b/server-api/database/schema.sql @@ -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 ( diff --git a/server-api/tools/release_audit.php b/server-api/tools/release_audit.php index 2bc5760..66fc883 100644 --- a/server-api/tools/release_audit.php +++ b/server-api/tools/release_audit.php @@ -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', diff --git a/server-api/tools/schema_upgrade_wechat_h5_auth.php b/server-api/tools/schema_upgrade_wechat_h5_auth.php new file mode 100644 index 0000000..b2b9d26 --- /dev/null +++ b/server-api/tools/schema_upgrade_wechat_h5_auth.php @@ -0,0 +1,66 @@ +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"; diff --git a/server-api/tools/wechat_h5_auth_mock_test.php b/server-api/tools/wechat_h5_auth_mock_test.php new file mode 100644 index 0000000..2b89188 --- /dev/null +++ b/server-api/tools/wechat_h5_auth_mock_test.php @@ -0,0 +1,206 @@ +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); + } +} diff --git a/user-app/src/api/app.ts b/user-app/src/api/app.ts index a1936fc..77d772b 100644 --- a/user-app/src/api/app.ts +++ b/user-app/src/api/app.ts @@ -373,6 +373,7 @@ export interface ReportDetailData { report_title: string; report_status: string; service_provider: string; + service_provider_text: string; institution_name: string; publish_time: string; zhongjian_report_no: string; diff --git a/user-app/src/api/auth.ts b/user-app/src/api/auth.ts index 6f4b812..91f9dcf 100644 --- a/user-app/src/api/auth.ts +++ b/user-app/src/api/auth.ts @@ -22,6 +22,30 @@ export interface LoginResult { user_info: AuthUserInfo; } +export interface WechatAuthConfig { + appid: string; + oauth_redirect_url: string; + enabled: boolean; + scope: string; + state: string; +} + +export interface WechatExchangeResult { + status: "logged_in" | "need_bind"; + token?: string; + user_info?: AuthUserInfo; + bind_ticket?: string; + expire_seconds?: number; + profile?: { + nickname: string; + avatar: string; + }; +} + +export interface WechatBindMobileResult extends LoginResult { + status: "logged_in"; +} + export const authApi = { sendLoginCode(mobile: string) { return request("/api/app/auth/send-code", { @@ -41,6 +65,25 @@ export const authApi = { data: { mobile, password }, }); }, + getWechatConfig() { + return request("/api/app/auth/wechat/config"); + }, + exchangeWechatCode(code: string, state: string) { + return request("/api/app/auth/wechat/exchange", { + method: "POST", + data: { code, state }, + }); + }, + bindWechatMobile(payload: { + bind_ticket: string; + mobile: string; + code: string; + }) { + return request("/api/app/auth/wechat/bind-mobile", { + method: "POST", + data: payload, + }); + }, getMe() { return request<{ user_info: AuthUserInfo }>("/api/app/auth/me"); }, diff --git a/user-app/src/mocks/app.ts b/user-app/src/mocks/app.ts index 4196107..3e82018 100644 --- a/user-app/src/mocks/app.ts +++ b/user-app/src/mocks/app.ts @@ -387,6 +387,8 @@ export const reportDetailFallback: ReportDetailData = { { label: "检测结论", value: "正品", remark: "综合当前送检资料与商品特征判断,符合正品特征。" }, { label: "品牌", value: "Rolex" }, { label: "主体颜色", value: "银盘" }, + { label: "服务类型", value: "中检鉴定" }, + { label: "鉴定师", value: "张师傅" }, ], }, trace_info: { @@ -422,6 +424,7 @@ export const reportDetailFallback: ReportDetailData = { report_title: "中检鉴定报告", report_status: "published", service_provider: "zhongjian", + service_provider_text: "中检鉴定", institution_name: "中检鉴定中心", publish_time: "2026-04-18 18:26:00", zhongjian_report_no: "ZJ-20260418-0001", diff --git a/user-app/src/pages.json b/user-app/src/pages.json index d6f65b0..93bbb88 100644 --- a/user-app/src/pages.json +++ b/user-app/src/pages.json @@ -6,6 +6,12 @@ "navigationBarTitleText": "登录" } }, + { + "path": "pages/auth/wechat-bind", + "style": { + "navigationBarTitleText": "绑定手机号" + } + }, { "path": "pages/home/index", "style": { diff --git a/user-app/src/pages/auth/login.vue b/user-app/src/pages/auth/login.vue index a0e67d9..aa826c5 100644 --- a/user-app/src/pages/auth/login.vue +++ b/user-app/src/pages/auth/login.vue @@ -4,7 +4,19 @@ import { onLoad } from "@dcloudio/uni-app"; import { authApi } from "../../api/auth"; import { useAppraisalStore } from "../../stores/appraisal"; import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; -import { isLoggedIn, isWechatBrowser, navigateAfterLogin, setUserToken } from "../../utils/auth"; +import { + clearWechatBindSession, + clearWechatOAuthState, + consumeWechatOAuthSuppression, + getWechatOAuthState, + isLoggedIn, + isWechatBrowser, + navigateAfterLogin, + rememberLoginRedirect, + setUserToken, + setWechatBindSession, + setWechatOAuthState, +} from "../../utils/auth"; type LoginMode = "code" | "password"; const COUNTDOWN_STORAGE_KEY = "anxinyan_login_code_countdown_expire_at"; @@ -12,6 +24,8 @@ const COUNTDOWN_STORAGE_KEY = "anxinyan_login_code_countdown_expire_at"; const mode = ref("code"); const sending = ref(false); const submitting = ref(false); +const wechatProcessing = ref(false); +const wechatMessage = ref(""); const countdown = ref(0); const redirect = ref(""); const sendCodeErrorMessage = ref(""); @@ -27,7 +41,7 @@ let countdownTimer: ReturnType | null = null; const browserHint = computed(() => isWechatBrowser() - ? "微信内也支持手机号快捷登录,后续可继续补充微信授权。" + ? "微信内将自动使用公众号授权登录;授权失败时也可继续用手机号登录。" : "当前为非微信浏览器环境,可直接使用手机号验证码或密码登录。", ); @@ -129,6 +143,138 @@ function goHome() { uni.reLaunch({ url: "/pages/home/index" }); } +function currentQueryValue(key: string) { + // #ifdef H5 + const url = new URL(window.location.href); + const directValue = url.searchParams.get(key); + if (directValue) { + return directValue; + } + + const hashQuery = window.location.hash.includes("?") ? window.location.hash.split("?")[1] : ""; + return new URLSearchParams(hashQuery).get(key) || ""; + // #endif + return ""; +} + +function cleanWechatCallbackQuery() { + // #ifdef H5 + const url = new URL(window.location.href); + const hashHasCallback = window.location.hash.includes("?") + && (new URLSearchParams(window.location.hash.split("?")[1]).has("code") + || new URLSearchParams(window.location.hash.split("?")[1]).has("state")); + if (!url.searchParams.has("code") && !url.searchParams.has("state") && !hashHasCallback) { + return; + } + url.searchParams.delete("code"); + url.searchParams.delete("state"); + if (window.location.hash.includes("?")) { + const [hashPath, hashQuery = ""] = window.location.hash.split("?"); + const hashParams = new URLSearchParams(hashQuery); + hashParams.delete("code"); + hashParams.delete("state"); + const nextQuery = hashParams.toString(); + url.hash = `${hashPath}${nextQuery ? `?${nextQuery}` : ""}`; + } + window.history.replaceState({}, document.title, url.toString()); + // #endif +} + +function buildWechatAuthorizeUrl(config: Awaited>) { + const params = [ + `appid=${encodeURIComponent(config.appid)}`, + `redirect_uri=${encodeURIComponent(config.oauth_redirect_url)}`, + "response_type=code", + `scope=${encodeURIComponent(config.scope || "snsapi_userinfo")}`, + `state=${encodeURIComponent(config.state)}`, + ].join("&"); + + return `https://open.weixin.qq.com/connect/oauth2/authorize?${params}#wechat_redirect`; +} + +async function startWechatOAuth() { + if (wechatProcessing.value || !isWechatBrowser() || isLoggedIn()) { + return; + } + + wechatProcessing.value = true; + wechatMessage.value = "正在准备微信授权"; + try { + const config = await authApi.getWechatConfig(); + if (!config.enabled) { + wechatMessage.value = "微信授权暂未启用,请使用手机号登录"; + return; + } + + setWechatOAuthState(config.state); + rememberLoginRedirect(redirect.value || "/pages/mine/index"); + // #ifdef H5 + window.location.href = buildWechatAuthorizeUrl(config); + // #endif + } catch (error) { + wechatMessage.value = "微信授权暂不可用,请使用手机号登录"; + showErrorToast(error, "微信授权失败"); + } finally { + setTimeout(() => { + wechatProcessing.value = false; + }, 800); + } +} + +async function handleWechatCallback() { + const code = currentQueryValue("code"); + const state = currentQueryValue("state"); + if (!code) { + if (consumeWechatOAuthSuppression()) { + wechatMessage.value = "可继续使用手机号登录"; + return; + } + await startWechatOAuth(); + return; + } + + const expectedState = getWechatOAuthState(); + if (expectedState && state !== expectedState) { + clearWechatOAuthState(); + cleanWechatCallbackQuery(); + showErrorToast(new Error("微信授权状态不匹配,请重新登录"), "微信授权失败"); + return; + } + + wechatProcessing.value = true; + wechatMessage.value = "正在完成微信登录"; + try { + const result = await authApi.exchangeWechatCode(code, state); + clearWechatOAuthState(); + cleanWechatCallbackQuery(); + + if (result.status === "logged_in" && result.token) { + clearWechatBindSession(); + setUserToken(result.token); + appraisalStore.resetForNewFlow(); + showInfoToast("登录成功"); + navigateAfterLogin(redirect.value || "/pages/mine/index"); + return; + } + + if (result.status === "need_bind" && result.bind_ticket) { + setWechatBindSession(result.bind_ticket, result.profile); + const bindUrl = `/pages/auth/wechat-bind${redirect.value ? `?redirect=${encodeURIComponent(redirect.value)}` : ""}`; + uni.redirectTo({ url: bindUrl }); + return; + } + + throw new Error("微信授权结果异常,请使用手机号登录"); + } catch (error) { + clearWechatOAuthState(); + cleanWechatCallbackQuery(); + wechatMessage.value = "微信授权失败,可使用手机号登录"; + showErrorToast(error, "微信授权失败"); + } finally { + wechatProcessing.value = false; + } +} + async function handleSendCode() { if (sending.value || countdown.value > 0) return; if (!validateMobile()) return; @@ -191,6 +337,11 @@ onLoad((options) => { restoreCountdown(); if (isLoggedIn()) { navigateAfterLogin(redirect.value || "/pages/mine/index"); + return; + } + + if (isWechatBrowser()) { + handleWechatCallback(); } }); @@ -234,6 +385,14 @@ onUnmounted(clearCountdown); + + + + {{ wechatProcessing ? "微信授权登录" : "微信授权提示" }} + {{ wechatMessage || "正在打开微信授权" }} + + + 验证码登录 @@ -414,6 +573,43 @@ onUnmounted(clearCountdown); box-shadow: var(--shadow-sm); } +.auth-wechat-status { + display: flex; + align-items: center; + gap: 18rpx; + margin-bottom: 22rpx; + padding: 20rpx 22rpx; + border-radius: 16rpx; + background: #edf7f0; + border: 1px solid rgba(47, 107, 79, 0.14); +} + +.auth-wechat-status__icon { + width: 64rpx; + height: 64rpx; + border-radius: 16rpx; + background: #2f6b4f; + color: #ffffff; + font-size: 26rpx; + font-weight: 700; + line-height: 64rpx; + text-align: center; + flex-shrink: 0; +} + +.auth-wechat-status__title { + color: #244f3b; + font-size: 26rpx; + font-weight: 700; +} + +.auth-wechat-status__desc { + margin-top: 6rpx; + color: #4f7662; + font-size: 22rpx; + line-height: 1.6; +} + .auth-switch { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/user-app/src/pages/auth/wechat-bind.vue b/user-app/src/pages/auth/wechat-bind.vue new file mode 100644 index 0000000..5cee4e4 --- /dev/null +++ b/user-app/src/pages/auth/wechat-bind.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/user-app/src/pages/report/detail.vue b/user-app/src/pages/report/detail.vue index f21a556..6b83844 100644 --- a/user-app/src/pages/report/detail.vue +++ b/user-app/src/pages/report/detail.vue @@ -6,6 +6,7 @@ import { reportDetailFallback } from "../../mocks/app"; import { resolveErrorMessage } from "../../utils/feedback"; type ReportTab = "product" | "trace"; +type ProductDisplayItem = ReportDetailData["product_display"]["items"][number]; const detail = ref(reportDetailFallback); const downloading = ref(false); @@ -37,12 +38,35 @@ const institutionName = computed(() => || "-", ); const productItems = computed(() => { - const items = detail.value.product_display?.items || []; - if (items.length) return items; - return [ - { label: "检测结论", value: detail.value.result_info.result_text || "-", remark: detail.value.result_info.result_desc || "" }, - { label: "品牌", value: detail.value.product_info.brand_name || "-" }, - ]; + const items: ProductDisplayItem[] = []; + const displayItems = detail.value.product_display?.items || []; + const baseItems = displayItems.length + ? displayItems + : [ + { label: "检测结论", value: detail.value.result_info.result_text || "-", remark: detail.value.result_info.result_desc || "" }, + { label: "品类", value: detail.value.product_info.category_name || "" }, + { label: "品牌", value: detail.value.product_info.brand_name || "" }, + { label: "颜色", value: detail.value.product_info.color || "" }, + { label: "规格/尺寸", value: detail.value.product_info.size_spec || "" }, + { label: "序列号/编码", value: detail.value.product_info.serial_no || "" }, + ]; + + for (const item of baseItems) { + appendProductItem(items, item.label, item.value, item.remark); + } + + appendProductItem( + items, + "服务类型", + detail.value.report_header.service_provider_text || serviceProviderText(detail.value.report_header.service_provider), + ); + + const appraiserName = textValue(detail.value.appraisal_info?.appraiser_name) + || textValue(detail.value.appraisal_info?.reviewer_name) + || textValue(detail.value.report_header.report_entry_admin_name); + appendProductItem(items, "鉴定师", appraiserName); + + return items; }); const publishTime = computed(() => detail.value.report_header.publish_time || "-"); const resultItem = computed(() => { @@ -74,6 +98,26 @@ const zhongjianImageFiles = computed(() => zhongjianReportFiles.value.filter((it const zhongjianOtherFiles = computed(() => zhongjianReportFiles.value.filter((item) => item.file_type !== "image")); const reportNo = computed(() => detail.value.report_header.report_no || ""); +function appendProductItem(items: ProductDisplayItem[], label: unknown, value: unknown, remark: unknown = "") { + const labelText = textValue(label); + const valueText = textValue(value); + const remarkText = textValue(remark); + if (!labelText || (!valueText && !remarkText) || items.some((item) => item.label === labelText)) return; + items.push({ + label: labelText, + value: valueText || "-", + remark: remarkText, + }); +} + +function textValue(value: unknown) { + return String(value ?? "").trim(); +} + +function serviceProviderText(serviceProvider: string) { + return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定"; +} + function evidenceTypeText(fileType: string) { if (fileType === "video") return "视频"; if (fileType === "pdf") return "PDF"; @@ -294,6 +338,7 @@ onLoad(async (options) => {