第四阶段开发完成

This commit is contained in:
2025-05-18 19:52:17 +08:00
parent 07391c3d69
commit ea60deaa8c
22 changed files with 2513 additions and 65 deletions

33
LogBook.md Normal file
View File

@@ -0,0 +1,33 @@
## 2024-07-29 (用户激活码功能 - 后端主体)
- **核心业务逻辑实现 (Service & Impl)**:
- 创建 `ActivationCodeService.java` 接口,定义了激活码生成 (`generateCodes`)、兑换 (`redeemCode`) 和查询条件构建 (`getQueryWrapper`) 的方法。
- 创建 `ActivationCodeServiceImpl.java` 实现类:
- `generateCodes`: 实现批量生成激活码的逻辑,使用 UUID 生成唯一码,支持自定义数量、面值、过期时间和批次号,并进行批量保存。
- `redeemCode`: 实现用户兑换激活码的逻辑,包含参数校验、激活码状态(是否存在、已用、过期)校验,调用 `UserService.increaseBalance()` 增加用户余额,并更新激活码状态。整个过程使用 `@Transactional` 保证事务原子性。
- `getQueryWrapper`: 根据 `ActivationCodeQueryRequest` 中的各种条件如激活码、使用状态、批次号、用户ID、面值范围、过期时间范围、创建时间范围构建 MyBatis Plus 查询条件,并处理排序(默认按创建时间降序)。
- **数据传输对象 (DTO) & 视图对象 (VO)**:
-`com.yupi.project.model.dto.activationcode` 包下创建了以下 DTO
- `RedeemCodeRequest.java`: 用户兑换激活码请求 (包含 `code`)。
- `GenerateCodesRequest.java`: 管理员生成激活码请求 (包含 `count`, `value`, `expireTime`, `batchId`)。
- `ActivationCodeQueryRequest.java`: 管理员查询激活码请求,继承自 `PageRequest`,包含多种筛选条件。
-`com.yupi.project.model.vo` 包下创建了 `ActivationCodeVO.java`:
- 包含激活码详细信息,并为已使用的激活码增加了 `userName` 字段(用于显示使用者用户名),日期时间字段使用 `@JsonFormat` 格式化。
- **API 控制器 (Controller)**:
- 创建 `ActivationCodeController.java`,定义了以下 RESTful API 接口:
- `POST /api/activation-code/redeem`: 用户兑换激活码接口。需要用户登录。调用 `activationCodeService.redeemCode`
- `POST /api/activation-code/admin/generate`: 管理员批量生成激活码接口。使用 `@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)` 进行权限控制。调用 `activationCodeService.generateCodes`
- `POST /api/activation-code/admin/list/page`: 管理员分页查询激活码接口。使用 `@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)` 进行权限控制。调用 `activationCodeService.page``getQueryWrapper`,并实现 `Page<ActivationCode>``Page<ActivationCodeVO>` 的转换逻辑,包括填充 `userName`
- **依赖注入与注解**:
- 正确使用了 `@Service`, `@Resource`, `@RestController`, `@RequestMapping`, `@PostMapping`, `@RequestBody`, `@AuthCheck`, `@Transactional`, `@Slf4j` 等注解。
- **项目结构**: 相关类已放置在规范的包路径下。
- **待办与后续**:
- 编写 API 文档 (Swagger/OpenAPI)。
- 编写单元测试和集成测试。
- 前端页面对接和开发。
- 根据实际测试反馈进一步完善错误处理和日志。

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.3",
"antd": "^5.25.1",
"axios": "^1.9.0",
"next": "15.3.2",
"react": "^19.0.0",

View File

