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 @@
+
+
+
+
+
+
+
+ 安
+
+ 安心验
+ 绑定手机号后即可完成微信登录
+
+
+
+
+
+ 微
+
+ {{ displayName }}
+ 首次微信登录需验证手机号
+
+
+
+
+
+ 绑定手机号
+ 同一手机号已存在账号时,将直接关联到原账号,不会创建重复账号。
+
+
+
+ 手机号
+
+
+
+
+
+
+ 验证码
+
+
+
+
+
+ {{ sending ? "发送中..." : sendButtonText }}
+
+
+ {{ sendCodeErrorMessage }}
+
+
+
+
+ 手机号登录
+
+ {{ submitting ? "绑定中..." : "完成绑定" }}
+
+
+
+
+
+
+
+
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) => {
+
@@ -334,8 +379,6 @@ onLoad(async (options) => {
-
-
{{ resultItem.label }}
@@ -343,8 +386,8 @@ onLoad(async (options) => {
{{ resultItem.remark }}
- ANXINYAN
- 可信
+ 安心验
+ 鉴定
@@ -490,6 +533,26 @@ onLoad(async (options) => {
box-shadow: 0 18rpx 48rpx rgba(31, 36, 48, 0.08);
}
+.report-shell__watermark {
+ position: absolute;
+ z-index: 0;
+ top: 374rpx;
+ left: 28rpx;
+ right: 28rpx;
+ height: 560rpx;
+ background: url("../../static/report/report-watermark.svg") center / 100% 100% no-repeat;
+ opacity: 1;
+ pointer-events: none;
+}
+
+.report-cover,
+.report-meta,
+.report-tabs,
+.report-panel {
+ position: relative;
+ z-index: 1;
+}
+
.report-cover {
height: 356rpx;
margin: 28rpx 28rpx 0;
@@ -614,28 +677,14 @@ onLoad(async (options) => {
overflow: hidden;
}
-.report-watermark {
- position: absolute;
- top: -6rpx;
- left: 50%;
- width: 520rpx;
- height: 430rpx;
- border-radius: 50%;
- opacity: 0.46;
- transform: translateX(-50%);
- background:
- repeating-radial-gradient(ellipse at center, rgba(230, 195, 79, 0.2) 0, rgba(230, 195, 79, 0.2) 2rpx, transparent 3rpx, transparent 17rpx),
- repeating-conic-gradient(from 0deg, rgba(230, 195, 79, 0.12) 0deg 8deg, transparent 8deg 16deg);
- pointer-events: none;
-}
-
.report-result {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
gap: 24rpx;
- padding: 10rpx 0 30rpx;
+ min-height: 132rpx;
+ padding: 10rpx 136rpx 30rpx 0;
border: 0;
border-bottom: 1px solid #e5e5e5;
border-radius: 0;
@@ -672,29 +721,57 @@ onLoad(async (options) => {
}
.report-seal {
- flex: 0 0 auto;
+ position: absolute;
+ right: 2rpx;
+ bottom: 22rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- width: 104rpx;
- height: 104rpx;
- margin-top: 2rpx;
- border: 4rpx solid rgba(56, 164, 73, 0.8);
+ width: 106rpx;
+ height: 106rpx;
+ border: 4rpx solid rgba(40, 151, 73, 0.82);
border-radius: 999rpx;
- color: #39a54b;
- transform: rotate(-10deg);
+ background: rgba(255, 255, 255, 0.42);
+ color: #239245;
+ box-shadow: inset 0 0 0 4rpx rgba(40, 151, 73, 0.1);
+ transform: rotate(-9deg);
+}
+
+.report-seal::before {
+ position: absolute;
+ inset: 10rpx;
+ border: 2rpx solid rgba(40, 151, 73, 0.58);
+ border-radius: inherit;
+ content: "";
+}
+
+.report-seal::after {
+ position: absolute;
+ left: 50%;
+ bottom: 12rpx;
+ width: 34rpx;
+ height: 5rpx;
+ border-radius: 999rpx;
+ background: currentColor;
+ content: "";
+ opacity: 0.7;
+ transform: translateX(-50%);
}
.report-seal__brand {
- font-size: 16rpx;
- font-weight: 800;
+ position: relative;
+ z-index: 1;
+ font-size: 18rpx;
+ font-weight: 900;
line-height: 1;
}
.report-seal__main {
- margin-top: 8rpx;
- font-size: 28rpx;
+ position: relative;
+ z-index: 1;
+ margin-top: 9rpx;
+ font-size: 26rpx;
font-weight: 900;
line-height: 1;
}
diff --git a/user-app/src/static/logo.png b/user-app/src/static/logo.png
index b5771e2..799fe94 100644
Binary files a/user-app/src/static/logo.png and b/user-app/src/static/logo.png differ
diff --git a/user-app/src/static/report/report-watermark.svg b/user-app/src/static/report/report-watermark.svg
new file mode 100644
index 0000000..bdea498
--- /dev/null
+++ b/user-app/src/static/report/report-watermark.svg
@@ -0,0 +1,36 @@
+
diff --git a/user-app/src/utils/auth.ts b/user-app/src/utils/auth.ts
index 50b968a..991311d 100644
--- a/user-app/src/utils/auth.ts
+++ b/user-app/src/utils/auth.ts
@@ -1,5 +1,9 @@
const TOKEN_KEY = "anxinyan_user_token";
const LOGIN_REDIRECT_KEY = "anxinyan_user_login_redirect";
+const WECHAT_BIND_TICKET_KEY = "anxinyan_wechat_bind_ticket";
+const WECHAT_BIND_PROFILE_KEY = "anxinyan_wechat_bind_profile";
+const WECHAT_OAUTH_STATE_KEY = "anxinyan_wechat_oauth_state";
+const WECHAT_OAUTH_SUPPRESS_KEY = "anxinyan_wechat_oauth_suppress_once";
const TABBAR_PAGES = new Set([
"/pages/home/index",
@@ -16,6 +20,7 @@ const PUBLIC_PAGES = new Set([
"/pages/verify/result",
"/pages/material-tag/detail",
"/pages/auth/login",
+ "/pages/auth/wechat-bind",
]);
let redirecting = false;
@@ -56,6 +61,12 @@ export function isLoggedIn() {
return getUserToken() !== "";
}
+export function rememberLoginRedirect(targetUrl: string) {
+ if (targetUrl) {
+ uni.setStorageSync(LOGIN_REDIRECT_KEY, targetUrl);
+ }
+}
+
export function buildAuthHeaders(headers: Record = {}) {
const token = getUserToken();
if (!token) {
@@ -75,6 +86,61 @@ export function isWechatBrowser() {
return false;
}
+export function getWechatOAuthState() {
+ return String(uni.getStorageSync(WECHAT_OAUTH_STATE_KEY) || "");
+}
+
+export function setWechatOAuthState(state: string) {
+ uni.setStorageSync(WECHAT_OAUTH_STATE_KEY, state);
+}
+
+export function clearWechatOAuthState() {
+ uni.removeStorageSync(WECHAT_OAUTH_STATE_KEY);
+}
+
+export function suppressNextWechatOAuth() {
+ uni.setStorageSync(WECHAT_OAUTH_SUPPRESS_KEY, "1");
+}
+
+export function consumeWechatOAuthSuppression() {
+ const suppressed = String(uni.getStorageSync(WECHAT_OAUTH_SUPPRESS_KEY) || "") === "1";
+ if (suppressed) {
+ uni.removeStorageSync(WECHAT_OAUTH_SUPPRESS_KEY);
+ }
+ return suppressed;
+}
+
+export function setWechatBindSession(bindTicket: string, profile?: { nickname?: string; avatar?: string }) {
+ uni.setStorageSync(WECHAT_BIND_TICKET_KEY, bindTicket);
+ uni.setStorageSync(WECHAT_BIND_PROFILE_KEY, JSON.stringify(profile || {}));
+}
+
+export function getWechatBindTicket() {
+ return String(uni.getStorageSync(WECHAT_BIND_TICKET_KEY) || "");
+}
+
+export function getWechatBindProfile() {
+ const raw = String(uni.getStorageSync(WECHAT_BIND_PROFILE_KEY) || "");
+ if (!raw) {
+ return { nickname: "", avatar: "" };
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as { nickname?: string; avatar?: string };
+ return {
+ nickname: String(parsed.nickname || ""),
+ avatar: String(parsed.avatar || ""),
+ };
+ } catch {
+ return { nickname: "", avatar: "" };
+ }
+}
+
+export function clearWechatBindSession() {
+ uni.removeStorageSync(WECHAT_BIND_TICKET_KEY);
+ uni.removeStorageSync(WECHAT_BIND_PROFILE_KEY);
+}
+
export function isPublicPage(urlOrPath: string) {
const { path } = splitUrl(urlOrPath);
return PUBLIC_PAGES.has(path);
@@ -100,9 +166,7 @@ export function redirectToLogin(targetUrl?: string) {
return;
}
- if (currentUrl) {
- uni.setStorageSync(LOGIN_REDIRECT_KEY, currentUrl);
- }
+ rememberLoginRedirect(currentUrl);
redirecting = true;
uni.navigateTo({
diff --git a/work-app/src/pages/report/detail.vue b/work-app/src/pages/report/detail.vue
index 66662aa..e7744c2 100644
--- a/work-app/src/pages/report/detail.vue
+++ b/work-app/src/pages/report/detail.vue
@@ -57,6 +57,7 @@ const productSpecItems = computed(() => {
appendSpecItem(items, "序列号/编码", product.serial_no);
for (const point of normalizedKeyPoints(result.key_points)) {
+ if (hasSpecItem(items, point.point_name)) continue;
appendSpecItem(items, point.point_name, point.point_value, point.point_remark);
}
@@ -92,6 +93,11 @@ function appendSpecItem(
});
}
+function hasSpecItem(items: Array<{ label: string }>, label: unknown) {
+ const labelText = textValue(label);
+ return Boolean(labelText && items.some((item) => item.label === labelText));
+}
+
function textValue(value: unknown) {
return String(value ?? "").trim();
}
@@ -276,6 +282,7 @@ onShow(() => {
+
@@ -316,8 +323,6 @@ onShow(() => {
-
-
{{ resultItem.label }}
@@ -325,8 +330,8 @@ onShow(() => {
{{ resultItem.remark }}
- ANXINYAN
- 可信
+ 安心验
+ 鉴定
@@ -496,6 +501,26 @@ onShow(() => {
box-shadow: 0 18rpx 48rpx rgba(31, 36, 48, 0.08);
}
+.report-shell__watermark {
+ position: absolute;
+ z-index: 0;
+ top: 374rpx;
+ left: 28rpx;
+ right: 28rpx;
+ height: 560rpx;
+ background: url("../../static/report/report-watermark.svg") center / 100% 100% no-repeat;
+ opacity: 1;
+ pointer-events: none;
+}
+
+.report-cover,
+.report-meta,
+.report-tabs,
+.report-panel {
+ position: relative;
+ z-index: 1;
+}
+
.report-cover {
height: 356rpx;
margin: 28rpx 28rpx 0;
@@ -620,28 +645,14 @@ onShow(() => {
overflow: hidden;
}
-.report-watermark {
- position: absolute;
- top: -6rpx;
- left: 50%;
- width: 520rpx;
- height: 430rpx;
- border-radius: 50%;
- opacity: 0.46;
- transform: translateX(-50%);
- background:
- repeating-radial-gradient(ellipse at center, rgba(230, 195, 79, 0.2) 0, rgba(230, 195, 79, 0.2) 2rpx, transparent 3rpx, transparent 17rpx),
- repeating-conic-gradient(from 0deg, rgba(230, 195, 79, 0.12) 0deg 8deg, transparent 8deg 16deg);
- pointer-events: none;
-}
-
.report-result {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
gap: 24rpx;
- padding: 10rpx 0 30rpx;
+ min-height: 132rpx;
+ padding: 10rpx 136rpx 30rpx 0;
border-bottom: 1px solid #e5e5e5;
}
@@ -674,29 +685,57 @@ onShow(() => {
}
.report-seal {
- flex: 0 0 auto;
+ position: absolute;
+ right: 2rpx;
+ bottom: 22rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- width: 104rpx;
- height: 104rpx;
- margin-top: 2rpx;
- border: 4rpx solid rgba(56, 164, 73, 0.8);
+ width: 106rpx;
+ height: 106rpx;
+ border: 4rpx solid rgba(40, 151, 73, 0.82);
border-radius: 999rpx;
- color: #39a54b;
- transform: rotate(-10deg);
+ background: rgba(255, 255, 255, 0.42);
+ color: #239245;
+ box-shadow: inset 0 0 0 4rpx rgba(40, 151, 73, 0.1);
+ transform: rotate(-9deg);
+}
+
+.report-seal::before {
+ position: absolute;
+ inset: 10rpx;
+ border: 2rpx solid rgba(40, 151, 73, 0.58);
+ border-radius: inherit;
+ content: "";
+}
+
+.report-seal::after {
+ position: absolute;
+ left: 50%;
+ bottom: 12rpx;
+ width: 34rpx;
+ height: 5rpx;
+ border-radius: 999rpx;
+ background: currentColor;
+ content: "";
+ opacity: 0.7;
+ transform: translateX(-50%);
}
.report-seal__brand {
- font-size: 16rpx;
- font-weight: 800;
+ position: relative;
+ z-index: 1;
+ font-size: 18rpx;
+ font-weight: 900;
line-height: 1;
}
.report-seal__main {
- margin-top: 8rpx;
- font-size: 28rpx;
+ position: relative;
+ z-index: 1;
+ margin-top: 9rpx;
+ font-size: 26rpx;
font-weight: 900;
line-height: 1;
}
diff --git a/work-app/src/static/logo.png b/work-app/src/static/logo.png
index b5771e2..799fe94 100644
Binary files a/work-app/src/static/logo.png and b/work-app/src/static/logo.png differ
diff --git a/work-app/src/static/report/report-watermark.svg b/work-app/src/static/report/report-watermark.svg
new file mode 100644
index 0000000..bdea498
--- /dev/null
+++ b/work-app/src/static/report/report-watermark.svg
@@ -0,0 +1,36 @@
+