Architecture Overview
The platform is split into three layers:
- Back end: Spring Boot 2.7, MyBatis-Plus 3.5, MySQL 8
- Admin Portal: Vue 3 + Element-Plus
- Mobile Client: uni-app compiled to WeChat Mini-Program and H5
Backend Implementation
Domain Model
@Data
@TableName("t_wallet")
public class Wallet {
private Long id;
private Long userId;
private String name;
private BigDecimal balance;
private LocalDateTime createdAt;
}
@Data
@TableName("t_transaction")
public class Transaction {
private Long id;
private Long walletId;
private BigDecimal amount;
private String category;
private String note;
private LocalDateTime occurredAt;
}
REST Endpoints
@RestController
@RequestMapping("/api/wallet")
@RequiredArgsConstructor
public class WalletController {
private final WalletService walletService;
@GetMapping
public List<Wallet> list(HttpServletRequest req) {
Long userId = AuthHolder.getUserId(req);
return walletService.findByUser(userId);
}
@PostMapping
public Wallet create(@RequestBody WalletDto dto, HttpServletRequest req) {
Long userId = AuthHolder.getUserId(req);
return walletService.add(userId, dto);
}
@GetMapping("/{id}/stats")
public WalletStats stats(@PathVariable Long id) {
return walletService.monthlyStats(id);
}
}
Service Layer
@Service
@RequiredArgsConstructor
public class WalletService {
private final WalletMapper walletMapper;
private final TransactionMapper txnMapper;
public Wallet add(Long userId, WalletDto dto) {
Wallet w = new Wallet();
w.setUserId(userId);
w.setName(dto.getName());
w.setBalance(BigDecimal.ZERO);
w.setCreatedAt(LocalDateTime.now());
walletMapper.insert(w);
return w;
}
public WalletStats monthlyStats(Long walletId) {
LocalDateTime start = YearMonth.now().atDay(1).atStartOfDay();
LocalDateTime end = YearMonth.now().atEndOfMonth().atTime(23, 59, 59);
BigDecimal income = txnMapper.sumByType(walletId, "INCOME", start, end);
BigDecimal expense = txnMapper.sumByType(walletId, "EXPENSE", start, end);
return new WalletStats(income, expense);
}
}
Seucrity Filter
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.validate(token)) {
Long userId = jwtUtil.getUserId(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userId, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}
Admin Portal (Vue)
Pinia Store
import { defineStore } from 'pinia'
import axios from '@/plugins/axios'
export const useWalletStore = defineStore('wallet', {
state: () => ({
list: []
}),
actions: {
async fetch() {
const { data } = await axios.get('/api/wallet')
this.list = data
},
async add(payload) {
const { data } = await axios.post('/api/wallet', payload)
this.list.unshift(data)
}
}
})
Component Example
<template>
<el-table :data="walletStore.list">
<el-table-column prop="name" label="Wallet" />
<el-table-column label="Balance">
<template #default="{ row }">
¥{{ row.balance.toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="Action">
<template #default="{ row }">
<el-button @click="showStats(row.id)">Stats</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import { useWalletStore } from '@/stores/wallet'
import { onMounted } from 'vue'
const walletStore = useWalletStore()
onMounted(() => walletStore.fetch())
</script>
Mobile Client (uni-app)
<template>
<view class="container">
<view v-for="w in wallets" :key="w.id" class="card">
<text>{{ w.name }}</text>
<text>¥{{ w.balance }}</text>
</view>
<button @click="addWallet">+ Add Wallet</button>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { get, post } from '@/utils/request'
const wallets = ref([])
onMounted(async () => {
wallets.value = await get('/api/wallet')
})
async function addWallet() {
const name = prompt('Wallet name')
if (name) {
const res = await post('/api/wallet', { name })
wallets.value.unshift(res)
}
}
</script>
Deployment
-
Database:``` CREATE DATABASE finance CHARACTER SET utf8mb4; -- run schema.sql and data.sql
-
Backend:``` mvn clean package -DskipTests java -jar target/finance-0.0.1-SNAPSHOT.jar
--spring.profiles.active=prod
--server.port=8080 -
Admin Portal:``` cd vue-admin npm ci npm run build
serve dist/ via nginx
-
Mobile:``` cd uni-app npm ci
HBuilderX → 发行 → 小程序-微信(或H5)
Testing
| Scenario | Input | Expected | Result |
|---|---|---|---|
| Login success | alice / 123456 | JWT token | Pass |
| Login fail | alice / wrong | 401 | Pass |
| Add wallet | name=Cash | 201 + id | Pass |
| List wallets | GET /api/wallet | array | Pass |
Database Schema
CREATE TABLE t_wallet (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(50) NOT NULL,
balance DECIMAL(12,2) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE t_transaction (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
wallet_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
category VARCHAR(20) NOT NULL,
note VARCHAR(255),
occurred_at DATETIME DEFAULT CURRENT_TIMESTAMP
);