超管用户登录;
代码核销功能
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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';
|
import { saveAuth, request } from '../lib/api';
|
||||||
|
|
||||||
|
type LoginResult = { accessToken: string; refreshToken: string };
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [smsForm] = Form.useForm();
|
||||||
|
const [passwordForm] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const phone = Form.useWatch('phone', form);
|
const phone = Form.useWatch('phone', smsForm);
|
||||||
|
|
||||||
async function sendCode() {
|
async function sendCode() {
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
@@ -19,7 +22,7 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
|
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
|
||||||
});
|
});
|
||||||
message.success('验证码已发送(Mock: 123456)');
|
message.success('验证码已发送');
|
||||||
setCodeCooldown(60);
|
setCodeCooldown(60);
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCodeCooldown((c) => {
|
setCodeCooldown((c) => {
|
||||||
@@ -32,19 +35,35 @@ export default function LoginPage() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFinish(values: { phone: string; code: string }) {
|
async function finishLogin(data: LoginResult) {
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await request<{ accessToken: string; refreshToken: string }>(
|
|
||||||
'/admin/auth/login/sms',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(values),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
saveAuth(data);
|
saveAuth(data);
|
||||||
message.success('登录成功');
|
message.success('登录成功');
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSmsFinish(values: { phone: string; code: string }) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<LoginResult>('/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<LoginResult>('/admin/auth/login/password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
await finishLogin(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '登录失败');
|
message.error(e instanceof Error ? e.message : '登录失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -66,7 +85,40 @@ export default function LoginPage() {
|
|||||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
HQ 管理后台
|
HQ 管理后台
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Form form={form} layout="vertical" onFinish={onFinish} initialValues={{ phone: '13600000001', code: '123456' }}>
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'password',
|
||||||
|
label: '账号密码',
|
||||||
|
children: (
|
||||||
|
<Form
|
||||||
|
form={passwordForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={onPasswordFinish}
|
||||||
|
initialValues={{ loginName: 'admin' }}
|
||||||
|
>
|
||||||
|
<Form.Item name="loginName" label="账号" rules={[{ required: true, message: '请输入账号' }]}>
|
||||||
|
<Input placeholder="admin" autoComplete="username" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sms',
|
||||||
|
label: '短信验证码',
|
||||||
|
children: (
|
||||||
|
<Form
|
||||||
|
form={smsForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={onSmsFinish}
|
||||||
|
initialValues={{ phone: '13600000001', code: '123456' }}
|
||||||
|
>
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
<Input placeholder="13600000001" maxLength={11} />
|
<Input placeholder="13600000001" maxLength={11} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -84,6 +136,10 @@ export default function LoginPage() {
|
|||||||
登录
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"prisma:validate": "prisma validate",
|
"prisma:validate": "prisma validate",
|
||||||
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
||||||
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.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"
|
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -495,6 +495,8 @@ model PartnerBill {
|
|||||||
model HqAccount {
|
model HqAccount {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
phone String @unique @db.VarChar(20)
|
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)
|
name String @db.VarChar(64)
|
||||||
adminRole HqAdminRole @default(OPS) @map("admin_role")
|
adminRole HqAdminRole @default(OPS) @map("admin_role")
|
||||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
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);
|
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')
|
@Get('me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
me(@CurrentUser() user: AuthUser) {
|
me(@CurrentUser() user: AuthUser) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { SmsActorRef } from '../../integrations/sms/sms.interface';
|
|||||||
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
|
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { verifyPassword } from '../../common/crypto/password.util';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { UserAddressService } from './user-address.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) {
|
async getMe(actorType: string, actorId: bigint) {
|
||||||
if (actorType === 'USER') {
|
if (actorType === 'USER') {
|
||||||
const user = await this.assertActiveUser(actorId);
|
const user = await this.assertActiveUser(actorId);
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ export class BindWechatPhoneDto {
|
|||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LoginPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
loginName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class BindWechatDto {
|
export class BindWechatDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
Reference in New Issue
Block a user