diff --git a/apps/admin-web/src/pages/LoginPage.tsx b/apps/admin-web/src/pages/LoginPage.tsx index 0255659..59461da 100644 --- a/apps/admin-web/src/pages/LoginPage.tsx +++ b/apps/admin-web/src/pages/LoginPage.tsx @@ -1,14 +1,17 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Button, Card, Form, Input, message, Typography } from 'antd'; +import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd'; import { saveAuth, request } from '../lib/api'; +type LoginResult = { accessToken: string; refreshToken: string }; + export default function LoginPage() { const navigate = useNavigate(); - const [form] = Form.useForm(); + const [smsForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); const [loading, setLoading] = useState(false); const [codeCooldown, setCodeCooldown] = useState(0); - const phone = Form.useWatch('phone', form); + const phone = Form.useWatch('phone', smsForm); async function sendCode() { if (!phone) { @@ -19,7 +22,7 @@ export default function LoginPage() { method: 'POST', body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }), }); - message.success('验证码已发送(Mock: 123456)'); + message.success('验证码已发送'); setCodeCooldown(60); const timer = setInterval(() => { setCodeCooldown((c) => { @@ -32,19 +35,35 @@ export default function LoginPage() { }, 1000); } - async function onFinish(values: { phone: string; code: string }) { + async function finishLogin(data: LoginResult) { + saveAuth(data); + message.success('登录成功'); + navigate('/'); + } + + async function onSmsFinish(values: { phone: string; code: string }) { setLoading(true); try { - const data = await request<{ accessToken: string; refreshToken: string }>( - '/admin/auth/login/sms', - { - method: 'POST', - body: JSON.stringify(values), - }, - ); - saveAuth(data); - message.success('登录成功'); - navigate('/'); + const data = await request('/admin/auth/login/sms', { + method: 'POST', + body: JSON.stringify(values), + }); + await finishLogin(data); + } catch (e) { + message.error(e instanceof Error ? e.message : '登录失败'); + } finally { + setLoading(false); + } + } + + async function onPasswordFinish(values: { loginName: string; password: string }) { + setLoading(true); + try { + const data = await request('/admin/auth/login/password', { + method: 'POST', + body: JSON.stringify(values), + }); + await finishLogin(data); } catch (e) { message.error(e instanceof Error ? e.message : '登录失败'); } finally { @@ -66,24 +85,61 @@ export default function LoginPage() { HQ 管理后台 -
- - - - - 0} onClick={() => void sendCode()}> - {codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'} - - } - /> - - -
+ + + + + + + + + + ), + }, + { + key: 'sms', + label: '短信验证码', + children: ( +
+ + + + + 0} onClick={() => void sendCode()}> + {codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'} + + } + /> + + +
+ ), + }, + ]} + /> ); diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 69fbd26..1eaa770 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -12,6 +12,7 @@ "prisma:validate": "prisma validate", "prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts", "prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts", + "prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts", "prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts" }, "dependencies": { diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 287771e..74e4feb 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -493,10 +493,12 @@ model PartnerBill { // ─── HQ ─────────────────────────────────────────────── model HqAccount { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - phone String @unique @db.VarChar(20) - name String @db.VarChar(64) - adminRole HqAdminRole @default(OPS) @map("admin_role") + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + phone String @unique @db.VarChar(20) + loginName String? @unique @map("login_name") @db.VarChar(64) + passwordHash String? @map("password_hash") @db.VarChar(255) + name String @db.VarChar(64) + adminRole HqAdminRole @default(OPS) @map("admin_role") wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxUnionId String? @map("wx_union_id") @db.VarChar(64) status AccountStatus @default(ACTIVE) diff --git a/server/dukang-api/scripts/upsert-super-admin.ts b/server/dukang-api/scripts/upsert-super-admin.ts new file mode 100644 index 0000000..ec38ced --- /dev/null +++ b/server/dukang-api/scripts/upsert-super-admin.ts @@ -0,0 +1,46 @@ +import '../src/load-env'; +import { PrismaClient } from '@prisma/client'; +import { hashPassword } from '../src/common/crypto/password.util'; + +const LOGIN_NAME = process.env.SUPER_ADMIN_LOGIN ?? 'admin'; +const PASSWORD = process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!'; +const PLACEHOLDER_PHONE = process.env.SUPER_ADMIN_PHONE ?? '19900000001'; + +async function main() { + const prisma = new PrismaClient(); + const passwordHash = hashPassword(PASSWORD); + try { + const deleted = await prisma.$executeRaw` + DELETE FROM hq_account WHERE admin_role = 'SUPER_ADMIN' + `; + console.log(`Deleted ${deleted} SUPER_ADMIN account row(s).`); + + await prisma.$executeRaw` + INSERT INTO hq_account ( + phone, login_name, password_hash, name, admin_role, status, created_at, updated_at + ) VALUES ( + ${PLACEHOLDER_PHONE}, + ${LOGIN_NAME}, + ${passwordHash}, + ${'超级管理员'}, + ${'SUPER_ADMIN'}, + ${'ACTIVE'}, + NOW(3), + NOW(3) + ) + `; + + console.log('Created SUPER_ADMIN:', { + loginName: LOGIN_NAME, + phone: PLACEHOLDER_PHONE, + password: '(hidden)', + }); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/dukang-api/src/common/crypto/password.util.ts b/server/dukang-api/src/common/crypto/password.util.ts new file mode 100644 index 0000000..21f4e7b --- /dev/null +++ b/server/dukang-api/src/common/crypto/password.util.ts @@ -0,0 +1,20 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +const SALT_LEN = 16; +const KEY_LEN = 64; + +export function hashPassword(password: string): string { + const salt = randomBytes(SALT_LEN); + const hash = scryptSync(password, salt, KEY_LEN); + return `${salt.toString('hex')}:${hash.toString('hex')}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const [saltHex, hashHex] = stored.split(':'); + if (!saltHex || !hashHex) return false; + const salt = Buffer.from(saltHex, 'hex'); + const expected = Buffer.from(hashHex, 'hex'); + const actual = scryptSync(password, salt, expected.length); + if (actual.length !== expected.length) return false; + return timingSafeEqual(actual, expected); +} diff --git a/server/dukang-api/src/modules/iam/admin-auth.controller.ts b/server/dukang-api/src/modules/iam/admin-auth.controller.ts index 025144c..b57dac6 100644 --- a/server/dukang-api/src/modules/iam/admin-auth.controller.ts +++ b/server/dukang-api/src/modules/iam/admin-auth.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { LoginSmsDto, SendSmsDto } from './dto/auth.dto'; +import { LoginPasswordDto, LoginSmsDto, SendSmsDto } from './dto/auth.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { AuthUser } from '../../common/guards/jwt-auth.guard'; @@ -20,6 +20,11 @@ export class AdminAuthController { return this.authService.loginHq(dto.phone, dto.code, ClientApp.HQ_WEB); } + @Post('login/password') + loginPassword(@Body() dto: LoginPasswordDto) { + return this.authService.loginHqPassword(dto.loginName, dto.password, ClientApp.HQ_WEB); + } + @Get('me') @UseGuards(JwtAuthGuard) me(@CurrentUser() user: AuthUser) { diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 4e10256..cb344d1 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -19,6 +19,7 @@ import type { SmsActorRef } from '../../integrations/sms/sms.interface'; import { SmsCodeStore } from '../../integrations/sms/sms-code.store'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { verifyPassword } from '../../common/crypto/password.util'; import { AnalyticsService } from '../analytics/analytics.service'; import { UserAddressService } from './user-address.service'; @@ -450,6 +451,30 @@ export class AuthService { }); } + async loginHqPassword(loginName: string, password: string, clientApp: ClientApp) { + const normalizedLogin = loginName.trim(); + if (!normalizedLogin) throw new BadRequestException('请输入账号'); + const account = await this.prisma.hqAccount.findUnique({ + where: { loginName: normalizedLogin }, + }); + if (!account?.passwordHash) throw new BadRequestException('账号或密码错误'); + if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用'); + if (!verifyPassword(password, account.passwordHash)) { + throw new BadRequestException('账号或密码错误'); + } + await this.prisma.hqAccount.update({ + where: { id: account.id }, + data: { lastLoginAt: new Date() }, + }); + return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, { + id: account.id.toString(), + phone: account.phone, + name: account.name, + adminRole: account.adminRole, + status: account.status, + }); + } + async getMe(actorType: string, actorId: bigint) { if (actorType === 'USER') { const user = await this.assertActiveUser(actorId); diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index fccec9c..79d3268 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -69,6 +69,16 @@ export class BindWechatPhoneDto { code: string; } +export class LoginPasswordDto { + @IsString() + @IsNotEmpty() + loginName: string; + + @IsString() + @IsNotEmpty() + password: string; +} + export class BindWechatDto { @IsString() @IsOptional()