@@ -0,0 +1,382 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import {
Button, Card, Col, DatePicker, Form, Input, InputNumber, message,
Row, Select, Space, Table, Tabs, Typography, Modal
} from 'antd';
import { api } from '@/services/api';
import { BaseResponse } from '@/types/api';
import LoadingSpinner from '@/components/LoadingSpinner';
const { Title, Text } = Typography;
const { TabPane } = Tabs;
const { RangePicker } = DatePicker;
// --- TypeScript Interfaces ---
interface ActivationCodeVO {
id: number;
code: string;
value: number;
isUsed: number;
userId?: number | null;
userName?: string | null;
useTime?: string | null;
expireTime?: string | null;
batchId?: string | null;
createTime: string;
updateTime: string;
}
interface GenerateCodesRequest {
count: number;
value: number;
expireTime?: string | null; // ISO string or null
batchId?: string | null;
}
interface ActivationCodeQueryFormData extends ActivationCodeQueryRequest {
expireTimeRange?: [any, any]; // Using 'any' for now, ideally Dayjs or Moment tuple
createTimeRange?: [any, any];
}
interface ActivationCodeQueryRequest {
current?: number;
pageSize?: number;
code?: string;
isUsed?: number;
batchId?: string;
userId?: number;
valueMin?: number;
valueMax?: number;
expireTimeStart?: string;
expireTimeEnd?: string;
createTimeStart?: string;
createTimeEnd?: string;
sortField?: string;
sortOrder?: string;
}
interface Page<T> {
records: T[];
total: number;
current: number;
size: number;
}
const AdminActivationCodesPage = () => {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const [generateForm] = Form.useForm<GenerateCodesRequest>();
const [queryForm] = Form.useForm<ActivationCodeQueryFormData>();
const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [activationCodes, setActivationCodes] = useState<ActivationCodeVO[]>([]);
const [totalCodes, setTotalCodes] = useState(0);
const [codesLoading, setCodesLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
});
// 添加删除Modal的状态
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [currentCodeId, setCurrentCodeId] = useState<number | null>(null);
// 权限检查和重定向
useEffect(() => {
if (!authLoading) {
if (!isAuthenticated) {
router.replace('/login');
} else if (user?.role !== 'admin') {
message.error('您没有权限访问此页面。');
router.replace('/dashboard'); // 或者其他非管理员默认页
}
}
}, [authLoading, isAuthenticated, user, router]);
// --- Generate Codes Logic ---
const handleGenerateCodes = async (values: GenerateCodesRequest) => {
setIsGenerating(true);
setGeneratedCodes([]);
try {
const requestPayload = {
...values,
expireTime: values.expireTime ? (values.expireTime as any).toISOString() : null,
};
const response = await api.post<BaseResponse<string[]>>('/activation-code/admin/generate', requestPayload);
if (response.data.code === 0 && response.data.data) {
setGeneratedCodes(response.data.data);
message.success(`成功生成 ${response.data.data.length} 个激活码!`);
generateForm.resetFields();
fetchActivationCodes({ current: 1 });
} else {
message.error(response.data.message || '生成激活码失败');
}
} catch (error: any) {
message.error(error.response?.data?.message || error.message || '生成激活码时发生错误');
}
setIsGenerating(false);
};
// --- List Codes Logic ---
const fetchActivationCodes = useCallback(async (params: Partial<ActivationCodeQueryRequest> = {}) => {
setCodesLoading(true);
try {
const formValues = queryForm.getFieldsValue();
const queryParams: ActivationCodeQueryRequest = {
current: pagination.current,
pageSize: pagination.pageSize,
...formValues,
...params,
};
if (formValues.expireTimeRange && formValues.expireTimeRange.length === 2) {
queryParams.expireTimeStart = (formValues.expireTimeRange[0] as any).toISOString();
queryParams.expireTimeEnd = (formValues.expireTimeRange[1] as any).toISOString();
}
delete (queryParams as ActivationCodeQueryFormData).expireTimeRange;
if (formValues.createTimeRange && formValues.createTimeRange.length === 2) {
queryParams.createTimeStart = (formValues.createTimeRange[0] as any).toISOString();
queryParams.createTimeEnd = (formValues.createTimeRange[1] as any).toISOString();
}
delete (queryParams as ActivationCodeQueryFormData).createTimeRange;
const response = await api.post<BaseResponse<Page<ActivationCodeVO>>>('/activation-code/admin/list/page', queryParams);
if (response.data.code === 0 && response.data.data) {
setActivationCodes(response.data.data.records || []);
setTotalCodes(response.data.data.total || 0);
} else {
message.error(response.data.message || '获取激活码列表失败');
setActivationCodes([]);
setTotalCodes(0);
}
} catch (error: any) {
message.error(error.response?.data?.message || error.message || '获取激活码列表时发生错误');
setActivationCodes([]);
setTotalCodes(0);
}
setCodesLoading(false);
}, [pagination, queryForm]);
useEffect(() => {
if (isAuthenticated && user?.role === 'admin') {
fetchActivationCodes();
}
}, [fetchActivationCodes, isAuthenticated, user]);
const handleTableChange = (newPagination: any, filters: any, sorter: any) => {
const sortParams: Partial<ActivationCodeQueryRequest> = {};
if (sorter.field && sorter.order) {
sortParams.sortField = sorter.field as string;
sortParams.sortOrder = sorter.order;
}
const newPager = {
current: newPagination.current,
pageSize: newPagination.pageSize,
};
setPagination(newPager);
fetchActivationCodes({
...newPager,
...sortParams
});
};
const onQueryFinish = (values: ActivationCodeQueryFormData) => {
const newPager = {...pagination, current: 1};
setPagination(newPager);
fetchActivationCodes({current: 1});
}
const handleDeleteCode = (codeId: number) => {
console.log('handleDeleteCode called with ID:', codeId);
setCurrentCodeId(codeId);
setIsDeleteModalVisible(true);
console.log('Delete modal state set to visible');
};
// 处理删除确认
const handleDeleteConfirm = () => {
if (currentCodeId) {
console.log('Delete confirmed for ID:', currentCodeId);
// 实际执行删除的代码
api.post<BaseResponse<boolean>>(
`/activation-code/admin/delete`,
{ id: currentCodeId }
).then(response => {
if (response.data.code === 0 && response.data.data === true) {
message.success('激活码删除成功!');
fetchActivationCodes({ current: pagination.current }); // 刷新当前页
} else {
message.error(response.data.message || '删除失败');
}
}).catch(error => {
message.error(error.message || '删除激活码时发生网络错误');
});
}
setIsDeleteModalVisible(false);
};
// 处理取消
const handleDeleteCancel = () => {
console.log('Delete cancelled');
setIsDeleteModalVisible(false);
};
// --- Columns for Activation Codes Table ---
const columns: any[] = [
{ title: 'ID', dataIndex: 'id', key: 'id', sorter: true },
{ title: '激活码', dataIndex: 'code', key: 'code' },
{ title: '面值', dataIndex: 'value', key: 'value', sorter: true, render: (val: number) => `¥${val.toFixed(2)}` },
{
title: '状态', dataIndex: 'isUsed', key: 'isUsed', sorter: true,
render: (isUsed: number) => isUsed === 1 ? <Text type="danger">使</Text> : <Text type="success">使</Text>,
filters: [
{ text: '未使用', value: 0 },
{ text: '已使用', value: 1 },
],
},
{ title: '使用者ID', dataIndex: 'userId', key: 'userId', sorter: true },
{ title: '使用者', dataIndex: 'userName', key: 'userName' },
{ title: '使用时间', dataIndex: 'useTime', key: 'useTime', sorter: true, render: (time: string) => time ? new Date(time).toLocaleString() : '-' },
{ title: '过期时间', dataIndex: 'expireTime', key: 'expireTime', sorter: true, render: (time: string) => time ? new Date(time).toLocaleString() : '永不' },
{ title: '批次号', dataIndex: 'batchId', key: 'batchId' },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', sorter: true, defaultSortOrder: 'descend', render: (time: string) => new Date(time).toLocaleString() },
{
title: '操作',
key: 'action',
render: (text: any, record: ActivationCodeVO) => (
<Space size="middle">
<Button type="link" danger onClick={() => handleDeleteCode(record.id)}>
</Button>
</Space>
),
},
];
if (authLoading) return <LoadingSpinner />;
if (!isAuthenticated || user?.role !== 'admin') return null; // 或显示权限不足的组件
return (
<div style={{ padding: '24px' }}>
<Title level={2} style={{ marginBottom: '24px' }}></Title>
<Tabs defaultActiveKey="1">
<TabPane tab="生成激活码" key="1">
<Card title="批量生成激活码" style={{ marginBottom: 24 }}>
<Form form={generateForm} layout="vertical" onFinish={handleGenerateCodes}>
<Row gutter={16}>
<Col xs={24} sm={12} md={8}>
<Form.Item name="count" label="生成数量" rules={[{ required: true, message: '请输入生成数量' }]}>
<InputNumber min={1} max={1000} style={{ width: '100%' }} placeholder="例如: 100" />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item name="value" label="激活码面值 (元)" rules={[{ required: true, message: '请输入激活码面值' }]}>
<InputNumber min={0.01} step={0.01} style={{ width: '100%' }} placeholder="例如: 10.00" />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item name="expireTime" label="过期时间 (可选)">
<DatePicker showTime style={{ width: '100%' }} placeholder="选择日期和时间" format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item name="batchId" label="批次号 (可选)">
<Input placeholder="例如: spring_promo_2024" />
</Form.Item>
</Col>
</Row>
<Form.Item>
<Button type="primary" htmlType="submit" loading={isGenerating} disabled={isGenerating}>
{isGenerating ? '正在生成...' : '立即生成'}
</Button>
</Form.Item>
</Form>
{generatedCodes.length > 0 && (
<Card title="本次生成的激活码" type="inner" style={{ marginTop: 16 }}>
<Space direction="vertical" style={{width: '100%'}}>
{generatedCodes.map((code, index) => (
<Text copyable key={index}>{code}</Text>
))}
</Space>
<Text type="secondary" style={{marginTop: 8, display: 'block'}}> {generatedCodes.length} </Text>
</Card>
)}
</Card>
</TabPane>
<TabPane tab="管理激活码列表" key="2">
<Card title="筛选查询" style={{ marginBottom: 24 }}>
<Form form={queryForm} layout="vertical" onFinish={onQueryFinish}>
<Row gutter={16}>
<Col xs={24} sm={12} md={6}><Form.Item name="code" label="激活码"><Input placeholder="模糊查询" /></Form.Item></Col>
<Col xs={24} sm={12} md={6}>
<Form.Item name="isUsed" label="使用状态">
<Select allowClear placeholder="全部状态">
<Select.Option value={0}>使</Select.Option>
<Select.Option value={1}>使</Select.Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}><Form.Item name="batchId" label="批次号"><Input placeholder="精确查询" /></Form.Item></Col>
<Col xs={24} sm={12} md={6}><Form.Item name="userId" label="使用者ID"><InputNumber style={{ width: '100%' }} placeholder="精确查询" /></Form.Item></Col>
<Col xs={24} sm={12} md={6}><Form.Item name="valueMin" label="最小面值"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={24} sm={12} md={6}><Form.Item name="valueMax" label="最大面值"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={24} sm={12} md={6}><Form.Item name="expireTimeRange" label="过期时间范围">
<RangePicker showTime style={{ width: '100%' }} format="YYYY-MM-DD HH:mm:ss" />
</Form.Item></Col>
<Col xs={24} sm={12} md={6}><Form.Item name="createTimeRange" label="创建时间范围">
<RangePicker showTime style={{ width: '100%' }} format="YYYY-MM-DD HH:mm:ss" />
</Form.Item></Col>
</Row>
<Form.Item>
<Button type="primary" htmlType="submit" loading={codesLoading}></Button>
<Button style={{ marginLeft: 8 }} onClick={() => { queryForm.resetFields(); onQueryFinish({}); }}></Button>
</Form.Item>
</Form>
</Card>
<Table
columns={columns}
dataSource={activationCodes}
loading={codesLoading}
rowKey="id"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: totalCodes,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
}}
onChange={handleTableChange}
scroll={{ x: 'max-content' }}
/>
</TabPane>
</Tabs>
{/* 删除确认对话框 */}
<Modal
title="确认删除"
open={isDeleteModalVisible}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
okText="确认删除"
cancelText="取消"
okType="danger"
>
<p> (ID: {currentCodeId}) </p>
</Modal>
</div>
);
};
export default AdminActivationCodesPage;

