diff --git a/admin-web/src/pages/reports/index.vue b/admin-web/src/pages/reports/index.vue index 9f96ab5..398291d 100644 --- a/admin-web/src/pages/reports/index.vue +++ b/admin-web/src/pages/reports/index.vue @@ -782,10 +782,6 @@ watch(
颜色 / 规格
{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}
-
-
序列号
-
{{ detail.product_info.serial_no || "-" }}
-
diff --git a/server-api/app/controller/admin/SystemConfigsController.php b/server-api/app/controller/admin/SystemConfigsController.php index a91ac3d..7046985 100644 --- a/server-api/app/controller/admin/SystemConfigsController.php +++ b/server-api/app/controller/admin/SystemConfigsController.php @@ -593,39 +593,92 @@ class SystemConfigsController throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数'); } - if (!$this->isPemContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) { - throw new \RuntimeException('商户 RSA 私钥需填写 PEM 内容,或填写服务器可读取的 PEM 文件路径'); + if (!$this->isPrivateKeyContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) { + throw new \RuntimeException('商户 RSA 私钥需填写可被 OpenSSL 解析的 PEM 内容,或填写服务器可读取的 PEM 文件路径'); } if (!$this->isPublicKeyContentOrReadablePath((string)$configValueMap['payment.shouqianba_public_key'])) { - throw new \RuntimeException('收钱吧 RSA 公钥需填写 PEM 内容、纯公钥文本,或填写服务器可读取的 PEM 文件路径'); + throw new \RuntimeException('收钱吧 RSA 公钥需填写可被 OpenSSL 解析的 PEM 内容、纯公钥文本,或填写服务器可读取的 PEM 文件路径'); } } + private function isPrivateKeyContentOrReadablePath(string $value): bool + { + $content = $this->pemContentOrReadablePath($value); + if ($content === '') { + return false; + } + + $key = openssl_pkey_get_private($content); + $ok = $key !== false; + $this->clearOpenSslErrors(); + + return $ok; + } + private function isPublicKeyContentOrReadablePath(string $value): bool { - $value = trim($value); - if ($this->isPemContentOrReadablePath($value)) { + $content = $this->pemContentOrReadablePath($value); + if ($content !== '' && $this->canOpenPublicKey($content)) { return true; } - return $this->looksLikeBase64KeyBody($value); + if (!$this->looksLikeBase64KeyBody($value)) { + return false; + } + + return $this->canOpenPublicKey($this->wrapPemKey($value, 'PUBLIC KEY')); } - private function isPemContentOrReadablePath(string $value): bool + private function pemContentOrReadablePath(string $value): string { $value = trim($value); if ($value === '') { - return false; + return ''; } if (str_contains($value, '-----BEGIN')) { - return true; + return $this->normalizePemNewlines($value); } if (!is_file($value) || !is_readable($value)) { - return false; + return ''; } $content = file_get_contents($value); - return is_string($content) && str_contains($content, '-----BEGIN'); + if (!is_string($content) || !str_contains($content, '-----BEGIN')) { + return ''; + } + + return $this->normalizePemNewlines($content); + } + + private function canOpenPublicKey(string $content): bool + { + $key = openssl_pkey_get_public($content); + $ok = $key !== false; + $this->clearOpenSslErrors(); + + return $ok; + } + + private function wrapPemKey(string $value, string $pemLabel): string + { + $body = preg_replace('/\s+/', '', trim($value)) ?: ''; + return sprintf( + "-----BEGIN %s-----\n%s\n-----END %s-----", + $pemLabel, + rtrim(chunk_split($body, 64, "\n")), + $pemLabel + ); + } + + private function normalizePemNewlines(string $value): string + { + return str_replace(["\\r\\n", "\\n", "\\r"], ["\n", "\n", "\r"], $value); + } + + private function clearOpenSslErrors(): void + { + while (openssl_error_string() !== false) { + } } private function looksLikeBase64KeyBody(string $value): bool diff --git a/server-api/app/controller/app/AuthController.php b/server-api/app/controller/app/AuthController.php index 39d5fe0..553976c 100644 --- a/server-api/app/controller/app/AuthController.php +++ b/server-api/app/controller/app/AuthController.php @@ -140,6 +140,46 @@ class AuthController } } + public function miniProgramExchange(Request $request) + { + $code = trim((string)$request->input('code', '')); + if ($code === '') { + return api_error('小程序登录 code 不能为空', 422); + } + + try { + $payload = (new MiniProgramAuthService())->exchangeCode($code, $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 miniProgramBindMobile(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 MiniProgramAuthService())->bindMobile($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/middleware/AppAuthMiddleware.php b/server-api/app/middleware/AppAuthMiddleware.php index b5e3b08..2ab2b59 100644 --- a/server-api/app/middleware/AppAuthMiddleware.php +++ b/server-api/app/middleware/AppAuthMiddleware.php @@ -57,6 +57,8 @@ class AppAuthMiddleware implements MiddlewareInterface '/api/app/auth/wechat/config', '/api/app/auth/wechat/exchange', '/api/app/auth/wechat/bind-mobile', + '/api/app/auth/mini-program/exchange', + '/api/app/auth/mini-program/bind-mobile', ], true); } } diff --git a/server-api/app/support/MiniProgramAuthService.php b/server-api/app/support/MiniProgramAuthService.php index 4e6c481..9173981 100644 --- a/server-api/app/support/MiniProgramAuthService.php +++ b/server-api/app/support/MiniProgramAuthService.php @@ -3,10 +3,85 @@ namespace app\support; use support\think\Db; +use Webman\Http\Request; class MiniProgramAuthService { public const AUTH_TYPE = 'wechat_mini_program'; + private const BIND_TICKET_TTL = 600; + + public function exchangeCode(string $code, Request $request): array + { + $identity = $this->buildIdentityByCode($code); + $auth = $this->findAuth($identity['openid'], $identity['unionid']); + if ($auth) { + $userId = (int)$auth['user_id']; + $user = Db::name('users')->where('id', $userId)->find(); + if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('小程序微信已绑定账号不存在或已停用'); + } + + $this->syncAuth($userId, $identity, date('Y-m-d H:i:s'), (int)$auth['id']); + return array_merge([ + 'status' => 'logged_in', + ], $this->issueToken($userId, $request)); + } + + return [ + 'status' => 'need_bind', + 'bind_ticket' => $this->createBindTicket($identity), + 'expire_seconds' => self::BIND_TICKET_TTL, + 'profile' => [ + 'nickname' => '', + 'avatar' => '', + ], + ]; + } + + public function bindMobile(string $bindTicket, string $mobile, string $code, Request $request): array + { + $identity = $this->verifyBindTicket($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->assertIdentityAvailable($identity, $user ? (int)$user['id'] : null); + $this->verifyLoginCode($mobile, $code, $now); + + if (!$user) { + $userId = (int)Db::name('users')->insertGetId([ + 'nickname' => '安心验用户' . substr($mobile, -4), + 'avatar' => '', + 'mobile' => $mobile, + 'password' => '', + 'status' => 'enabled', + 'last_login_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } else { + $userId = (int)$user['id']; + } + + $this->syncMobileAuth($userId, $mobile, $now); + $this->syncAuth($userId, $identity, $now); + $payload = array_merge([ + 'status' => 'logged_in', + ], $this->issueToken($userId, $request)); + + Db::commit(); + return $payload; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } public function bindOpenid(int $userId, string $code): array { @@ -93,6 +168,262 @@ class MiniProgramAuthService ->value('auth_open_id'); } + private function buildIdentityByCode(string $code): array + { + $payload = $this->fetchOpenidByCode($code); + $openid = trim((string)($payload['openid'] ?? '')); + if ($openid === '') { + throw new \RuntimeException('微信小程序登录返回缺少 openid'); + } + + $sessionKey = (string)($payload['session_key'] ?? ''); + return [ + 'openid' => $openid, + 'unionid' => trim((string)($payload['unionid'] ?? '')), + 'auth_extra' => [ + 'source' => 'mini_program_login', + 'session_key_present' => $sessionKey !== '', + 'authorized_at' => date('Y-m-d H:i:s'), + ], + ]; + } + + private function findAuth(string $openid, string $unionid, bool $lock = false): ?array + { + $query = Db::name('user_auths') + ->where('auth_type', self::AUTH_TYPE) + ->where('auth_key', $openid); + if ($lock) { + $query->lock(true); + } + $auth = $query->find(); + if ($auth) { + return $auth; + } + + if ($unionid === '') { + return null; + } + + $query = Db::name('user_auths') + ->where('auth_type', self::AUTH_TYPE) + ->where('auth_union_id', $unionid) + ->order('id', 'asc'); + if ($lock) { + $query->lock(true); + } + $auth = $query->find(); + + return $auth ?: null; + } + + private function assertIdentityAvailable(array $identity, ?int $allowedUserId): void + { + $auth = $this->findAuth((string)$identity['openid'], (string)$identity['unionid'], true); + if ($auth && ($allowedUserId === null || (int)$auth['user_id'] !== $allowedUserId)) { + throw new \RuntimeException('该小程序微信身份已绑定其他账号'); + } + } + + private function syncAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void + { + $openid = trim((string)($identity['openid'] ?? '')); + $unionid = trim((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 = $this->findAuth($openid, $unionid); + } + + if ($existing && (int)$existing['user_id'] !== $userId) { + throw new \RuntimeException('该小程序微信身份已绑定其他账号'); + } + + $payload = [ + 'user_id' => $userId, + 'auth_type' => self::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', (int)$existing['id'])->update($payload); + return; + } + + $payload['created_at'] = $now; + Db::name('user_auths')->insert($payload); + } + + private function syncMobileAuth(int $userId, string $mobile, string $now): void + { + $existing = Db::name('user_auths') + ->where('auth_type', 'mobile') + ->where('auth_key', $mobile) + ->find(); + + $payload = [ + 'user_id' => $userId, + 'auth_type' => 'mobile', + 'auth_key' => $mobile, + 'auth_open_id' => '', + 'auth_union_id' => '', + 'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE), + 'updated_at' => $now, + ]; + + if ($existing) { + Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload); + return; + } + + $payload['created_at'] = $now; + Db::name('user_auths')->insert($payload); + } + + private function verifyLoginCode(string $mobile, string $code, string $now): 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('验证码错误'); + } + + Db::name('sms_code_logs')->where('id', (int)$record['id'])->update([ + 'used_at' => $now, + 'updated_at' => $now, + ]); + } + + private function issueToken(int $userId, Request $request): array + { + $user = Db::name('users')->where('id', $userId)->find(); + if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('账号不存在或已停用'); + } + + $token = bin2hex(random_bytes(24)); + $tokenHash = hash('sha256', $token); + $now = date('Y-m-d H:i:s'); + $expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600); + + Db::name('user_api_tokens')->where('user_id', $userId)->delete(); + Db::name('user_api_tokens')->insert([ + 'user_id' => $userId, + 'token_hash' => $tokenHash, + 'auth_type' => self::AUTH_TYPE, + 'expire_time' => $expireTime, + 'last_active_at' => $now, + 'last_ip' => $request->getRealIp(), + 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('users')->where('id', $userId)->update([ + 'last_login_at' => $now, + 'updated_at' => $now, + ]); + + return [ + 'token' => $token, + 'user_info' => $this->userInfo($userId), + ]; + } + + private function userInfo(int $userId): array + { + $user = Db::name('users')->where('id', $userId)->find(); + return [ + 'id' => (int)($user['id'] ?? 0), + 'nickname' => $user['nickname'] ?: '安心验用户', + 'mobile' => $user['mobile'] ?? '', + 'avatar' => $user['avatar'] ?? '', + 'status' => $user['status'] ?? 'enabled', + 'password_set' => ((string)($user['password'] ?? '')) !== '', + ]; + } + + private function createBindTicket(array $identity): string + { + $now = time(); + $payload = [ + 'typ' => 'wechat_mini_program_bind', + 'openid' => (string)$identity['openid'], + 'unionid' => (string)($identity['unionid'] ?? ''), + 'auth_extra' => $identity['auth_extra'] ?? [], + 'iat' => $now, + 'exp' => $now + self::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 verifyBindTicket(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_mini_program_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'] ?? '')), + 'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [], + ]; + } + private function fetchOpenidByCode(string $code): array { if (str_starts_with($code, 'mock_mp_')) { @@ -170,4 +501,47 @@ class MiniProgramAuthService ->where('config_key', $key) ->value('config_value')); } + + private function normalizeMobile(string $mobile): string + { + $mobile = preg_replace('/\D+/', '', $mobile) ?: ''; + if (!preg_match('/^1\d{10}$/', $mobile)) { + throw new \RuntimeException('请输入正确的手机号'); + } + return $mobile; + } + + private function codeHash(string $mobile, string $scene, string $code): string + { + 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('mini_program', 'app_secret'); + } + if ($seed === '') { + $seed = 'anxinyan-mini-program-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 : ''; + } } diff --git a/server-api/app/support/ShouqianbaConfigService.php b/server-api/app/support/ShouqianbaConfigService.php index eeb03ac..1be18a8 100644 --- a/server-api/app/support/ShouqianbaConfigService.php +++ b/server-api/app/support/ShouqianbaConfigService.php @@ -78,6 +78,11 @@ class ShouqianbaConfigService } } + $this->assertPrivateKey($config['merchant_private_key']); + if ($requirePublicKey) { + $this->assertPublicKey($config['shouqianba_public_key']); + } + return $config; } @@ -148,7 +153,7 @@ class ShouqianbaConfigService return ''; } if (str_contains($value, '-----BEGIN')) { - return $value; + return $this->normalizePemNewlines($value); } if (is_file($value)) { $content = file_get_contents($value); @@ -167,6 +172,39 @@ class ShouqianbaConfigService return $value; } + private function assertPrivateKey(string $value): void + { + $key = openssl_pkey_get_private($value); + if ($key === false) { + $this->clearOpenSslErrors(); + throw new \RuntimeException('收钱吧商户 RSA 私钥不可用,请在后台系统配置中填写有效 PEM 私钥或服务器可读取的 PEM 文件路径。'); + } + + $this->clearOpenSslErrors(); + } + + private function assertPublicKey(string $value): void + { + $key = openssl_pkey_get_public($value); + if ($key === false) { + $this->clearOpenSslErrors(); + throw new \RuntimeException('收钱吧 RSA 公钥不可用,请在后台系统配置中填写有效 PEM 公钥或服务器可读取的 PEM 文件路径。'); + } + + $this->clearOpenSslErrors(); + } + + private function normalizePemNewlines(string $value): string + { + return str_replace(["\\r\\n", "\\n", "\\r"], ["\n", "\n", "\r"], $value); + } + + private function clearOpenSslErrors(): void + { + while (openssl_error_string() !== false) { + } + } + private function looksLikeBase64KeyBody(string $value): bool { $body = preg_replace('/\s+/', '', trim($value)); diff --git a/server-api/config/route.php b/server-api/config/route.php index ed5accf..842f889 100644 --- a/server-api/config/route.php +++ b/server-api/config/route.php @@ -176,6 +176,8 @@ Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByP 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::post('/api/app/auth/mini-program/exchange', [AppAuthController::class, 'miniProgramExchange']); +Route::post('/api/app/auth/mini-program/bind-mobile', [AppAuthController::class, 'miniProgramBindMobile']); Route::post('/api/app/auth/mini-program/bind', [AppAuthController::class, 'miniProgramBind']); Route::get('/api/app/auth/me', [AppAuthController::class, 'me']); Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']); diff --git a/server-api/tools/shouqianba_payment_mock_test.php b/server-api/tools/shouqianba_payment_mock_test.php index c441e33..edcc236 100644 --- a/server-api/tools/shouqianba_payment_mock_test.php +++ b/server-api/tools/shouqianba_payment_mock_test.php @@ -274,6 +274,22 @@ function latestPayment(int $orderId): array return $payment; } +function mockKeyPair(): array +{ + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + assertTrue($key !== false, 'mock rsa key generation failed'); + + $privateKey = ''; + assertTrue(openssl_pkey_export($key, $privateKey), 'mock private key export failed'); + $details = openssl_pkey_get_details($key); + assertTrue(is_array($details) && !empty($details['key']), 'mock public key export failed'); + + return [$privateKey, (string)$details['key']]; +} + $configKeys = [ 'payment.enabled', 'payment.api_domain', @@ -294,6 +310,7 @@ $configKeys = [ $snapshot = captureConfigs($configKeys); $client = new MockShouqianbaClient(new ShouqianbaConfigService()); $service = new ShouqianbaPaymentService(null, $client); +[$mockPrivateKey, $mockPublicKey] = mockKeyPair(); try { cleanupMockData(); @@ -307,8 +324,8 @@ try { ensureConfig('payment', 'workstation_sn', '0'); ensureConfig('payment', 'industry_code', '0'); ensureConfig('payment', 'order_expire_minutes', '1440'); - ensureConfig('payment', 'merchant_private_key', "-----BEGIN PRIVATE KEY-----\nmock\n-----END PRIVATE KEY-----"); - ensureConfig('payment', 'shouqianba_public_key', "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----"); + ensureConfig('payment', 'merchant_private_key', $mockPrivateKey); + ensureConfig('payment', 'shouqianba_public_key', $mockPublicKey); ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify'); ensureConfig('payment', 'mini_program_plugin_version', '2.3.70'); ensureConfig('h5', 'page_base_url', 'https://m.example.com'); diff --git a/user-app/.env.development b/user-app/.env.development index a62ceac..0fdb047 100644 --- a/user-app/.env.development +++ b/user-app/.env.development @@ -1,3 +1,3 @@ -VITE_API_BASE_URL=http://127.0.0.1:8787 -VITE_APP_ENV=development +VITE_API_BASE_URL=https://test.api.anxinjianyan.com +VITE_APP_ENV=test VITE_APP_TITLE=安心验 diff --git a/user-app/package.json b/user-app/package.json index c59a9ae..5674333 100644 --- a/user-app/package.json +++ b/user-app/package.json @@ -33,6 +33,7 @@ "build:mp-harmony": "uni build -p mp-harmony", "sync:mp-config": "php ../server-api/tools/sync_client_configs.php", "build:mp-weixin": "npm run sync:mp-config && uni build -p mp-weixin", + "build:mp-weixin:test": "npm run sync:mp-config && uni build --mode test -p mp-weixin", "build:mp-xhs": "uni build -p mp-xhs", "build:quickapp-webview": "uni build -p quickapp-webview", "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", diff --git a/user-app/src/api/auth.ts b/user-app/src/api/auth.ts index f508547..93c6ba4 100644 --- a/user-app/src/api/auth.ts +++ b/user-app/src/api/auth.ts @@ -51,6 +51,12 @@ export interface MiniProgramBindResult { unionid: string; } +export interface MiniProgramExchangeResult extends WechatExchangeResult {} + +export interface MiniProgramBindMobileResult extends LoginResult { + status: "logged_in"; +} + export const authApi = { sendLoginCode(mobile: string) { return request("/api/app/auth/send-code", { @@ -95,6 +101,22 @@ export const authApi = { data: { code }, }); }, + exchangeMiniProgramCode(code: string) { + return request("/api/app/auth/mini-program/exchange", { + method: "POST", + data: { code }, + }); + }, + bindMiniProgramMobile(payload: { + bind_ticket: string; + mobile: string; + code: string; + }) { + return request("/api/app/auth/mini-program/bind-mobile", { + method: "POST", + data: payload, + }); + }, getMe() { return request<{ user_info: AuthUserInfo }>("/api/app/auth/me"); }, diff --git a/user-app/src/pages/auth/login.vue b/user-app/src/pages/auth/login.vue index 9665362..c9f77c6 100644 --- a/user-app/src/pages/auth/login.vue +++ b/user-app/src/pages/auth/login.vue @@ -3,7 +3,7 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import { authApi } from "../../api/auth"; import { useAppraisalStore } from "../../stores/appraisal"; -import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; +import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; import { clearWechatBindSession, clearWechatOAuthState, @@ -26,6 +26,8 @@ const sending = ref(false); const submitting = ref(false); const wechatProcessing = ref(false); const wechatMessage = ref(""); +const miniProgramProcessing = ref(false); +const miniProgramMessage = ref(""); const countdown = ref(0); const redirect = ref(""); const sendCodeErrorMessage = ref(""); @@ -44,6 +46,15 @@ const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value} const countdownHint = computed(() => countdown.value > 0 ? `${countdown.value} 秒后可重新发送验证码` : "验证码有效期 5 分钟,请注意查收短信。", ); +const authorizationStatusVisible = computed(() => + wechatProcessing.value || miniProgramProcessing.value || wechatMessage.value || miniProgramMessage.value, +); +const authorizationStatusTitle = computed(() => + wechatProcessing.value || miniProgramProcessing.value ? "微信授权登录" : "微信授权提示", +); +const authorizationStatusDesc = computed(() => + miniProgramMessage.value || wechatMessage.value || "正在打开微信授权", +); function openAgreement(keyword: "privacy" | "service") { const query = encodeURIComponent(keyword === "privacy" ? "隐私政策" : "服务协议"); @@ -275,6 +286,66 @@ async function handleWechatCallback() { } } +function getMiniProgramLoginCode() { + // #ifdef MP-WEIXIN + return new Promise((resolve, reject) => { + uni.login({ + provider: "weixin", + success: (result) => { + const code = String(result.code || ""); + if (!code) { + reject(new Error("小程序登录 code 为空")); + return; + } + resolve(code); + }, + fail: (error) => reject(error), + }); + }); + // #endif + return Promise.reject(new Error("当前环境不支持小程序授权登录")); +} + +async function handleMiniProgramAuthorizeLogin() { + if (miniProgramProcessing.value || isLoggedIn()) { + return; + } + if (!agreementAccepted.value) { + showInfoToast("请先阅读并同意隐私权政策和用户协议"); + return; + } + + miniProgramProcessing.value = true; + miniProgramMessage.value = "正在获取小程序授权"; + try { + const code = await getMiniProgramLoginCode(); + const result = await withLoading("正在授权登录", async () => authApi.exchangeMiniProgramCode(code)); + + 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?source=mini-program${redirect.value ? `&redirect=${encodeURIComponent(redirect.value)}` : ""}`; + uni.redirectTo({ url: bindUrl }); + return; + } + + throw new Error("小程序授权结果异常,请使用手机号登录"); + } catch (error) { + miniProgramMessage.value = resolveErrorMessage(error, "授权登录失败,可使用手机号登录"); + showErrorToast(error, "授权登录失败"); + } finally { + miniProgramProcessing.value = false; + } +} + async function handleSendCode() { if (sending.value || countdown.value > 0) return; if (!validateMobile()) return; @@ -387,11 +458,11 @@ onUnmounted(clearCountdown); - + - {{ wechatProcessing ? "微信授权登录" : "微信授权提示" }} - {{ wechatMessage || "正在打开微信授权" }} + {{ authorizationStatusTitle }} + {{ authorizationStatusDesc }} @@ -427,6 +498,16 @@ onUnmounted(clearCountdown); {{ submitting ? "登录中..." : "登录" }} + + + + + + + @@ -718,6 +799,43 @@ onUnmounted(clearCountdown); box-shadow: none; } +.auth-mini-login { + display: flex; + align-items: center; + justify-content: center; + gap: 12rpx; + height: 68rpx; + margin-top: 22rpx; + border: 1rpx solid rgba(47, 107, 79, 0.28); + border-radius: 14rpx; + background: #f5fbf7; + color: #2f6b4f; + font-size: 26rpx; + font-weight: 800; + line-height: 68rpx; +} + +.auth-mini-login__icon { + flex-shrink: 0; + width: 38rpx; + height: 38rpx; + border-radius: 10rpx; + background: #2f6b4f; + color: #ffffff; + font-size: 20rpx; + font-weight: 900; + line-height: 38rpx; + text-align: center; +} + +.auth-mini-login__text { + min-width: 0; +} + +.auth-mini-login--disabled { + opacity: 0.58; +} + .auth-agreement { display: flex; align-items: flex-start; @@ -727,6 +845,10 @@ onUnmounted(clearCountdown); padding: 0 8rpx; } +.auth-mini-login + .auth-agreement { + margin-top: 96rpx; +} + .auth-agreement__check { flex-shrink: 0; width: 22rpx; @@ -790,5 +912,9 @@ onUnmounted(clearCountdown); .auth-agreement { margin-top: 116rpx; } + + .auth-mini-login + .auth-agreement { + margin-top: 72rpx; + } } diff --git a/user-app/src/pages/auth/wechat-bind.vue b/user-app/src/pages/auth/wechat-bind.vue index 5cee4e4..52981fe 100644 --- a/user-app/src/pages/auth/wechat-bind.vue +++ b/user-app/src/pages/auth/wechat-bind.vue @@ -13,9 +13,11 @@ import { suppressNextWechatOAuth, } from "../../utils/auth"; +type BindSource = "wechat-h5" | "mini-program"; const COUNTDOWN_STORAGE_KEY = "anxinyan_wechat_bind_code_countdown_expire_at"; const redirect = ref(""); +const source = ref("wechat-h5"); const sending = ref(false); const submitting = ref(false); const countdown = ref(0); @@ -34,6 +36,12 @@ let countdownTimer: ReturnType | null = null; const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}s 后重发` : "发送验证码")); const displayName = computed(() => profile.value.nickname || "微信用户"); const displayAvatar = computed(() => profile.value.avatar || ""); +const brandSubtitle = computed(() => + source.value === "mini-program" ? "绑定手机号后即可完成小程序授权登录" : "绑定手机号后即可完成微信登录", +); +const profileDesc = computed(() => + source.value === "mini-program" ? "首次小程序授权登录需验证手机号" : "首次微信登录需验证手机号", +); function resolveSendCodeError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ""); @@ -144,13 +152,22 @@ async function handleSubmit() { submitting.value = true; try { - const result = await withLoading("正在绑定", async () => - authApi.bindWechatMobile({ + const payload = { + bind_ticket: bindTicket.value, + mobile: form.mobile.trim(), + code: form.code.trim(), + }; + const result = await withLoading("正在绑定", async () => { + if (source.value === "mini-program") { + return authApi.bindMiniProgramMobile(payload); + } + + return authApi.bindWechatMobile({ bind_ticket: bindTicket.value, mobile: form.mobile.trim(), code: form.code.trim(), - }), - ); + }); + }); setUserToken(result.token); clearWechatBindSession(); appraisalStore.resetForNewFlow(); @@ -171,6 +188,7 @@ function useMobileLogin() { onLoad((options) => { redirect.value = String(options?.redirect || ""); + source.value = String(options?.source || "") === "mini-program" ? "mini-program" : "wechat-h5"; bindTicket.value = getWechatBindTicket(); profile.value = getWechatBindProfile(); restoreCountdown(); @@ -199,7 +217,7 @@ onUnmounted(clearCountdown); 安心验 - 绑定手机号后即可完成微信登录 + {{ brandSubtitle }} @@ -208,7 +226,7 @@ onUnmounted(clearCountdown); {{ displayName }} - 首次微信登录需验证手机号 + {{ profileDesc }} diff --git a/user-app/src/pages/report/detail.vue b/user-app/src/pages/report/detail.vue index f9840b5..fe5a408 100644 --- a/user-app/src/pages/report/detail.vue +++ b/user-app/src/pages/report/detail.vue @@ -62,7 +62,6 @@ const productItems = computed(() => { { 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) { @@ -97,8 +96,7 @@ const productSpecItems = computed(() => { { label: "品牌", value: detail.value.product_info.brand_name || "-", remark: "" }, { label: "颜色", value: detail.value.product_info.color || "-", remark: "" }, { label: "规格/尺寸", value: detail.value.product_info.size_spec || "-", remark: "" }, - { label: "序列号/编码", value: detail.value.product_info.serial_no || "-", remark: "" }, - ].filter((item) => item.value && item.value !== "-"); + ].filter((item) => !isHiddenProductItemLabel(item.label) && item.value && item.value !== "-"); }); const traceInfoVisible = computed(() => Boolean(detail.value.trace_info?.visible || detail.value.report_header.trace_info_visible)); const centerTabVisible = computed(() => { @@ -128,7 +126,13 @@ function appendProductItem(items: ProductDisplayItem[], label: unknown, value: u const labelText = textValue(label); const valueText = textValue(value); const remarkText = textValue(remark); - if (labelText === "鉴定师" || !labelText || (!valueText && !remarkText) || items.some((item) => item.label === labelText)) return; + if ( + labelText === "鉴定师" + || isHiddenProductItemLabel(labelText) + || !labelText + || (!valueText && !remarkText) + || items.some((item) => item.label === labelText) + ) return; items.push({ label: labelText, value: valueText || "-", @@ -140,6 +144,11 @@ function textValue(value: unknown) { return String(value ?? "").trim(); } +function isHiddenProductItemLabel(label: string) { + const normalized = label.replace(/\s+/g, ""); + return normalized === "序列号" || normalized === "序列号/编码"; +} + function serviceProviderText(serviceProvider: string) { return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定"; } diff --git a/work-app/src/pages/report/detail.vue b/work-app/src/pages/report/detail.vue index 6576f5b..e76ace1 100644 --- a/work-app/src/pages/report/detail.vue +++ b/work-app/src/pages/report/detail.vue @@ -73,7 +73,6 @@ const productSpecItems = computed(() => { appendSpecItem(items, "品牌", product.brand_name); appendSpecItem(items, "颜色", product.color); appendSpecItem(items, "规格/尺寸", product.size_spec); - appendSpecItem(items, "序列号/编码", product.serial_no); for (const point of normalizedKeyPoints(result.key_points)) { if (hasSpecItem(items, point.point_name)) continue; @@ -102,11 +101,12 @@ function appendSpecItem( value: unknown, remark: unknown = "", ) { + const labelText = textValue(label); const valueText = textValue(value); const remarkText = textValue(remark); - if (!valueText && !remarkText) return; + if (!labelText || isHiddenProductSpecLabel(labelText) || (!valueText && !remarkText)) return; items.push({ - label, + label: labelText, value: valueText || "-", remark: remarkText, }); @@ -121,6 +121,11 @@ function textValue(value: unknown) { return String(value ?? "").trim(); } +function isHiddenProductSpecLabel(label: string) { + const normalized = label.replace(/\s+/g, ""); + return normalized === "序列号" || normalized === "序列号/编码"; +} + function normalizedKeyPoints(value: unknown) { if (!Array.isArray(value)) return []; return value