chore: prepare production release package
This commit is contained in:
@@ -782,10 +782,6 @@ watch(
|
|||||||
<div class="detail-label">颜色 / 规格</div>
|
<div class="detail-label">颜色 / 规格</div>
|
||||||
<div class="detail-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</div>
|
<div class="detail-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card__desc">
|
|
||||||
<div class="detail-label">序列号</div>
|
|
||||||
<div class="detail-value">{{ detail.product_info.serial_no || "-" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
|
|||||||
@@ -593,39 +593,92 @@ class SystemConfigsController
|
|||||||
throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数');
|
throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->isPemContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) {
|
if (!$this->isPrivateKeyContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) {
|
||||||
throw new \RuntimeException('商户 RSA 私钥需填写 PEM 内容,或填写服务器可读取的 PEM 文件路径');
|
throw new \RuntimeException('商户 RSA 私钥需填写可被 OpenSSL 解析的 PEM 内容,或填写服务器可读取的 PEM 文件路径');
|
||||||
}
|
}
|
||||||
if (!$this->isPublicKeyContentOrReadablePath((string)$configValueMap['payment.shouqianba_public_key'])) {
|
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
|
private function isPublicKeyContentOrReadablePath(string $value): bool
|
||||||
{
|
{
|
||||||
$value = trim($value);
|
$content = $this->pemContentOrReadablePath($value);
|
||||||
if ($this->isPemContentOrReadablePath($value)) {
|
if ($content !== '' && $this->canOpenPublicKey($content)) {
|
||||||
return true;
|
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);
|
$value = trim($value);
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return false;
|
return '';
|
||||||
}
|
}
|
||||||
if (str_contains($value, '-----BEGIN')) {
|
if (str_contains($value, '-----BEGIN')) {
|
||||||
return true;
|
return $this->normalizePemNewlines($value);
|
||||||
}
|
}
|
||||||
if (!is_file($value) || !is_readable($value)) {
|
if (!is_file($value) || !is_readable($value)) {
|
||||||
return false;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = file_get_contents($value);
|
$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
|
private function looksLikeBase64KeyBody(string $value): bool
|
||||||
|
|||||||
@@ -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)
|
public function me(Request $request)
|
||||||
{
|
{
|
||||||
$userInfo = (new AppAuthService())->current($request);
|
$userInfo = (new AppAuthService())->current($request);
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class AppAuthMiddleware implements MiddlewareInterface
|
|||||||
'/api/app/auth/wechat/config',
|
'/api/app/auth/wechat/config',
|
||||||
'/api/app/auth/wechat/exchange',
|
'/api/app/auth/wechat/exchange',
|
||||||
'/api/app/auth/wechat/bind-mobile',
|
'/api/app/auth/wechat/bind-mobile',
|
||||||
|
'/api/app/auth/mini-program/exchange',
|
||||||
|
'/api/app/auth/mini-program/bind-mobile',
|
||||||
], true);
|
], true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,85 @@
|
|||||||
namespace app\support;
|
namespace app\support;
|
||||||
|
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
|
use Webman\Http\Request;
|
||||||
|
|
||||||
class MiniProgramAuthService
|
class MiniProgramAuthService
|
||||||
{
|
{
|
||||||
public const AUTH_TYPE = 'wechat_mini_program';
|
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
|
public function bindOpenid(int $userId, string $code): array
|
||||||
{
|
{
|
||||||
@@ -93,6 +168,262 @@ class MiniProgramAuthService
|
|||||||
->value('auth_open_id');
|
->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
|
private function fetchOpenidByCode(string $code): array
|
||||||
{
|
{
|
||||||
if (str_starts_with($code, 'mock_mp_')) {
|
if (str_starts_with($code, 'mock_mp_')) {
|
||||||
@@ -170,4 +501,47 @@ class MiniProgramAuthService
|
|||||||
->where('config_key', $key)
|
->where('config_key', $key)
|
||||||
->value('config_value'));
|
->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 : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ class ShouqianbaConfigService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertPrivateKey($config['merchant_private_key']);
|
||||||
|
if ($requirePublicKey) {
|
||||||
|
$this->assertPublicKey($config['shouqianba_public_key']);
|
||||||
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +153,7 @@ class ShouqianbaConfigService
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (str_contains($value, '-----BEGIN')) {
|
if (str_contains($value, '-----BEGIN')) {
|
||||||
return $value;
|
return $this->normalizePemNewlines($value);
|
||||||
}
|
}
|
||||||
if (is_file($value)) {
|
if (is_file($value)) {
|
||||||
$content = file_get_contents($value);
|
$content = file_get_contents($value);
|
||||||
@@ -167,6 +172,39 @@ class ShouqianbaConfigService
|
|||||||
return $value;
|
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
|
private function looksLikeBase64KeyBody(string $value): bool
|
||||||
{
|
{
|
||||||
$body = preg_replace('/\s+/', '', trim($value));
|
$body = preg_replace('/\s+/', '', trim($value));
|
||||||
|
|||||||
@@ -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::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/exchange', [AppAuthController::class, 'wechatExchange']);
|
||||||
Route::post('/api/app/auth/wechat/bind-mobile', [AppAuthController::class, 'wechatBindMobile']);
|
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::post('/api/app/auth/mini-program/bind', [AppAuthController::class, 'miniProgramBind']);
|
||||||
Route::get('/api/app/auth/me', [AppAuthController::class, 'me']);
|
Route::get('/api/app/auth/me', [AppAuthController::class, 'me']);
|
||||||
Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']);
|
Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']);
|
||||||
|
|||||||
@@ -274,6 +274,22 @@ function latestPayment(int $orderId): array
|
|||||||
return $payment;
|
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 = [
|
$configKeys = [
|
||||||
'payment.enabled',
|
'payment.enabled',
|
||||||
'payment.api_domain',
|
'payment.api_domain',
|
||||||
@@ -294,6 +310,7 @@ $configKeys = [
|
|||||||
$snapshot = captureConfigs($configKeys);
|
$snapshot = captureConfigs($configKeys);
|
||||||
$client = new MockShouqianbaClient(new ShouqianbaConfigService());
|
$client = new MockShouqianbaClient(new ShouqianbaConfigService());
|
||||||
$service = new ShouqianbaPaymentService(null, $client);
|
$service = new ShouqianbaPaymentService(null, $client);
|
||||||
|
[$mockPrivateKey, $mockPublicKey] = mockKeyPair();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cleanupMockData();
|
cleanupMockData();
|
||||||
@@ -307,8 +324,8 @@ try {
|
|||||||
ensureConfig('payment', 'workstation_sn', '0');
|
ensureConfig('payment', 'workstation_sn', '0');
|
||||||
ensureConfig('payment', 'industry_code', '0');
|
ensureConfig('payment', 'industry_code', '0');
|
||||||
ensureConfig('payment', 'order_expire_minutes', '1440');
|
ensureConfig('payment', 'order_expire_minutes', '1440');
|
||||||
ensureConfig('payment', 'merchant_private_key', "-----BEGIN PRIVATE KEY-----\nmock\n-----END PRIVATE KEY-----");
|
ensureConfig('payment', 'merchant_private_key', $mockPrivateKey);
|
||||||
ensureConfig('payment', 'shouqianba_public_key', "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----");
|
ensureConfig('payment', 'shouqianba_public_key', $mockPublicKey);
|
||||||
ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify');
|
ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify');
|
||||||
ensureConfig('payment', 'mini_program_plugin_version', '2.3.70');
|
ensureConfig('payment', 'mini_program_plugin_version', '2.3.70');
|
||||||
ensureConfig('h5', 'page_base_url', 'https://m.example.com');
|
ensureConfig('h5', 'page_base_url', 'https://m.example.com');
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
VITE_API_BASE_URL=http://127.0.0.1:8787
|
VITE_API_BASE_URL=https://test.api.anxinjianyan.com
|
||||||
VITE_APP_ENV=development
|
VITE_APP_ENV=test
|
||||||
VITE_APP_TITLE=安心验
|
VITE_APP_TITLE=安心验
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"build:mp-harmony": "uni build -p mp-harmony",
|
"build:mp-harmony": "uni build -p mp-harmony",
|
||||||
"sync:mp-config": "php ../server-api/tools/sync_client_configs.php",
|
"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": "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:mp-xhs": "uni build -p mp-xhs",
|
||||||
"build:quickapp-webview": "uni build -p quickapp-webview",
|
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export interface MiniProgramBindResult {
|
|||||||
unionid: string;
|
unionid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MiniProgramExchangeResult extends WechatExchangeResult {}
|
||||||
|
|
||||||
|
export interface MiniProgramBindMobileResult extends LoginResult {
|
||||||
|
status: "logged_in";
|
||||||
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
sendLoginCode(mobile: string) {
|
sendLoginCode(mobile: string) {
|
||||||
return request<SendLoginCodeResult>("/api/app/auth/send-code", {
|
return request<SendLoginCodeResult>("/api/app/auth/send-code", {
|
||||||
@@ -95,6 +101,22 @@ export const authApi = {
|
|||||||
data: { code },
|
data: { code },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
exchangeMiniProgramCode(code: string) {
|
||||||
|
return request<MiniProgramExchangeResult>("/api/app/auth/mini-program/exchange", {
|
||||||
|
method: "POST",
|
||||||
|
data: { code },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
bindMiniProgramMobile(payload: {
|
||||||
|
bind_ticket: string;
|
||||||
|
mobile: string;
|
||||||
|
code: string;
|
||||||
|
}) {
|
||||||
|
return request<MiniProgramBindMobileResult>("/api/app/auth/mini-program/bind-mobile", {
|
||||||
|
method: "POST",
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
getMe() {
|
getMe() {
|
||||||
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
|
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
|||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import { authApi } from "../../api/auth";
|
import { authApi } from "../../api/auth";
|
||||||
import { useAppraisalStore } from "../../stores/appraisal";
|
import { useAppraisalStore } from "../../stores/appraisal";
|
||||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
import {
|
import {
|
||||||
clearWechatBindSession,
|
clearWechatBindSession,
|
||||||
clearWechatOAuthState,
|
clearWechatOAuthState,
|
||||||
@@ -26,6 +26,8 @@ const sending = ref(false);
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const wechatProcessing = ref(false);
|
const wechatProcessing = ref(false);
|
||||||
const wechatMessage = ref("");
|
const wechatMessage = ref("");
|
||||||
|
const miniProgramProcessing = ref(false);
|
||||||
|
const miniProgramMessage = ref("");
|
||||||
const countdown = ref(0);
|
const countdown = ref(0);
|
||||||
const redirect = ref("");
|
const redirect = ref("");
|
||||||
const sendCodeErrorMessage = ref("");
|
const sendCodeErrorMessage = ref("");
|
||||||
@@ -44,6 +46,15 @@ const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}
|
|||||||
const countdownHint = computed(() =>
|
const countdownHint = computed(() =>
|
||||||
countdown.value > 0 ? `${countdown.value} 秒后可重新发送验证码` : "验证码有效期 5 分钟,请注意查收短信。",
|
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") {
|
function openAgreement(keyword: "privacy" | "service") {
|
||||||
const query = encodeURIComponent(keyword === "privacy" ? "隐私政策" : "服务协议");
|
const query = encodeURIComponent(keyword === "privacy" ? "隐私政策" : "服务协议");
|
||||||
@@ -275,6 +286,66 @@ async function handleWechatCallback() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMiniProgramLoginCode() {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
return new Promise<string>((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() {
|
async function handleSendCode() {
|
||||||
if (sending.value || countdown.value > 0) return;
|
if (sending.value || countdown.value > 0) return;
|
||||||
if (!validateMobile()) return;
|
if (!validateMobile()) return;
|
||||||
@@ -387,11 +458,11 @@ onUnmounted(clearCountdown);
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="wechatProcessing || wechatMessage" class="auth-wechat-status">
|
<view v-if="authorizationStatusVisible" class="auth-wechat-status">
|
||||||
<view class="auth-wechat-status__icon">微</view>
|
<view class="auth-wechat-status__icon">微</view>
|
||||||
<view>
|
<view>
|
||||||
<view class="auth-wechat-status__title">{{ wechatProcessing ? "微信授权登录" : "微信授权提示" }}</view>
|
<view class="auth-wechat-status__title">{{ authorizationStatusTitle }}</view>
|
||||||
<view class="auth-wechat-status__desc">{{ wechatMessage || "正在打开微信授权" }}</view>
|
<view class="auth-wechat-status__desc">{{ authorizationStatusDesc }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -427,6 +498,16 @@ onUnmounted(clearCountdown);
|
|||||||
{{ submitting ? "登录中..." : "登录" }}
|
{{ submitting ? "登录中..." : "登录" }}
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
|
<view
|
||||||
|
:class="['auth-mini-login', miniProgramProcessing ? 'auth-mini-login--disabled' : '']"
|
||||||
|
@click="handleMiniProgramAuthorizeLogin"
|
||||||
|
>
|
||||||
|
<view class="auth-mini-login__icon">微</view>
|
||||||
|
<view class="auth-mini-login__text">{{ miniProgramProcessing ? "授权中..." : "微信授权登录" }}</view>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
<view class="auth-agreement" @click="toggleAgreement">
|
<view class="auth-agreement" @click="toggleAgreement">
|
||||||
<view :class="['auth-agreement__check', agreementAccepted ? 'auth-agreement__check--active' : '']"></view>
|
<view :class="['auth-agreement__check', agreementAccepted ? 'auth-agreement__check--active' : '']"></view>
|
||||||
<view class="auth-agreement__text">
|
<view class="auth-agreement__text">
|
||||||
@@ -718,6 +799,43 @@ onUnmounted(clearCountdown);
|
|||||||
box-shadow: none;
|
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 {
|
.auth-agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -727,6 +845,10 @@ onUnmounted(clearCountdown);
|
|||||||
padding: 0 8rpx;
|
padding: 0 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-mini-login + .auth-agreement {
|
||||||
|
margin-top: 96rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-agreement__check {
|
.auth-agreement__check {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 22rpx;
|
width: 22rpx;
|
||||||
@@ -790,5 +912,9 @@ onUnmounted(clearCountdown);
|
|||||||
.auth-agreement {
|
.auth-agreement {
|
||||||
margin-top: 116rpx;
|
margin-top: 116rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-mini-login + .auth-agreement {
|
||||||
|
margin-top: 72rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import {
|
|||||||
suppressNextWechatOAuth,
|
suppressNextWechatOAuth,
|
||||||
} from "../../utils/auth";
|
} from "../../utils/auth";
|
||||||
|
|
||||||
|
type BindSource = "wechat-h5" | "mini-program";
|
||||||
const COUNTDOWN_STORAGE_KEY = "anxinyan_wechat_bind_code_countdown_expire_at";
|
const COUNTDOWN_STORAGE_KEY = "anxinyan_wechat_bind_code_countdown_expire_at";
|
||||||
|
|
||||||
const redirect = ref("");
|
const redirect = ref("");
|
||||||
|
const source = ref<BindSource>("wechat-h5");
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const countdown = ref(0);
|
const countdown = ref(0);
|
||||||
@@ -34,6 +36,12 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
|||||||
const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}s 后重发` : "发送验证码"));
|
const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}s 后重发` : "发送验证码"));
|
||||||
const displayName = computed(() => profile.value.nickname || "微信用户");
|
const displayName = computed(() => profile.value.nickname || "微信用户");
|
||||||
const displayAvatar = computed(() => profile.value.avatar || "");
|
const displayAvatar = computed(() => profile.value.avatar || "");
|
||||||
|
const brandSubtitle = computed(() =>
|
||||||
|
source.value === "mini-program" ? "绑定手机号后即可完成小程序授权登录" : "绑定手机号后即可完成微信登录",
|
||||||
|
);
|
||||||
|
const profileDesc = computed(() =>
|
||||||
|
source.value === "mini-program" ? "首次小程序授权登录需验证手机号" : "首次微信登录需验证手机号",
|
||||||
|
);
|
||||||
|
|
||||||
function resolveSendCodeError(error: unknown) {
|
function resolveSendCodeError(error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
@@ -144,13 +152,22 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await withLoading("正在绑定", async () =>
|
const payload = {
|
||||||
authApi.bindWechatMobile({
|
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,
|
bind_ticket: bindTicket.value,
|
||||||
mobile: form.mobile.trim(),
|
mobile: form.mobile.trim(),
|
||||||
code: form.code.trim(),
|
code: form.code.trim(),
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
setUserToken(result.token);
|
setUserToken(result.token);
|
||||||
clearWechatBindSession();
|
clearWechatBindSession();
|
||||||
appraisalStore.resetForNewFlow();
|
appraisalStore.resetForNewFlow();
|
||||||
@@ -171,6 +188,7 @@ function useMobileLogin() {
|
|||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
redirect.value = String(options?.redirect || "");
|
redirect.value = String(options?.redirect || "");
|
||||||
|
source.value = String(options?.source || "") === "mini-program" ? "mini-program" : "wechat-h5";
|
||||||
bindTicket.value = getWechatBindTicket();
|
bindTicket.value = getWechatBindTicket();
|
||||||
profile.value = getWechatBindProfile();
|
profile.value = getWechatBindProfile();
|
||||||
restoreCountdown();
|
restoreCountdown();
|
||||||
@@ -199,7 +217,7 @@ onUnmounted(clearCountdown);
|
|||||||
<view class="bind-brand-mark">安</view>
|
<view class="bind-brand-mark">安</view>
|
||||||
<view>
|
<view>
|
||||||
<view class="bind-brand-title">安心验</view>
|
<view class="bind-brand-title">安心验</view>
|
||||||
<view class="bind-brand-subtitle">绑定手机号后即可完成微信登录</view>
|
<view class="bind-brand-subtitle">{{ brandSubtitle }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -208,7 +226,7 @@ onUnmounted(clearCountdown);
|
|||||||
<view v-else class="bind-profile__avatar bind-profile__avatar--text">微</view>
|
<view v-else class="bind-profile__avatar bind-profile__avatar--text">微</view>
|
||||||
<view>
|
<view>
|
||||||
<view class="bind-profile__name">{{ displayName }}</view>
|
<view class="bind-profile__name">{{ displayName }}</view>
|
||||||
<view class="bind-profile__desc">首次微信登录需验证手机号</view>
|
<view class="bind-profile__desc">{{ profileDesc }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ const productItems = computed(() => {
|
|||||||
{ label: "品牌", value: detail.value.product_info.brand_name || "" },
|
{ label: "品牌", value: detail.value.product_info.brand_name || "" },
|
||||||
{ label: "颜色", value: detail.value.product_info.color || "" },
|
{ label: "颜色", value: detail.value.product_info.color || "" },
|
||||||
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "" },
|
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "" },
|
||||||
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const item of baseItems) {
|
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.brand_name || "-", remark: "" },
|
||||||
{ label: "颜色", value: detail.value.product_info.color || "-", remark: "" },
|
{ label: "颜色", value: detail.value.product_info.color || "-", remark: "" },
|
||||||
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "-", remark: "" },
|
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "-", remark: "" },
|
||||||
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "-", remark: "" },
|
].filter((item) => !isHiddenProductItemLabel(item.label) && item.value && item.value !== "-");
|
||||||
].filter((item) => item.value && item.value !== "-");
|
|
||||||
});
|
});
|
||||||
const traceInfoVisible = computed(() => Boolean(detail.value.trace_info?.visible || detail.value.report_header.trace_info_visible));
|
const traceInfoVisible = computed(() => Boolean(detail.value.trace_info?.visible || detail.value.report_header.trace_info_visible));
|
||||||
const centerTabVisible = computed(() => {
|
const centerTabVisible = computed(() => {
|
||||||
@@ -128,7 +126,13 @@ function appendProductItem(items: ProductDisplayItem[], label: unknown, value: u
|
|||||||
const labelText = textValue(label);
|
const labelText = textValue(label);
|
||||||
const valueText = textValue(value);
|
const valueText = textValue(value);
|
||||||
const remarkText = textValue(remark);
|
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({
|
items.push({
|
||||||
label: labelText,
|
label: labelText,
|
||||||
value: valueText || "-",
|
value: valueText || "-",
|
||||||
@@ -140,6 +144,11 @@ function textValue(value: unknown) {
|
|||||||
return String(value ?? "").trim();
|
return String(value ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHiddenProductItemLabel(label: string) {
|
||||||
|
const normalized = label.replace(/\s+/g, "");
|
||||||
|
return normalized === "序列号" || normalized === "序列号/编码";
|
||||||
|
}
|
||||||
|
|
||||||
function serviceProviderText(serviceProvider: string) {
|
function serviceProviderText(serviceProvider: string) {
|
||||||
return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定";
|
return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ const productSpecItems = computed(() => {
|
|||||||
appendSpecItem(items, "品牌", product.brand_name);
|
appendSpecItem(items, "品牌", product.brand_name);
|
||||||
appendSpecItem(items, "颜色", product.color);
|
appendSpecItem(items, "颜色", product.color);
|
||||||
appendSpecItem(items, "规格/尺寸", product.size_spec);
|
appendSpecItem(items, "规格/尺寸", product.size_spec);
|
||||||
appendSpecItem(items, "序列号/编码", product.serial_no);
|
|
||||||
|
|
||||||
for (const point of normalizedKeyPoints(result.key_points)) {
|
for (const point of normalizedKeyPoints(result.key_points)) {
|
||||||
if (hasSpecItem(items, point.point_name)) continue;
|
if (hasSpecItem(items, point.point_name)) continue;
|
||||||
@@ -102,11 +101,12 @@ function appendSpecItem(
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
remark: unknown = "",
|
remark: unknown = "",
|
||||||
) {
|
) {
|
||||||
|
const labelText = textValue(label);
|
||||||
const valueText = textValue(value);
|
const valueText = textValue(value);
|
||||||
const remarkText = textValue(remark);
|
const remarkText = textValue(remark);
|
||||||
if (!valueText && !remarkText) return;
|
if (!labelText || isHiddenProductSpecLabel(labelText) || (!valueText && !remarkText)) return;
|
||||||
items.push({
|
items.push({
|
||||||
label,
|
label: labelText,
|
||||||
value: valueText || "-",
|
value: valueText || "-",
|
||||||
remark: remarkText,
|
remark: remarkText,
|
||||||
});
|
});
|
||||||
@@ -121,6 +121,11 @@ function textValue(value: unknown) {
|
|||||||
return String(value ?? "").trim();
|
return String(value ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHiddenProductSpecLabel(label: string) {
|
||||||
|
const normalized = label.replace(/\s+/g, "");
|
||||||
|
return normalized === "序列号" || normalized === "序列号/编码";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedKeyPoints(value: unknown) {
|
function normalizedKeyPoints(value: unknown) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value
|
return value
|
||||||
|
|||||||
Reference in New Issue
Block a user