View File

@@ -5,8 +5,9 @@ import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner';
import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings } from 'react-icons/fi';
import { api } from '@/utils/axios';
import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings, FiGift } from 'react-icons/fi';
import { api } from '@/services/api';
import { BaseResponse } from '@/types/api';
import { AxiosError, AxiosResponse } from 'axios';
// 匹配后端 AdminDashboardStatsVO
@@ -28,13 +29,6 @@ interface AdminStats {
// availableParkingSpots: number;
}
// 建议定义一个通用的 BaseResponse 接口
interface BackendBaseResponse<T> {
data: T;
message: string;
code?: number;
}
const AdminDashboardPage = () => {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
@@ -56,23 +50,22 @@ const AdminDashboardPage = () => {
if (isAuthenticated && user?.role === 'admin') {
setStatsLoading(true);
setStatsError(null);
api.get<BackendBaseResponse<AdminStats>>('/admin/stats/summary') // 使用 BackendBaseResponse
.then((response: AxiosResponse<BackendBaseResponse<AdminStats>>) => {
if (response.data && response.data.data) {
// 后端返回的 totalRevenue 对应前端接口的 totalRevenueToday
// 后端返回的 activeSessions 对应前端接口的 totalChargingSessionsToday
// 但为了保持一致性我们最好让前端AdminStats接口的字段名与后端VO的字段名一致
// 这里暂时直接使用后端返回的字段名,前端 JSX 部分也需要对应调整
api.get<BaseResponse<AdminStats>>('/admin/stats/summary')
.then((response: AxiosResponse<BaseResponse<AdminStats>>) => {
if (response.data && response.data.code === 0 && response.data.data) {
setAdminStats(response.data.data);
} else {
console.error("Failed to fetch admin stats or data is null/malformed:", response);
setStatsError("加载统计数据失败: 数据为空或格式错误。");
console.error("Failed to fetch admin stats or data is null/malformed:", response.data?.message);
setStatsError(response.data?.message || "加载统计数据失败: 数据为空或格式错误。");
setAdminStats(null);
}
})
.catch((error: AxiosError) => {
.catch((error: AxiosError<BaseResponse<unknown>>) => {
console.error("Failed to fetch admin stats:", error);
if (error.response) {
const backendMessage = error.response?.data?.message;
if (backendMessage) {
setStatsError(`加载统计数据失败: ${backendMessage}`);
} else if (error.response) {
setStatsError(`加载统计数据失败: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
setStatsError("加载统计数据失败: 未收到服务器响应。");
@@ -200,7 +193,6 @@ const AdminDashboardPage = () => {
description="查看所有充电会话记录、详情"
href="/admin/sessions"
icon={<FiList size={28}/>}
disabled={true}
/>
<NavCard
title="用户管理"
@@ -208,6 +200,12 @@ const AdminDashboardPage = () => {
href="/admin/user-management"
icon={<FiUserCheck size={28}/>}
/>
<NavCard
title="激活码管理"
description="生成、查询和管理激活码"
href="/admin/activation-codes"
icon={<FiGift size={28}/>}
/>
<NavCard
title="系统设置"
description="配置系统参数、费率等"

View File

@@ -5,8 +5,11 @@ import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner';
import { api, BaseResponse } from '@/utils/axios'; // Import the api instance
import { FiDollarSign, FiZap, FiClock, FiList, FiChevronRight, FiInfo, FiPower, FiAlertCircle } from 'react-icons/fi';
import { api } from '@/services/api'; // 新的API实例导入
import { BaseResponse } from '@/types/api'; // 新的BaseResponse导入
import { FiDollarSign, FiZap, FiClock, FiList, FiChevronRight, FiInfo, FiPower, FiAlertCircle, FiGift } from 'react-icons/fi';
import { Button, Modal, message } from 'antd'; // 确保Button和Modal已导入
import RedeemCodeForm from '@/components/RedeemCodeForm'; // 导入兑换表单组件
// Interface for active charging session (matches ChargingSessionVO fields used)
interface ActiveChargingSession {
@@ -26,7 +29,7 @@ interface UserDashboardStats {
}
export default function DashboardPage() {
const { user, isAuthenticated, isLoading: authLoading, logout } = useAuth();
const { user, isAuthenticated, isLoading: authLoading, logout, checkAuth } = useAuth();
const router = useRouter();
const [activeSession, setActiveSession] = useState<ActiveChargingSession | null | undefined>(undefined);
@@ -34,6 +37,7 @@ export default function DashboardPage() {
const [dataLoading, setDataLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isStopping, setIsStopping] = useState(false);
const [isRedeemModalVisible, setIsRedeemModalVisible] = useState(false); // Modal状态
useEffect(() => {
if (!authLoading && !isAuthenticated) {
@@ -51,44 +55,30 @@ export default function DashboardPage() {
setDataLoading(true);
setError(null);
try {
// 使用 Promise.allSettled 来处理部分请求失败的情况,如果需要
const [sessionResult, statsResult] = await Promise.all([
api.get<BaseResponse<ActiveChargingSession | null>>('/session/my/active'),
api.get<BaseResponse<UserDashboardStats>>('/user/stats/mine')
]);
if (sessionResult.data && sessionResult.data.code === 0) { // 假设 code 0 代表成功
console.log("Active session response:", sessionResult.data);
setActiveSession(sessionResult.data.data); // data 可以是 null
if (sessionResult.data && sessionResult.data.code === 0) {
setActiveSession(sessionResult.data.data);
} else {
// setActiveSession(null); // 或者根据后端错误信息设置
console.warn("Failed to fetch active session or no active session:", sessionResult.data?.message);
setActiveSession(null); // 如果没有活动会话后端data.data可能为null是正常情况
setActiveSession(null);
}
if (statsResult.data && statsResult.data.code === 0 && statsResult.data.data) {
console.log("User stats response:", statsResult.data.data);
// 检查后端返回的是monthlyCharges还是monthlySessions并进行适当转换
const statsData = statsResult.data.data;
if ('monthlyCharges' in statsData && statsData.monthlyCharges !== undefined) {
// 如果后端返回的是monthlyCharges字段映射到monthlySessions
setUserStats({
monthlySessions: statsData.monthlyCharges as number,
monthlySpending: statsData.monthlySpending
});
} else {
// 否则假定数据结构已匹配接口
setUserStats(statsData);
}
setUserStats({
monthlySessions: (statsData as any).monthlyCharges !== undefined ? (statsData as any).monthlyCharges : statsData.monthlySessions,
monthlySpending: statsData.monthlySpending
});
} else {
setUserStats(null);
console.error("Failed to fetch user stats:", statsResult.data?.message);
// setError(prev => prev ? prev + '\n' + (statsResult.data?.message || '获取用户统计失败') : (statsResult.data?.message || '获取用户统计失败'));
}
} catch (err: any) {
console.error("Error fetching dashboard data:", err);
const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败,请稍后再试。';
const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败';
setError(prevError => prevError ? `${prevError}\n${errorMessage}` : errorMessage);
setActiveSession(null);
setUserStats(null);
@@ -100,15 +90,14 @@ export default function DashboardPage() {
if (isAuthenticated && user?.role === 'user') {
fetchData();
} else if (!authLoading && !isAuthenticated) {
// Handle cases where user is not authenticated and not loading
setDataLoading(false); // Stop loading if not fetching
setDataLoading(false);
}
}, [fetchData, isAuthenticated, user, authLoading]);
// 直接从仪表盘停止充电
const handleStopCharging = async () => {
if (!activeSession || !activeSession.id) {
setError("无法停止充电:无效的会话信息。");
message.error("无法停止充电:无效的会话信息。"); // 使用 antd message
return;
}
setIsStopping(true);
@@ -116,14 +105,17 @@ export default function DashboardPage() {
try {
const response = await api.post<BaseResponse<any>>(`/session/stop`, { sessionId: activeSession.id });
if (response.data && response.data.code === 0) {
// 停止成功后重新获取数据
fetchData();
message.success('已成功发送停止充电请求。');
} else {
setError(response.data?.message || "停止充电请求失败");
message.error(response.data?.message || "停止充电请求失败");
}
} catch (err: any) {
console.error("Error stopping charging session:", err);
setError(err.response?.data?.message || err.message || "停止充电时发生错误");
const msg = err.response?.data?.message || err.message || "停止充电时发生错误";
setError(msg);
message.error(msg);
} finally {
setIsStopping(false);
}
@@ -168,19 +160,32 @@ export default function DashboardPage() {
return '--';
};
const handleRedeemSuccess = () => {
setIsRedeemModalVisible(false);
checkAuth(); // 刷新用户数据,尤其是余额
// fetchData(); // 也可以选择刷新整个仪表盘数据如果余额不通过checkAuth更新
message.success('激活码已成功兑换,余额已更新!');
};
return (
<div className="min-h-screen bg-gray-100 p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<header className="mb-8 flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-800"></h1>
{/* Logout button is in global layout, no need here if using that strategy */}
<Button
type="primary"
icon={<FiGift className="mr-2" />}
onClick={() => setIsRedeemModalVisible(true)}
>
</Button>
</header>
<div className="bg-white p-6 rounded-xl shadow-lg mb-8">
<h2 className="text-2xl font-semibold text-gray-700 mb-2">, {user.username}!</h2>
<p className="text-gray-600 mb-1"><span className="font-medium">ID:</span> {user.id}</p>
<p className="text-gray-600 mb-1"><span className="font-medium">:</span> {user.role === 'user' ? '普通用户' : user.role}</p>
<p className="text-gray-600"><span className="font-medium">:</span> ¥{user.balance?.toFixed(2) ?? 'N/A'}</p>
<p className="text-gray-600"><span className="font-medium">:</span> <span className="text-lg font-semibold text-green-600">¥{user.balance?.toFixed(2) ?? 'N/A'}</span></p>
</div>
{error && (
@@ -286,6 +291,20 @@ export default function DashboardPage() {
</Link>
</div>
</div>
<Modal
title="兑换激活码"
open={isRedeemModalVisible}
onCancel={() => setIsRedeemModalVisible(false)}
footer={null} // RedeemCodeForm 内部有自己的提交和取消逻辑
destroyOnClose // 关闭时销毁内部组件状态
>
<RedeemCodeForm
onSuccess={handleRedeemSuccess}
onCancel={() => setIsRedeemModalVisible(false)} // Modal 的取消按钮也调用此 handler
/>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React, { useState, ChangeEvent, FormEvent } from 'react';
import { Input, Button, Card, Typography, message } from 'antd';
import { useAuth } from '@/contexts/AuthContext';
import { api as apiService } from '@/services/api';
// 暂时在此处定义 BaseResponse因为 @/services/api.ts 未导出它
// 理想情况下它应该在全局类型定义文件中或与api实例一起导出
export interface BaseResponse<T> {
code: number;
data: T;
message?: string;
}
const { Title } = Typography;
const RedeemActivationCodePage = () => {
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const { isAuthenticated, user } = useAuth();
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setCode(e.target.value.trim());
setError(null);
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isAuthenticated) {
message.error('请先登录后再兑换激活码。');
return;
}
if (!code) {
setError('请输入激活码。');
message.error('请输入激活码。');
return;
}
setIsLoading(true);
setError(null);
try {
const response = await apiService.post<BaseResponse<boolean>>('/api/activation-code/redeem',
{ code }
);
if (response.data && response.data.code === 0 && response.data.data === true) {
message.success('激活码兑换成功!您的余额已更新。');
setCode('');
} else {
const errorMessage = response.data?.message || '兑换失败,请稍后再试。';
setError(errorMessage);
message.error(errorMessage);
}
} catch (err: any) {
console.error('Redeem code error:', err);
const errorMessage = err.response?.data?.message || err.message || '兑换过程中发生错误,请检查网络或联系管理员。';
setError(errorMessage);
message.error(errorMessage);
} finally {
setIsLoading(false);
}
};
return (
<div style={{ maxWidth: '500px', margin: '50px auto', padding: '20px' }}>
<Card bordered={false}>
<Title level={3} style={{ textAlign: 'center', marginBottom: '30px' }}>
</Title>
<form onSubmit={handleSubmit}>
<Input
placeholder="请输入您的激活码"
value={code}
onChange={handleInputChange}
size="large"
style={{ marginBottom: '20px' }}
disabled={isLoading || !isAuthenticated}
/>
{error && (
<Typography.Text type="danger" style={{ display: 'block', marginBottom: '10px' }}>
{error}
</Typography.Text>
)}
<Button
type="primary"
htmlType="submit"
loading={isLoading}
disabled={!isAuthenticated}
block
size="large"
>
{isLoading ? '兑换中...' : '立即兑换'}
</Button>
{!isAuthenticated && (
<Typography.Text type="warning" style={{ display: 'block', marginTop: '10px', textAlign: 'center' }}>
</Typography.Text>
)}
</form>
</Card>
</div>
);
};
export default RedeemActivationCodePage;

View File

@@ -0,0 +1,109 @@
import React, { useState, ChangeEvent, FormEvent } from 'react';
import { Input, Button, Typography, message } from 'antd';
import { useAuth } from '@/contexts/AuthContext';
import { api as apiService } from '@/services/api';
import { BaseResponse } from '@/types/api'; // 假设 BaseResponse 移到了这里
interface RedeemCodeFormProps {
onSuccess?: () => void; // 兑换成功后的回调
onCancel?: () => void; // 可选的取消/关闭回调
}
const RedeemCodeForm: React.FC<RedeemCodeFormProps> = ({ onSuccess, onCancel }) => {
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const { isAuthenticated, checkAuth } = useAuth(); // checkAuth 用于成功后刷新用户信息
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setCode(e.target.value.trim());
setError(null);
};
const handleSubmit = async (e?: FormEvent<HTMLFormElement>) => {
if (e) e.preventDefault();
if (!isAuthenticated) {
message.error('请先登录后再兑换激活码。');
return;
}
if (!code) {
setError('请输入激活码。');
message.error('请输入激活码。');
return;
}
setIsLoading(true);
setError(null);
try {
const response = await apiService.post<BaseResponse<boolean>>('/activation-code/redeem',
{ code }
);
console.log('Backend response for redeem:', response.data);
if (response.data && response.data.code === 0 && response.data.data === true) {
message.success('激活码兑换成功!您的余额已更新。');
setCode('');
await checkAuth(); // 刷新用户信息,特别是余额
if (onSuccess) onSuccess();
} else {
const errorMessage = response.data?.message || '兑换失败,请稍后再试。';
setError(errorMessage);
message.error(errorMessage);
}
} catch (err: any) {
console.error('Redeem code error:', err);
const errorMessage = err.response?.data?.message || err.message || '兑换过程中发生错误。';
setError(errorMessage);
message.error(errorMessage);
}
setIsLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<Input
placeholder="请输入您的激活码"
value={code}
onChange={handleInputChange}
size="large"
style={{ marginBottom: '20px' }}
disabled={isLoading || !isAuthenticated}
/>
{error && (
<Typography.Text type="danger" style={{ display: 'block', marginBottom: '10px' }}>
{error}
</Typography.Text>
)}
<Button
type="primary"
htmlType="submit"
loading={isLoading}
disabled={!isAuthenticated || !code.trim()}
block
size="large"
style={{ marginBottom: onCancel ? '10px' : '0' }} // 如果有取消按钮,给兑换按钮留点空间
>
{isLoading ? '兑换中...' : '立即兑换'}
</Button>
{onCancel && (
<Button
onClick={onCancel}
block
size="large"
disabled={isLoading}
>
</Button>
)}
{!isAuthenticated && (
<Typography.Text type="warning" style={{ display: 'block', marginTop: '10px', textAlign: 'center' }}>
</Typography.Text>
)}
</form>
);
};
export default RedeemCodeForm;

View File

@@ -44,6 +44,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
try {
const response = await api.get('/user/current');
if (response.data.code === 0 && response.data.data) {
console.log('Data from /user/current for setUser:', JSON.stringify(response.data.data));
setUser(response.data.data);
console.log('User authenticated:', response.data.data.username, 'Role:', response.data.data.role);
} else {

View File

@@ -0,0 +1,10 @@
/**
* 通用的后端响应体结构
*/
export interface BaseResponse<T> {
code: number; // 响应状态码0表示成功
data: T; // 泛型数据,可以是任何类型
message?: string; // 可选的消息说明
}
// 你可以在这里添加其他通用的API相关类型定义

View File

@@ -0,0 +1,161 @@
package com.yupi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yupi.project.annotation.AuthCheck;
import com.yupi.project.common.BaseResponse;
import com.yupi.project.common.DeleteRequest;
import com.yupi.project.common.ErrorCode;
import com.yupi.project.common.ResultUtils;
import com.yupi.project.constant.UserConstant;
import com.yupi.project.exception.BusinessException;
import com.yupi.project.exception.ThrowUtils;
import com.yupi.project.model.dto.activationcode.ActivationCodeQueryRequest;
import com.yupi.project.model.dto.activationcode.GenerateCodesRequest;
import com.yupi.project.model.dto.activationcode.RedeemCodeRequest;
import com.yupi.project.model.dto.common.IdRequest;
import com.yupi.project.model.entity.ActivationCode;
import com.yupi.project.model.entity.User;
import com.yupi.project.model.vo.ActivationCodeVO;
import com.yupi.project.service.ActivationCodeService;
import com.yupi.project.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/activation-code")
@Slf4j
public class ActivationCodeController {
@Resource
private ActivationCodeService activationCodeService;
@Resource
private UserService userService;
// region 用户操作
/**
* 用户兑换激活码
*
* @param redeemCodeRequest 激活码请求
* @param request HTTP请求
* @return 是否成功
*/
@PostMapping("/redeem")
public BaseResponse<Boolean> redeemActivationCode(@RequestBody RedeemCodeRequest redeemCodeRequest, HttpServletRequest request) {
if (redeemCodeRequest == null || redeemCodeRequest.getCode() == null || redeemCodeRequest.getCode().trim().isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码不能为空");
}
User loginUser = userService.getCurrentUser(request);
if (loginUser == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户未登录或登录已失效");
}
boolean result = activationCodeService.redeemCode(loginUser.getId(), redeemCodeRequest.getCode());
return ResultUtils.success(result);
}
// endregion
// region 管理员操作
/**
* 管理员批量生成激活码
*
* @param generateCodesRequest 生成请求
* @return 生成的激活码列表 (只返回code避免信息过多或根据需要返回VO)
*/
@PostMapping("/admin/generate")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<List<ActivationCodeVO>> generateActivationCodes(@RequestBody GenerateCodesRequest generateCodesRequest) {
if (generateCodesRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
if (generateCodesRequest.getCount() == null || generateCodesRequest.getCount() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "生成数量必须大于0");
}
if (generateCodesRequest.getValue() == null || generateCodesRequest.getValue().signum() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "面值必须大于0");
}
try {
List<ActivationCode> codes = activationCodeService.generateCodes(
generateCodesRequest.getCount(),
generateCodesRequest.getValue(),
generateCodesRequest.getExpireTime(),
generateCodesRequest.getBatchId()
);
List<ActivationCodeVO> voList = codes.stream()
.map(code -> {
ActivationCodeVO vo = new ActivationCodeVO();
BeanUtils.copyProperties(code, vo);
return vo;
})
.collect(Collectors.toList());
return ResultUtils.success(voList);
} catch (Exception e) {
log.error("Error generating activation codes", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, e.getMessage());
}
}
/**
* 管理员分页查询激活码
*
* @param activationCodeQueryRequest 查询条件
* @return 分页的激活码视图对象
*/
@PostMapping("/admin/list/page")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<ActivationCodeVO>> listActivationCodesByPage(@RequestBody ActivationCodeQueryRequest activationCodeQueryRequest) {
if (activationCodeQueryRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
long current = activationCodeQueryRequest.getCurrent();
long size = activationCodeQueryRequest.getPageSize();
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<ActivationCode> activationCodePage = activationCodeService.page(new Page<>(current, size),
activationCodeService.getQueryWrapper(activationCodeQueryRequest));
Page<ActivationCodeVO> activationCodeVOPage = new Page<>(activationCodePage.getCurrent(), activationCodePage.getSize(), activationCodePage.getTotal());
List<ActivationCodeVO> activationCodeVOList = activationCodePage.getRecords().stream().map(activationCode -> {
ActivationCodeVO activationCodeVO = new ActivationCodeVO();
BeanUtils.copyProperties(activationCode, activationCodeVO);
if (activationCode.getIsUsed() == 1 && activationCode.getUserId() != null) {
User user = userService.getById(activationCode.getUserId());
if (user != null) {
activationCodeVO.setUserName(user.getUsername());
}
}
return activationCodeVO;
}).collect(Collectors.toList());
activationCodeVOPage.setRecords(activationCodeVOList);
return ResultUtils.success(activationCodeVOPage);
}
/**
* 管理员删除激活码
*
* @param idRequest 包含激活码ID的请求体
* @param request HTTP请求 (用于权限校验等)
* @return 是否成功
*/
@PostMapping("/admin/delete")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> deleteActivationCode(@RequestBody IdRequest idRequest, HttpServletRequest request) {
if (idRequest == null || idRequest.getId() == null || idRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码ID不能为空");
}
boolean result = activationCodeService.deleteCode(idRequest.getId());
return ResultUtils.success(result);
}
// endregion
}

View File

@@ -0,0 +1,12 @@
package com.yupi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yupi.project.model.entity.ActivationCode;
/**
* @description 针对表【activation_code(激活码表)】的数据库操作Mapper
* @createDate 2024-08-04 10:00:00
* @Entity com.yupi.project.model.entity.ActivationCode
*/
public interface ActivationCodeMapper extends BaseMapper<ActivationCode> {
}

View File

@@ -0,0 +1,70 @@
package com.yupi.project.model.dto.activationcode;
import com.yupi.project.common.PageRequest;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 激活码查询请求
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class ActivationCodeQueryRequest extends PageRequest implements Serializable {
/**
* 激活码 (模糊匹配)
*/
private String code;
/**
* 是否已使用 (0 - 未使用, 1 - 已使用)
*/
private Integer isUsed;
/**
* 批次号
*/
private String batchId;
/**
* 使用者用户ID
*/
private Long userId;
/**
* 面值下限 (用于范围查询)
*/
private BigDecimal valueMin;
/**
* 面值上限 (用于范围查询)
*/
private BigDecimal valueMax;
/**
* 过期时间开始 (用于范围查询)
*/
private LocalDateTime expireTimeStart;
/**
* 过期时间结束 (用于范围查询)
*/
private LocalDateTime expireTimeEnd;
/**
* 创建时间开始 (用于范围查询)
*/
private LocalDateTime createTimeStart;
/**
* 创建时间结束 (用于范围查询)
*/
private LocalDateTime createTimeEnd;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,36 @@
package com.yupi.project.model.dto.activationcode;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 管理员生成激活码请求
*/
@Data
public class GenerateCodesRequest implements Serializable {
/**
* 生成数量
*/
private Integer count;
/**
* 激活码面值
*/
private BigDecimal value;
/**
* 过期时间 (可选, null 表示永不过期)
*/
private LocalDateTime expireTime;
/**
* 批次号 (可选, 用于追踪)
*/
private String batchId;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,19 @@
package com.yupi.project.model.dto.activationcode;
import lombok.Data;
import java.io.Serializable;
/**
* 用户兑换激活码请求
*/
@Data
public class RedeemCodeRequest implements Serializable {
/**
* 激活码
*/
private String code;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,13 @@
package com.yupi.project.model.dto.common;
import lombok.Data;
import java.io.Serializable;
/**
* 通用ID请求DTO
*/
@Data
public class IdRequest implements Serializable {
private Long id;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,80 @@
package com.yupi.project.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 激活码表实体类
*/
@TableName(value = "activation_code")
@Data
public class ActivationCode implements Serializable {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 激活码字符串,确保全局唯一
*/
private String code;
/**
* 激活码对应的面值(金额)
*/
private BigDecimal value;
/**
* 是否已使用0-未使用1-已使用
*/
private Integer isUsed;
/**
* 使用者用户ID (如果已使用)
*/
private Long userId;
/**
* 激活码使用时间 (如果已使用)
*/
private LocalDateTime useTime;
/**
* 激活码过期时间 (NULL表示永不过期)
*/
private LocalDateTime expireTime;
/**
* 生成批次号,方便管理员追踪管理
*/
private String batchId;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 逻辑删除标志0-未删除1-已删除
*/
@TableLogic
private Integer isDelete;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,76 @@
package com.yupi.project.model.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 激活码视图对象
*/
@Data
public class ActivationCodeVO implements Serializable {
/**
* 主键
*/
private Long id;
/**
* 激活码
*/
private String code;
/**
* 面值
*/
private BigDecimal value;
/**
* 是否已使用 (0 - 未使用, 1 - 已使用)
*/
private Integer isUsed;
/**
* 使用者用户ID (如果已使用)
*/
private Long userId;
/**
* 使用者用户名 (如果已使用, 需要额外查询填充)
*/
private String userName;
/**
* 使用时间 (如果已使用)
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime useTime;
/**
* 过期时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime expireTime;
/**
* 批次号
*/
private String batchId;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,58 @@
package com.yupi.project.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.yupi.project.model.entity.ActivationCode;
import com.yupi.project.model.dto.activationcode.ActivationCodeQueryRequest;
// TODO: Import Page, ActivationCodeVO, ActivationCodeQueryRequest when created
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 激活码服务接口
*/
public interface ActivationCodeService extends IService<ActivationCode> {
/**
* (管理员) 批量生成激活码。
*
* @param count 生成数量
* @param value 单个激活码的面值
* @param expireTime 过期时间 (null 表示永不过期)
* @param batchId 批次ID (可由调用者传入或在Service中生成)
* @return 生成的激活码列表
* @throws Exception 如果生成过程中发生错误
*/
List<ActivationCode> generateCodes(int count, BigDecimal value, LocalDateTime expireTime, String batchId) throws Exception;
/**
* (用户) 兑换激活码。
*
* @param currentUserId 当前登录用户的ID
* @param code 激活码字符串
* @return true 如果兑换成功,并增加了用户余额
* @throws com.yupi.project.exception.BusinessException 如果激活码无效、已使用、已过期或兑换失败
*/
boolean redeemCode(Long currentUserId, String code);
/**
* 构建查询条件
*
* @param activationCodeQueryRequest 查询请求
* @return QueryWrapper
*/
QueryWrapper<ActivationCode> getQueryWrapper(ActivationCodeQueryRequest activationCodeQueryRequest);
/**
* 根据ID删除激活码 (逻辑删除)
*
* @param codeId 激活码ID
* @return 是否删除成功
*/
boolean deleteCode(Long codeId);
// TODO: Add method for listing codes (admin)
// Page<ActivationCodeVO> listCodes(ActivationCodeQueryRequest queryRequest);
}

View File

@@ -0,0 +1,215 @@
package com.yupi.project.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yupi.project.common.ErrorCode;
import com.yupi.project.exception.BusinessException;
import com.yupi.project.mapper.ActivationCodeMapper;
import com.yupi.project.model.dto.activationcode.ActivationCodeQueryRequest;
import com.yupi.project.model.entity.ActivationCode;
import com.yupi.project.service.ActivationCodeService;
import com.yupi.project.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
@Slf4j
public class ActivationCodeServiceImpl extends ServiceImpl<ActivationCodeMapper, ActivationCode> implements ActivationCodeService {
@Resource
private UserService userService;
// 通常不直接注入Mapper而是通过继承的ServiceImpl的方法操作但如果需要复杂查询可以注入
// @Resource
// private ActivationCodeMapper activationCodeMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public List<ActivationCode> generateCodes(int count, BigDecimal value, LocalDateTime expireTime, String batchId) throws Exception {
if (count <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "生成数量必须大于0");
}
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码面值必须大于0");
}
List<ActivationCode> generatedCodes = new ArrayList<>();
String effectiveBatchId = (batchId == null || batchId.isEmpty()) ? UUID.randomUUID().toString() : batchId;
for (int i = 0; i < count; i++) {
ActivationCode activationCode = new ActivationCode();
// 生成唯一code的简单策略实际项目中可能需要更复杂的防碰撞机制或预生成库
String uniqueCode = UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
// TODO: 考虑code冲突的可能性虽然UUID冲突概率极低但严格来说需要循环检查或数据库唯一约束处理
// 在高并发下,先生成再批量插入,并处理唯一约束异常可能是更好的方式
activationCode.setCode(uniqueCode);
activationCode.setValue(value);
activationCode.setExpireTime(expireTime);
activationCode.setBatchId(effectiveBatchId);
activationCode.setIsUsed(0); // 0 for not used
activationCode.setIsDelete(0); // 0 for not deleted
// createTime and updateTime will be handled by DB default or MyBatis Plus fill strategy
generatedCodes.add(activationCode);
}
boolean saveBatchResult = this.saveBatch(generatedCodes);
if (!saveBatchResult) {
log.error("Failed to batch save activation codes for batchId: {}", effectiveBatchId);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "批量生成激活码失败");
}
log.info("Successfully generated {} activation codes with value {}, batchId: {}.", count, value, effectiveBatchId);
return generatedCodes;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean redeemCode(Long currentUserId, String code) {
if (currentUserId == null || currentUserId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的用户ID");
}
if (code == null || code.trim().isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码不能为空");
}
// 1. 查询激活码
QueryWrapper<ActivationCode> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("code", code.trim());
// is_delete = 0 会由 @TableLogic 自动处理
ActivationCode activationCode = this.getOne(queryWrapper);
if (activationCode == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "无效的激活码");
}
// 2. 检查是否已使用
if (activationCode.getIsUsed() == 1) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "此激活码已被使用");
}
// 3. 检查是否过期
if (activationCode.getExpireTime() != null && LocalDateTime.now().isAfter(activationCode.getExpireTime())) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "此激活码已过期");
}
// 4. 增加用户余额
boolean increaseSuccess = userService.increaseBalance(currentUserId, activationCode.getValue());
if (!increaseSuccess) {
// increaseBalance 内部应该会抛出异常如果失败如用户不存在或者返回false
log.error("Failed to increase balance for user {} while redeeming code {}", currentUserId, code);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "余额充值失败,请稍后再试");
}
// 5. 更新激活码状态
activationCode.setIsUsed(1);
activationCode.setUserId(currentUserId);
activationCode.setUseTime(LocalDateTime.now());
// updateTime 会自动更新
boolean updateResult = this.updateById(activationCode);
if (!updateResult) {
log.error("Failed to update activation code {} status after redeeming by user {}. Potential inconsistency!", code, currentUserId);
// 由于余额已增加,但激活码状态更新失败,这是一个危险状态。事务应回滚。
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新激活码状态失败,请联系客服");
}
log.info("User {} successfully redeemed activation code {}. Value: {}", currentUserId, code, activationCode.getValue());
return true;
}
@Override
public QueryWrapper<ActivationCode> getQueryWrapper(ActivationCodeQueryRequest activationCodeQueryRequest) {
QueryWrapper<ActivationCode> queryWrapper = new QueryWrapper<>();
if (activationCodeQueryRequest == null) {
return queryWrapper;
}
String code = activationCodeQueryRequest.getCode();
Integer isUsed = activationCodeQueryRequest.getIsUsed();
String batchId = activationCodeQueryRequest.getBatchId();
Long userId = activationCodeQueryRequest.getUserId();
BigDecimal valueMin = activationCodeQueryRequest.getValueMin();
BigDecimal valueMax = activationCodeQueryRequest.getValueMax();
LocalDateTime expireTimeStart = activationCodeQueryRequest.getExpireTimeStart();
LocalDateTime expireTimeEnd = activationCodeQueryRequest.getExpireTimeEnd();
LocalDateTime createTimeStart = activationCodeQueryRequest.getCreateTimeStart();
LocalDateTime createTimeEnd = activationCodeQueryRequest.getCreateTimeEnd();
String sortField = activationCodeQueryRequest.getSortField();
String sortOrder = activationCodeQueryRequest.getSortOrder();
// 拼接查询条件
if (StringUtils.isNotBlank(code)) {
queryWrapper.like("code", code);
}
if (isUsed != null) {
queryWrapper.eq("isUsed", isUsed);
}
if (StringUtils.isNotBlank(batchId)) {
queryWrapper.eq("batchId", batchId);
}
if (userId != null && userId > 0) {
queryWrapper.eq("userId", userId);
}
if (valueMin != null) {
queryWrapper.ge("value", valueMin);
}
if (valueMax != null) {
queryWrapper.le("value", valueMax);
}
if (expireTimeStart != null) {
queryWrapper.ge("expireTime", expireTimeStart);
}
if (expireTimeEnd != null) {
queryWrapper.le("expireTime", expireTimeEnd);
}
if (createTimeStart != null) {
queryWrapper.ge("createTime", createTimeStart);
}
if (createTimeEnd != null) {
queryWrapper.le("createTime", createTimeEnd);
}
// 默认按创建时间降序
if (StringUtils.isNotBlank(sortField)) {
queryWrapper.orderBy(true, "ascend".equalsIgnoreCase(sortOrder), sortField);
} else {
queryWrapper.orderByDesc("create_time");
}
return queryWrapper;
}
@Override
@Transactional
public boolean deleteCode(Long codeId) {
if (codeId == null || codeId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的激活码ID");
}
// 检查激活码是否存在这步可选因为removeById如果找不到也不会报错但返回false
ActivationCode existingCode = this.getById(codeId);
if (existingCode == null || existingCode.getIsDelete() == 1) { // 已经是逻辑删除状态
// 可以选择抛出未找到错误,或者认为已经是"已删除"状态返回true
// throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "激活码不存在或已被删除");
log.warn("Attempted to delete a non-existent or already deleted activation code with ID: {}", codeId);
return true; // 或者 false取决于业务定义这里认为已经是目标状态所以返回true
}
// 使用MyBatis Plus的逻辑删除
boolean result = this.removeById(codeId);
if (!result) {
// 这种情况理论上不应该发生,除非并发删除了或者 getById 和 removeById 之间状态变了
log.error("Failed to logically delete activation code with ID: {}. removeById returned false.", codeId);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除激活码失败");
}
log.info("Activation code with ID: {} logically deleted.", codeId);
return true;
}
}

View File

@@ -140,25 +140,51 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
public User getCurrentUser(HttpServletRequest request) {
// 优先从 SecurityContextHolder 获取认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated() && !(authentication.getPrincipal() instanceof String && authentication.getPrincipal().equals("anonymousUser"))) {
if (authentication != null && authentication.isAuthenticated() && !(authentication.getPrincipal() instanceof String && "anonymousUser".equals(authentication.getPrincipal()))) {
Object principal = authentication.getPrincipal();
if (principal instanceof User) {
return (User) principal; // principal 已经是 safetyUser
User userFromContext = (User) principal;
// 根据 Context 中的用户ID从数据库重新获取最新的用户信息
User latestUser = this.getById(userFromContext.getId());
if (latestUser != null && latestUser.getIsDeleted() == 0) { // 确保用户未被删除
// 返回脱敏后的最新用户信息
return getSafetyUser(latestUser);
} else {
// 如果根据ID查不到用户了例如被删除了则认为未登录或异常
// 清除可能无效的认证信息
SecurityContextHolder.clearContext();
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户状态异常或已失效,请重新登录");
}
} else if (principal instanceof org.springframework.security.core.userdetails.User) {
// 如果 principal 是 Spring SecurityUser (不太可能在这里,因为我们设置的是 safetyUser)
// 需要转换或重新查询
// For now, assume it's our User object based on login logic
// 如果Spring SecurityUser对象通常包含username
org.springframework.security.core.userdetails.User springUser = (org.springframework.security.core.userdetails.User) principal;
User userByUsername = this.getOne(new QueryWrapper<User>().eq("username", springUser.getUsername()));
if (userByUsername != null && userByUsername.getIsDeleted() == 0) {
return getSafetyUser(userByUsername);
} else {
SecurityContextHolder.clearContext();
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户状态异常或已失效,请重新登录");
}
}
}
// 如果 SecurityContextHolder 中没有,尝试从 session (旧逻辑,作为后备或移除)
// 如果 SecurityContextHolder 中没有有效信息,尝试从 session (旧逻辑,作为后备)
// 注意:如果完全依赖 Spring Security这部分可以考虑移除或调整
Object userObj = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
if (userObj instanceof User) {
// 最好在这里也验证一下数据库中的用户状态或者确保session中的信息足够可信
return (User) userObj;
User userFromSession = (User) userObj;
User latestUser = this.getById(userFromSession.getId());
if (latestUser != null && latestUser.getIsDeleted() == 0) {
// 如果session中的用户有效也最好将其信息同步到SecurityContext以保持一致性 (可选)
// recreateAuthenticationInSecurityContext(latestUser, request); // 辅助方法,如果需要
return getSafetyUser(latestUser);
}
// Session 中的用户信息无效
request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE);
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "会话已过期或无效,请重新登录");
}
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户未登录");
}
@Override