清理多余目录和文件
This commit is contained in:
parent
83c8410627
commit
c522b772f6
|
|
@ -1,359 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>甲辰管理后台</title>
|
|
||||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/axios@1/dist/axios.min.js"></script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
html, body, #root { min-height: 100vh; width: 100%; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; background: #1a1a2e; color: #fff; }
|
|
||||||
input, button, select, table, textarea { font-family: inherit; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script>
|
|
||||||
const { useState, useEffect } = React;
|
|
||||||
|
|
||||||
// ============== 工具函数 ==============
|
|
||||||
const formatDate = (date) => date ? new Date(date).replace('+08:00','+0800').toLocaleString('zh-CN') : '-';
|
|
||||||
const levelColors = { '黄金': '#f59e0b', '铂金': '#94a3b8', '钻石': '#3b82f6', '青铜': '#10b981' };
|
|
||||||
|
|
||||||
// ============== 登录组件 ==============
|
|
||||||
function Login({ onLogin }) {
|
|
||||||
const [username, setUsername] = useState('admin');
|
|
||||||
const [password, setPassword] = useState('admin123');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (!username || !password) { setError('请输入用户名和密码'); return; }
|
|
||||||
setLoading(true); setError('');
|
|
||||||
try {
|
|
||||||
const res = await axios.post('/api/auth/login', new URLSearchParams({ username, password }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
|
||||||
if (res.data.access_token) {
|
|
||||||
localStorage.setItem('adminToken', res.data.access_token);
|
|
||||||
onLogin({ username: username });
|
|
||||||
} else if (res.data.code === 0) {
|
|
||||||
localStorage.setItem('adminToken', res.data.data.token);
|
|
||||||
onLogin(res.data.data.user || { username: username });
|
|
||||||
} else { setError(res.data.message || '登录失败'); }
|
|
||||||
} catch (e) { setError('网络错误: ' + (e.message || '请检查后端服务')); }
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' } },
|
|
||||||
React.createElement('div', { style: { width: '360px', padding: '40px 30px', background: 'rgba(255,255,255,0.05)', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.1)' } },
|
|
||||||
React.createElement('h1', { style: { textAlign: 'center', marginBottom: '30px', fontSize: '24px' } }, '甲辰管理后台'),
|
|
||||||
React.createElement('input', { type: 'text', placeholder: '请输入管理员账号', value: username, onChange: e => setUsername(e.target.value), style: { width: '100%', padding: '14px', marginBottom: '16px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '15px' } }),
|
|
||||||
React.createElement('input', { type: 'password', placeholder: '请输入密码', value: password, onChange: e => setPassword(e.target.value), onKeyPress: e => e.key === 'Enter' && handleLogin(), style: { width: '100%', padding: '14px', marginBottom: '20px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '15px' } }),
|
|
||||||
error && React.createElement('div', { style: { color: '#f87171', marginBottom: '16px', textAlign: 'center' } }, error),
|
|
||||||
React.createElement('button', { onClick: handleLogin, disabled: loading, style: { width: '100%', padding: '14px', borderRadius: '8px', border: 'none', background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', color: '#fff', fontSize: '16px', cursor: loading ? 'not-allowed' : 'pointer' } }, loading ? '登录中...' : '登录')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 用户编辑弹窗 ==============
|
|
||||||
function UserEditModal({ user, onClose, onSave }) {
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
username: user.username || '',
|
|
||||||
email: user.email || '',
|
|
||||||
role: user.role || 'user',
|
|
||||||
user_code: user.user_code || '',
|
|
||||||
level: user.level || '',
|
|
||||||
points: user.points || 0,
|
|
||||||
balance: user.balance || 0,
|
|
||||||
totalAmount: user.totalAmount || 0,
|
|
||||||
aiCount: user.aiCount || 0,
|
|
||||||
searchCount: user.searchCount || 0,
|
|
||||||
loginCount: user.loginCount || 0,
|
|
||||||
phone: user.phone || ''
|
|
||||||
});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const updateData = {
|
|
||||||
username: form.username,
|
|
||||||
email: form.email,
|
|
||||||
role: form.role,
|
|
||||||
user_code: form.user_code,
|
|
||||||
level: form.level,
|
|
||||||
points: form.points,
|
|
||||||
balance: form.balance,
|
|
||||||
totalAmount: form.totalAmount,
|
|
||||||
aiCount: form.aiCount,
|
|
||||||
searchCount: form.searchCount
|
|
||||||
};
|
|
||||||
await axios.put('/api/admin/users/' + user.id, updateData, { headers: { Authorization: 'Bearer ' + token } });
|
|
||||||
onSave();
|
|
||||||
onClose();
|
|
||||||
} catch (e) { alert('保存失败: ' + e.message); }
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const field = (label, key, type = 'text') => React.createElement('div', { style: { marginBottom: '14px' } },
|
|
||||||
React.createElement('div', { style: { color: '#94a3b8', marginBottom: '6px', fontSize: '13px' } }, label),
|
|
||||||
type === 'checkbox'
|
|
||||||
? React.createElement('input', { type: 'checkbox', checked: form[key], onChange: e => setForm({...form, [key]: e.target.checked}), style: { width: '20px', height: '20px' } })
|
|
||||||
: type === 'select'
|
|
||||||
? React.createElement('select', { value: form[key], onChange: e => setForm({...form, [key]: e.target.value}), style: { width: '100%', padding: '10px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff' } },
|
|
||||||
React.createElement('option', { value: 'user' }, '普通用户'),
|
|
||||||
React.createElement('option', { value: 'admin' }, '管理员')
|
|
||||||
)
|
|
||||||
: React.createElement('input', { type: type, value: form[key], onChange: e => setForm({...form, [key]: type === 'number' ? (parseFloat(e.target.value) || 0) : e.target.value}), style: { width: '100%', padding: '10px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff' } })
|
|
||||||
);
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, overflow: 'auto', padding: '20px' } },
|
|
||||||
React.createElement('div', { style: { width: '520px', background: '#1e293b', borderRadius: '16px', padding: '28px', boxShadow: '0 20px 60px rgba(0,0,0,0.5)' } },
|
|
||||||
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' } },
|
|
||||||
React.createElement('h2', { fontSize: '20px' }, '✏️ 编辑用户'),
|
|
||||||
React.createElement('button', { onClick: onClose, style: { background: 'none', border: 'none', color: '#94a3b8', fontSize: '24px', cursor: 'pointer' } }, '×')
|
|
||||||
),
|
|
||||||
React.createElement('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' } },
|
|
||||||
field('用户名', 'username'),
|
|
||||||
field('用户编码', 'user_code'),
|
|
||||||
field('邮箱', 'email'),
|
|
||||||
field('手机号', 'phone'),
|
|
||||||
field('角色', 'role', 'select'),
|
|
||||||
field('会员等级', 'level'),
|
|
||||||
field('账户余额', 'balance', 'number'),
|
|
||||||
field('累计金额', 'totalAmount', 'number'),
|
|
||||||
field('积分', 'points', 'number'),
|
|
||||||
field('AI识别次数', 'aiCount', 'number'),
|
|
||||||
field('寻号次数', 'searchCount', 'number'),
|
|
||||||
field('登录次数', 'loginCount', 'number')
|
|
||||||
),
|
|
||||||
React.createElement('div', { style: { display: 'flex', gap: '12px', marginTop: '28px' } },
|
|
||||||
React.createElement('button', { onClick: handleSave, disabled: saving, style: { flex: 1, padding: '14px', borderRadius: '8px', border: 'none', background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', color: '#fff', fontSize: '15px', cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.7 : 1 } }, saving ? '保存中...' : '保存修改'),
|
|
||||||
React.createElement('button', { onClick: onClose, style: { flex: 1, padding: '14px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: '#fff', fontSize: '15px', cursor: 'pointer' } }, '取消')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 用户管理组件 ==============
|
|
||||||
function UserManager() {
|
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [editingUser, setEditingUser] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => { loadUsers(); }, []);
|
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const res = await axios.get('/api/admin/users', { headers: { Authorization: 'Bearer ' + token } });
|
|
||||||
if (res.data && res.data.length) setUsers(res.data);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (userId) => {
|
|
||||||
if (!confirm('确定删除该用户吗?')) return;
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
await axios.delete('/api/admin/users/' + userId, { headers: { Authorization: 'Bearer ' + token } });
|
|
||||||
loadUsers();
|
|
||||||
} catch (e) { alert('删除失败: ' + e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = users.filter(u =>
|
|
||||||
(u.username || '').includes(search) ||
|
|
||||||
(u.phone || '').includes(search) ||
|
|
||||||
(u.id || '').includes(search) ||
|
|
||||||
(u.user_code || '').includes(search)
|
|
||||||
);
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { padding: '24px' } },
|
|
||||||
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' } },
|
|
||||||
React.createElement('h2', { fontSize: '20px' }, '👥 用户管理'),
|
|
||||||
React.createElement('span', { color: '#94a3b8', fontSize: '14px' }, '共 ' + filtered.length + ' 用户')
|
|
||||||
),
|
|
||||||
React.createElement('div', { style: { position: 'relative', marginBottom: '20px' } },
|
|
||||||
React.createElement('input', { type: 'text', placeholder: '🔍 搜索用户名、手机号、用户编码...', value: search, onChange: e => setSearch(e.target.value), style: { width: '100%', padding: '14px 16px 14px 44px', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '14px' } }),
|
|
||||||
React.createElement('span', { position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', fontSize: '16px' }, '🔍')
|
|
||||||
),
|
|
||||||
loading ? React.createElement('div', { style: { textAlign: 'center', padding: '60px', color: '#94a3b8' } }, '加载中...') :
|
|
||||||
React.createElement('div', { style: { background: '#1e293b', borderRadius: '12px', overflow: 'hidden' } },
|
|
||||||
React.createElement('table', { style: { width: '100%', borderCollapse: 'collapse' } },
|
|
||||||
React.createElement('thead', null,
|
|
||||||
React.createElement('tr', { style: { background: 'rgba(0,0,0,0.3)' } },
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'left', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '用户'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'left', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '编码'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'left', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '联系方式'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'center', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '角色'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'center', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '等级'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'right', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '余额'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'right', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '积分'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'center', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '藏品'),
|
|
||||||
React.createElement('th', { style: { padding: '14px 12px', textAlign: 'center', fontSize: '12px', color: '#94a3b8', fontWeight: 'normal' } }, '操作')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement('tbody', null,
|
|
||||||
filtered.map(u => React.createElement('tr', { key: u.id, style: { borderBottom: '1px solid rgba(255,255,255,0.05)' } },
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px' } },
|
|
||||||
React.createElement('div', { fontWeight: '500' }, u.username || '-'),
|
|
||||||
React.createElement('div', { fontSize: '11px', color: '#64748b' }, u.email || '-')
|
|
||||||
),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', color: '#f59e0b', fontWeight: '500' } }, u.user_code || '-'),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', fontSize: '13px' } }, u.phone || '-'),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', textAlign: 'center' } }, u.role === 'admin' ? React.createElement('span', { style: { padding: '4px 10px', borderRadius: '4px', background: 'rgba(245,158,11,0.2)', color: '#f59e0b', fontSize: '12px' } }, '管理员') : '-'),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', textAlign: 'center' } }, u.level ? React.createElement('span', { style: { padding: '4px 10px', borderRadius: '4px', background: 'rgba(16,185,129,0.2)', color: levelColors[u.level] || '#10b981', fontSize: '12px' } }, u.level) : '-'),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', textAlign: 'right', color: u.balance > 0 ? '#10b981' : '#fff' } }, '¥' + (u.balance || 0)),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', textAlign: 'right' } }, u.points || 0),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', textAlign: 'center' } }, u.collectionCount || 0),
|
|
||||||
React.createElement('td', { style: { padding: '14px 12px', textAlign: 'center' } },
|
|
||||||
React.createElement('button', { onClick: () => setEditingUser(u), style: { marginRight: '8px', padding: '6px 14px', borderRadius: '6px', border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: '12px' } }, '编辑'),
|
|
||||||
React.createElement('button', { onClick: () => handleDelete(u.id), style: { padding: '6px 14px', borderRadius: '6px', border: '1px solid #ef4444', background: 'transparent', color: '#ef4444', cursor: 'pointer', fontSize: '12px' } }, '删除')
|
|
||||||
)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
editingUser && React.createElement(UserEditModal, { user: editingUser, onClose: () => setEditingUser(null), onSave: loadUsers })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 统计分析组件 ==============
|
|
||||||
function StatsManager() {
|
|
||||||
const [stats, setStats] = useState({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => { loadStats(); }, []);
|
|
||||||
|
|
||||||
const loadStats = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const res = await axios.get('/api/admin/users/stats', { headers: { Authorization: 'Bearer ' + token } });
|
|
||||||
if (res.data) setStats(res.data);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ label: '用户总数', value: stats.totalUsers || 0, color: '#3b82f6', icon: '👥' },
|
|
||||||
{ label: '藏品总数', value: stats.totalItems || 0, color: '#10b981', icon: '📚' },
|
|
||||||
{ label: '账户总余额', value: '¥' + (stats.totalBalance || 0).toFixed(2), color: '#f59e0b', icon: '💰' },
|
|
||||||
{ label: '会员人数', value: stats.totalMembers || 0, color: '#8b5cf6', icon: '⭐' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { padding: '24px' } },
|
|
||||||
React.createElement('h2', { fontSize: '20px', marginBottom: '24px' }, '📊 统计分析概览'),
|
|
||||||
loading ? React.createElement('div', { textAlign: 'center', padding: '60px', color: '#94a3b8' }, '加载中...') :
|
|
||||||
React.createElement('div', { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px' },
|
|
||||||
statCards.map((s, i) => React.createElement('div', { key: i, style: { padding: '24px', background: '#1e293b', borderRadius: '16px', border: '1px solid ' + s.color + '30' } },
|
|
||||||
React.createElement('div', { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' },
|
|
||||||
React.createElement('span', { fontSize: '28px' }, s.icon),
|
|
||||||
React.createElement('span', { color: '#94a3b8', fontSize: '14px' }, s.label)
|
|
||||||
),
|
|
||||||
React.createElement('div', { fontSize: '36px', fontWeight: 'bold', color: s.color } }, s.value)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 信息发布组件 ==============
|
|
||||||
function InfoPublish() {
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [type, setType] = useState('notice');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [msg, setMsg] = useState('');
|
|
||||||
|
|
||||||
const handlePublish = async () => {
|
|
||||||
if (!title || !content) { setMsg('请填写标题和内容'); return; }
|
|
||||||
setSending(true); setMsg('');
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const res = await axios.post('/api/information/publish', { title, content, info_type: type }, { headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } });
|
|
||||||
if (res.data && res.data.id) { setMsg('发布成功!'); setTitle(''); setContent(''); }
|
|
||||||
else { setMsg(res.data?.message || '发布失败'); }
|
|
||||||
} catch (e) { setMsg('发布失败: ' + e.message); }
|
|
||||||
setSending(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { padding: '24px' } },
|
|
||||||
React.createElement('h2', { fontSize: '20px', marginBottom: '24px' }, '📢 发布信息'),
|
|
||||||
React.createElement('div', { background: '#1e293b', borderRadius: '16px', padding: '24px' } },
|
|
||||||
React.createElement('div', { marginBottom: '16px' },
|
|
||||||
React.createElement('div', { color: '#94a3b8', marginBottom: '8px', fontSize: '14px' }, '信息类型'),
|
|
||||||
React.createElement('select', { value: type, onChange: e => setType(e.target.value), style: { width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '14px' } },
|
|
||||||
React.createElement('option', { value: 'notice' }, '📌 系统通知'),
|
|
||||||
React.createElement('option', { value: 'deal' }, '💹 成交数据')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement('div', { marginBottom: '16px' },
|
|
||||||
React.createElement('div', { color: '#94a3b8', marginBottom: '8px', fontSize: '14px' }, '标题'),
|
|
||||||
React.createElement('input', { type: 'text', placeholder: '请输入标题', value: title, onChange: e => setTitle(e.target.value), style: { width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '14px' } })
|
|
||||||
),
|
|
||||||
React.createElement('div', { marginBottom: '20px' },
|
|
||||||
React.createElement('div', { color: '#94a3b8', marginBottom: '8px', fontSize: '14px' }, '内容'),
|
|
||||||
React.createElement('textarea', { placeholder: '请输入内容...', value: content, onChange: e => setContent(e.target.value), rows: 8, style: { width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '14px', resize: 'vertical' } })
|
|
||||||
),
|
|
||||||
msg && React.createElement('div', { color: msg.includes('成功') ? '#10b981' : '#f87171', marginBottom: '16px', padding: '12px', borderRadius: '8px', background: msg.includes('成功') ? 'rgba(16,185,129,0.1)' : 'rgba(248,113,113,0.1)' }, msg),
|
|
||||||
React.createElement('button', { onClick: handlePublish, disabled: sending, style: { padding: '14px 32px', borderRadius: '8px', border: 'none', background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', color: '#fff', fontSize: '15px', cursor: sending ? 'not-allowed' : 'pointer', opacity: sending ? 0.7 : 1 } }, sending ? '发布中...' : '🚀 发布')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 主应用 ==============
|
|
||||||
function App() {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
const [activeTab, setActiveTab] = useState('users');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const userStr = localStorage.getItem('adminUser');
|
|
||||||
if (token && userStr) {
|
|
||||||
try { setUser(JSON.parse(userStr)); } catch (e) {}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
localStorage.removeItem('adminToken');
|
|
||||||
localStorage.removeItem('adminUser');
|
|
||||||
setUser(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) return React.createElement(Login, { onLogin: (u) => { localStorage.setItem('adminUser', JSON.stringify(u)); setUser(u); } });
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'users', label: '用户管理', icon: '👥' },
|
|
||||||
{ id: 'stats', label: '统计分析', icon: '📊' },
|
|
||||||
{ id: 'publish', label: '信息发布', icon: '📢' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { minHeight: '100vh', background: '#1a1a2e' } },
|
|
||||||
React.createElement('div', { style: { padding: '20px 28px', background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid rgba(255,255,255,0.1)' } },
|
|
||||||
React.createElement('h1', { fontSize: '22px', fontWeight: '600' } }, '🐉 甲辰管理后台'),
|
|
||||||
React.createElement('div', { display: 'flex', alignItems: 'center', gap: '16px' },
|
|
||||||
React.createElement('span', { color: '#94a3b8', fontSize: '14px' }, '👤 ' + (user.username || '管理员')),
|
|
||||||
React.createElement('button', { onClick: handleLogout, style: { padding: '8px 16px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '13px' } }, '退出')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement('div', { display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.1)', padding: '0 28px' },
|
|
||||||
tabs.map(tab => React.createElement('div', { key: tab.id, onClick: () => setActiveTab(tab.id), style: { padding: '16px 24px', cursor: 'pointer', borderBottom: activeTab === tab.id ? '3px solid #f59e0b' : '3px solid transparent', color: activeTab === tab.id ? '#fff' : '#94a3b8', fontSize: '14px', fontWeight: activeTab === tab.id ? '500' : 'normal' } }, tab.icon + ' ' + tab.label))
|
|
||||||
),
|
|
||||||
React.createElement('div', { minHeight: 'calc(100vh - 130px)' },
|
|
||||||
activeTab === 'users' && React.createElement(UserManager),
|
|
||||||
activeTab === 'stats' && React.createElement(StatsManager),
|
|
||||||
activeTab === 'publish' && React.createElement(InfoPublish)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(React.createElement(App));
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"name": "admin-standalone",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"axios": "^1.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
|
||||||
"vite": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import axios from 'axios'
|
|
||||||
import Users from './pages/Users'
|
|
||||||
|
|
||||||
const API_BASE = ''
|
|
||||||
|
|
||||||
// 登录页面
|
|
||||||
function Login({ onLogin }) {
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (!username || !password) { setError('请输入用户名和密码'); return }
|
|
||||||
setLoading(true); setError('')
|
|
||||||
try {
|
|
||||||
const res = await axios.post(`${API_BASE}/api/auth/login`,
|
|
||||||
new URLSearchParams({ username, password }),
|
|
||||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
||||||
)
|
|
||||||
if (res.data.access_token) {
|
|
||||||
localStorage.setItem('adminToken', res.data.access_token)
|
|
||||||
localStorage.setItem('adminUser', JSON.stringify(res.data))
|
|
||||||
onLogin(res.data)
|
|
||||||
} else { setError(res.data.detail || '登录失败') }
|
|
||||||
} catch (e) { setError('网络错误') }
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' }}>
|
|
||||||
<div style={{ width: '360px', padding: '40px 30px', background: 'rgba(255,255,255,0.05)', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
<h1 style={{ textAlign: 'center', marginBottom: '30px', fontSize: '24px' }}>甲辰管理后台</h1>
|
|
||||||
<input type="text" placeholder="管理员账号" value={username} onChange={e => setUsername(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '14px 16px', marginBottom: '16px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '15px' }} />
|
|
||||||
<input type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)} onKeyPress={e => e.key === 'Enter' && handleLogin()}
|
|
||||||
style={{ width: '100%', padding: '14px 16px', marginBottom: '20px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '15px' }} />
|
|
||||||
{error && <div style={{ color: '#f87171', marginBottom: '16px', textAlign: 'center' }}>{error}</div>}
|
|
||||||
<button onClick={handleLogin} disabled={loading}
|
|
||||||
style={{ width: '100%', padding: '14px', borderRadius: '8px', border: 'none', background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', color: '#fff', fontSize: '16px', cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}>
|
|
||||||
{loading ? '登录中...' : '登录'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [user, setUser] = useState(null)
|
|
||||||
const [activeTab, setActiveTab] = useState('users')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = localStorage.getItem('adminToken')
|
|
||||||
const userStr = localStorage.getItem('adminUser')
|
|
||||||
if (token && userStr) {
|
|
||||||
try { setUser(JSON.parse(userStr)) } catch (e) {}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
localStorage.removeItem('adminToken')
|
|
||||||
localStorage.removeItem('adminUser')
|
|
||||||
setUser(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return <Login onLogin={setUser} />
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ key: 'users', label: '👥 用户管理', icon: '👥' },
|
|
||||||
{ key: 'stats', label: '📊 统计分析', icon: '📊' },
|
|
||||||
{ key: 'info', label: '📝 资讯管理', icon: '📝' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', background: '#0f172a', color: '#fff', paddingBottom: '80px' }}>
|
|
||||||
{/* 顶部栏 */}
|
|
||||||
<div style={{ background: '#1e293b', padding: '16px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #334155' }}>
|
|
||||||
<h1 style={{ margin: 0, fontSize: '18px', fontWeight: '600' }}>甲辰管理后台</h1>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<span style={{ color: '#94a3b8', fontSize: '14px' }}>{user.username || user.name || '管理员'}</span>
|
|
||||||
<button onClick={handleLogout} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid #334155', background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '13px' }}>退出</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 主内容 */}
|
|
||||||
<div style={{ padding: '0' }}>
|
|
||||||
{activeTab === 'users' && <Users />}
|
|
||||||
{activeTab === 'stats' && <div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>统计分析功能开发中...</div>}
|
|
||||||
{activeTab === 'info' && <div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>资讯管理功能开发中...</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部导航 */}
|
|
||||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: '#1e293b', borderTop: '1px solid #334155', padding: '8px 16px', display: 'flex', justifyContent: 'space-around', zIndex: 100 }}>
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<div key={tab.key} onClick={() => setActiveTab(tab.key)}
|
|
||||||
style={{ padding: '12px 20px', borderRadius: '10px', background: activeTab === tab.key ? '#3b82f6' : 'transparent',
|
|
||||||
color: activeTab === tab.key ? '#fff' : '#94a3b8', cursor: 'pointer', fontSize: '14px', fontWeight: '500' }}>
|
|
||||||
{tab.label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
html, body, #root {
|
|
||||||
min-height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
input, textarea, select, button {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
)
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function Users() {
|
|
||||||
const [users, setUsers] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
|
||||||
const [editingUser, setEditingUser] = useState(null)
|
|
||||||
const [newUser, setNewUser] = useState({ username: '', password: '', email: '', role: 'user' })
|
|
||||||
|
|
||||||
const API_BASE = localStorage.getItem('API_BASE') || ''
|
|
||||||
|
|
||||||
useEffect(() => { fetchUsers() }, [])
|
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken')
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/users?page=1&limit=100`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('获取用户列表失败')
|
|
||||||
const data = await res.json()
|
|
||||||
setUsers(data)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteUser = async (userId, username) => {
|
|
||||||
if (!confirm(`确定要删除用户 "${username}" 吗?`)) return
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken')
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/users/${userId}`, {
|
|
||||||
method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('删除失败')
|
|
||||||
fetchUsers()
|
|
||||||
} catch (err) { alert(err.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddUser = async () => {
|
|
||||||
if (!newUser.username || !newUser.password) { alert('用户名和密码为必填项'); return }
|
|
||||||
const payload = { ...newUser }
|
|
||||||
if (!payload.email) delete payload.email
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken')
|
|
||||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('添加失败')
|
|
||||||
alert('添加成功!')
|
|
||||||
setShowAddModal(false)
|
|
||||||
setNewUser({ username: '', password: '', email: '', role: 'user' })
|
|
||||||
fetchUsers()
|
|
||||||
} catch (err) { alert(err.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditUser = async () => {
|
|
||||||
if (!editingUser.username) { alert('用户名不能为空'); return }
|
|
||||||
if (editingUser.newPassword || editingUser.confirmPassword) {
|
|
||||||
if (!editingUser.newPassword || !editingUser.confirmPassword) { alert('请填写完整密码信息'); return }
|
|
||||||
if (editingUser.newPassword !== editingUser.confirmPassword) { alert('两次输入的密码不一致'); return }
|
|
||||||
if (editingUser.newPassword.length < 6) { alert('密码至少 6 个字符'); return }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken')
|
|
||||||
const updateData = { username: editingUser.username, email: editingUser.email, role: editingUser.role }
|
|
||||||
if (editingUser.newPassword && editingUser.newPassword.trim()) updateData.password = editingUser.newPassword
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/users/${editingUser.id}`, {
|
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
||||||
body: JSON.stringify(updateData)
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('更新失败')
|
|
||||||
alert('更新成功!')
|
|
||||||
setEditingUser(null)
|
|
||||||
fetchUsers()
|
|
||||||
} catch (err) { alert(err.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div style={{ padding: '20px', color: '#fff' }}>加载中...</div>
|
|
||||||
if (error) return <div style={{ padding: '20px', color: '#ef4444' }}>⚠️ {error}</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', padding: '20px', paddingBottom: '80px' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
||||||
<h1 style={{ color: '#fbbf24', fontSize: '24px', fontWeight: '700' }}>⚙️ 用户管理</h1>
|
|
||||||
<button onClick={() => setShowAddModal(true)} style={{ padding: '10px 20px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>➕ 添加用户</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户列表 */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{users.map(user => (
|
|
||||||
<div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold' }}>{user.username}</div>
|
|
||||||
<div style={{ padding: '2px 8px', borderRadius: '4px', background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(148, 163, 184, 0.2)', color: user.role === 'admin' ? '#10b981' : '#94a3b8', fontSize: '12px' }}>
|
|
||||||
{user.role === 'admin' ? '👑 管理员' : user.role === 'editor' ? '📝 信息员' : '👤 用户'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '4px' }}>📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div>
|
|
||||||
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold' }}>{user.collectionCount || 0}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
<div style={{ color: '#64748b', fontSize: '12px' }}>注册时间:{user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : '-'}</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button onClick={() => setEditingUser({ ...user })} style={{ padding: '6px 12px', borderRadius: '6px', border: 'none', background: 'rgba(59, 130, 246, 0.2)', color: '#3b82f6', cursor: 'pointer', fontSize: '12px' }}>✏️ 编辑</button>
|
|
||||||
{user.role !== 'admin' && (
|
|
||||||
<button onClick={() => handleDeleteUser(user.id, user.username)} style={{ padding: '6px 12px', borderRadius: '6px', border: 'none', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', cursor: 'pointer', fontSize: '12px' }}>🗑️ 删除</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{users.length === 0 && <div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>暂无用户数据</div>}
|
|
||||||
|
|
||||||
{/* 添加用户模态框 */}
|
|
||||||
{showAddModal && (
|
|
||||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '90%', maxWidth: '400px' }}>
|
|
||||||
<h2 style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold', marginBottom: '20px' }}>添加用户</h2>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>用户名 *</label>
|
|
||||||
<input type="text" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>密码 *</label>
|
|
||||||
<input type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>邮箱</label>
|
|
||||||
<input type="email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
|
|
||||||
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })} style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}>
|
|
||||||
<option value="user">普通用户</option>
|
|
||||||
<option value="editor">信息员</option>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<button onClick={() => setShowAddModal(false)} style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}>取消</button>
|
|
||||||
<button onClick={handleAddUser} style={{ flex: 1, padding: '12px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}>确定</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 编辑用户模态框 */}
|
|
||||||
{editingUser && (
|
|
||||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
|
||||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '90%', maxWidth: '500px', maxHeight: '90vh', overflowY: 'auto' }}>
|
|
||||||
<h2 style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold', marginBottom: '20px' }}>编辑用户</h2>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
|
||||||
<h3 style={{ color: '#fbbf24', fontSize: '15px', fontWeight: 'bold', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid rgba(251, 191, 36, 0.3)' }}>📋 基本信息</h3>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>用户名</label>
|
|
||||||
<input type="text" value={editingUser.username} onChange={(e) => setEditingUser({ ...editingUser, username: e.target.value })} style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>邮箱</label>
|
|
||||||
<input type="email" value={editingUser.email || ''} onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value })} style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
|
|
||||||
<select value={editingUser.role} onChange={(e) => setEditingUser({ ...editingUser, role: e.target.value })} style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}>
|
|
||||||
<option value="user">普通用户</option>
|
|
||||||
<option value="editor">信息员</option>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
|
||||||
<h3 style={{ color: '#fbbf24', fontSize: '15px', fontWeight: 'bold', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid rgba(251, 191, 36, 0.3)' }}>🔐 修改密码(可选)</h3>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>新密码 <span style={{ color: '#64748b', fontSize: '12px' }}>(留空则不修改)</span></label>
|
|
||||||
<input type="password" value={editingUser.newPassword || ''} onChange={(e) => setEditingUser({ ...editingUser, newPassword: e.target.value })} placeholder="请输入新密码(至少 6 位)" style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>确认新密码</label>
|
|
||||||
<input type="password" value={editingUser.confirmPassword || ''} onChange={(e) => setEditingUser({ ...editingUser, confirmPassword: e.target.value })} placeholder="请再次输入新密码" style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<button onClick={() => setEditingUser(null)} style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}>取消</button>
|
|
||||||
<button onClick={handleEditUser} style={{ flex: 1, padding: '12px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}>确定</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
build: {
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 3001,
|
|
||||||
host: '0.0.0.0'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>甲辰管理后台</title>
|
|
||||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/axios@1/dist/axios.min.js"></script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
html, body, #root { min-height: 100vh; width: 100%; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; background: #1a1a2e; color: #fff; }
|
|
||||||
input, button { font-family: inherit; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script>
|
|
||||||
const { useState, useEffect } = React;
|
|
||||||
|
|
||||||
function Login({ onLogin }) {
|
|
||||||
const [username, setUsername] = useState('admin');
|
|
||||||
const [password, setPassword] = useState('admin123');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (!username || !password) { setError('请输入用户名和密码'); return; }
|
|
||||||
setLoading(true); setError('');
|
|
||||||
try {
|
|
||||||
const res = await axios.post('/api/auth/login', new URLSearchParams({ username, password }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
|
||||||
if (res.data.access_token) {
|
|
||||||
localStorage.setItem('adminToken', res.data.access_token);
|
|
||||||
onLogin({ username: username });
|
|
||||||
} else if (res.data.code === 0) {
|
|
||||||
localStorage.setItem('adminToken', res.data.data.token);
|
|
||||||
onLogin(res.data.data.user || { username: username });
|
|
||||||
} else { setError(res.data.message || '登录失败'); }
|
|
||||||
} catch (e) { setError('网络错误: ' + (e.message || '请检查后端服务')); }
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' } },
|
|
||||||
React.createElement('div', { style: { width: '360px', padding: '40px 30px', background: 'rgba(255,255,255,0.05)', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.1)' } },
|
|
||||||
React.createElement('h1', { style: { textAlign: 'center', marginBottom: '30px', fontSize: '24px' } }, '甲辰管理后台'),
|
|
||||||
React.createElement('input', { type: 'text', placeholder: '请输入管理员账号', value: username, onChange: e => setUsername(e.target.value), style: { width: '100%', padding: '14px', marginBottom: '16px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '15px' } }),
|
|
||||||
React.createElement('input', { type: 'password', placeholder: '请输入密码', value: password, onChange: e => setPassword(e.target.value), onKeyPress: e => e.key === 'Enter' && handleLogin(), style: { width: '100%', padding: '14px', marginBottom: '20px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '15px' } }),
|
|
||||||
error && React.createElement('div', { style: { color: '#f87171', marginBottom: '16px', textAlign: 'center' } }, error),
|
|
||||||
React.createElement('button', { onClick: handleLogin, disabled: loading, style: { width: '100%', padding: '14px', borderRadius: '8px', border: 'none', background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', color: '#fff', fontSize: '16px', cursor: loading ? 'not-allowed' : 'pointer' } }, loading ? '登录中...' : '登录')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const userStr = localStorage.getItem('adminUser');
|
|
||||||
if (token && userStr) {
|
|
||||||
try { setUser(JSON.parse(userStr)); } catch (e) {}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
localStorage.removeItem('adminToken');
|
|
||||||
localStorage.removeItem('adminUser');
|
|
||||||
setUser(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) return React.createElement(Login, { onLogin: (u) => { localStorage.setItem('adminUser', JSON.stringify(u)); setUser(u); } });
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { minHeight: '100vh', background: '#1a1a2e', color: '#fff', padding: '20px' } },
|
|
||||||
React.createElement('h1', null, '甲辰管理后台 - 登录成功'),
|
|
||||||
React.createElement('p', null, '欢迎 ', user.username || '管理员'),
|
|
||||||
React.createElement('button', { onClick: handleLogout, style: { marginTop: '20px', padding: '10px 20px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: '#fff', cursor: 'pointer' } }, '退出')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(React.createElement(App));
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>甲辰管理后台</title>
|
|
||||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/axios@1/dist/axios.min.js"></script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
html, body, #root { min-height: 100vh; width: 100%; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; background: #1a1a2e; color: #fff; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script>
|
|
||||||
const { useState, useEffect } = React;
|
|
||||||
|
|
||||||
function Login({ onLogin }) {
|
|
||||||
const [username, setUsername] = useState('admin');
|
|
||||||
const [password, setPassword] = useState('admin123');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (!username || !password) { setError('请输入用户名和密码'); return; }
|
|
||||||
setLoading(true); setError('');
|
|
||||||
try {
|
|
||||||
const res = await axios.post('/api/auth/login', new URLSearchParams({ username, password }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
|
||||||
if (res.data.access_token) {
|
|
||||||
localStorage.setItem('adminToken', res.data.access_token);
|
|
||||||
onLogin({ username: username });
|
|
||||||
} else if (res.data.code === 0) {
|
|
||||||
localStorage.setItem('adminToken', res.data.data.token);
|
|
||||||
onLogin(res.data.data.user || { username: username });
|
|
||||||
} else { setError(res.data.message || '登录失败'); }
|
|
||||||
} catch (e) { setError('网络错误: ' + e.message); }
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return React.createElement('div', { style: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#1a1a2e' } },
|
|
||||||
React.createElement('div', { style: { width: '320px', padding: '30px', background: '#16213e', borderRadius: '12px' } },
|
|
||||||
React.createElement('h2', { style: { textAlign: 'center', marginBottom: '20px' } }, '甲辰管理后台'),
|
|
||||||
React.createElement('input', { type: 'text', value: username, onChange: e => setUsername(e.target.value), placeholder: '账号', style: { width: '100%', padding: '12px', marginBottom: '12px', background: '#0f172a', border: '1px solid #334155', color: '#fff', borderRadius: '6px' } }),
|
|
||||||
React.createElement('input', { type: 'password', value: password, onChange: e => setPassword(e.target.value), placeholder: '密码', style: { width: '100%', padding: '12px', marginBottom: '12px', background: '#0f172a', border: '1px solid #334155', color: '#fff', borderRadius: '6px' } }),
|
|
||||||
error && React.createElement('div', { style: { color: '#f87171', marginBottom: '12px', fontSize: '14px' } }, error),
|
|
||||||
React.createElement('button', { onClick: handleLogin, disabled: loading, style: { width: '100%', padding: '12px', background: '#f59e0b', border: 'none', borderRadius: '6px', color: '#000', cursor: 'pointer' } }, loading ? '登录中...' : '登录')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserManager() {
|
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => { loadUsers(); }, []);
|
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const res = await axios.get('/api/admin/users', { headers: { Authorization: 'Bearer ' + token } });
|
|
||||||
if (res.data) setUsers(res.data);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = users.filter(u => (u.username || '').includes(search) || (u.phone || '').includes(search));
|
|
||||||
|
|
||||||
return React.createElement('div', { padding: '20px' },
|
|
||||||
React.createElement('h2', { marginBottom: '16px' }, '用户列表'),
|
|
||||||
React.createElement('input', { type: 'text', value: search, onChange: e => setSearch(e.target.value), placeholder: '搜索...', style: { width: '100%', padding: '10px', marginBottom: '16px', background: '#0f172a', border: '1px solid #334155', color: '#fff', borderRadius: '6px' } }),
|
|
||||||
loading ? React.createElement('div', { textAlign: 'center', padding: '40px' }, '加载中...') :
|
|
||||||
React.createElement('table', { width: '100%', borderCollapse: 'collapse' },
|
|
||||||
React.createElement('thead', null,
|
|
||||||
React.createElement('tr', null,
|
|
||||||
React.createElement('th', { style: { padding: '10px', textAlign: 'left' } }, '用户名'),
|
|
||||||
React.createElement('th', { style: { padding: '10px', textAlign: 'left' } }, '编码'),
|
|
||||||
React.createElement('th', { style: { padding: '10px', textAlign: 'left' } }, '手机'),
|
|
||||||
React.createElement('th', { style: { padding: '10px', textAlign: 'left' } }, '角色'),
|
|
||||||
React.createElement('th', { style: { padding: '10px', textAlign: 'right' } }, '余额'),
|
|
||||||
React.createElement('th', { style: { padding: '10px', textAlign: 'right' } }, '积分')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement('tbody', null,
|
|
||||||
filtered.map(u => React.createElement('tr', { key: u.id, style: { borderBottom: '1px solid #334155' } },
|
|
||||||
React.createElement('td', { style: { padding: '10px' } }, u.username || '-'),
|
|
||||||
React.createElement('td', { style: { padding: '10px', color: '#f59e0b' } }, u.user_code || '-'),
|
|
||||||
React.createElement('td', { style: { padding: '10px' } }, u.phone || '-'),
|
|
||||||
React.createElement('td', { style: { padding: '10px' } }, u.role === 'admin' ? '管理员' : '用户'),
|
|
||||||
React.createElement('td', { style: { padding: '10px', textAlign: 'right' } }, '¥' + (u.balance || 0)),
|
|
||||||
React.createElement('td', { style: { padding: '10px', textAlign: 'right' } }, u.points || 0)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
const [activeTab, setActiveTab] = useState('users');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
const userStr = localStorage.getItem('adminUser');
|
|
||||||
if (token && userStr) {
|
|
||||||
try { setUser(JSON.parse(userStr)); } catch (e) {}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!user) return React.createElement(Login, { onLogin: (u) => { localStorage.setItem('adminUser', JSON.stringify(u)); setUser(u); } });
|
|
||||||
|
|
||||||
return React.createElement('div', { minHeight: '100vh', background: '#1a1a2e' },
|
|
||||||
React.createElement('div', { padding: '16px', background: '#16213e', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #334155' },
|
|
||||||
React.createElement('h1', { fontSize: '18px' }, '管理后台'),
|
|
||||||
React.createElement('button', { onClick: () => { localStorage.clear(); setUser(null); }, style: { background: 'transparent', border: '1px solid #334155', color: '#94a3b8', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer' } }, '退出')
|
|
||||||
),
|
|
||||||
React.createElement('div', { display: 'flex', borderBottom: '1px solid #334155' },
|
|
||||||
React.createElement('div', { onClick: () => setActiveTab('users'), style: { padding: '12px 20px', cursor: 'pointer', borderBottom: activeTab === 'users' ? '2px solid #f59e0b' : '2px solid transparent', color: activeTab === 'users' ? '#fff' : '#94a3b8' } }, '用户'),
|
|
||||||
React.createElement('div', { onClick: () => setActiveTab('stats'), style: { padding: '12px 20px', cursor: 'pointer', borderBottom: activeTab === 'stats' ? '2px solid #f59e0b' : '2px solid transparent', color: activeTab === 'stats' ? '#fff' : '#94a3b8' } }, '统计')
|
|
||||||
),
|
|
||||||
React.createElement('div', null,
|
|
||||||
activeTab === 'users' && React.createElement(UserManager),
|
|
||||||
activeTab === 'stats' && React.createElement('div', { padding: '20px', color: '#94a3b8' }, '统计功能开发中...')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(React.createElement(App));
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
uploads/*
|
|
||||||
!uploads/.gitkeep
|
|
||||||
logs/*
|
|
||||||
*.log
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 数据库配置
|
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
|
|
||||||
|
|
||||||
# JWT配置
|
|
||||||
SECRET_KEY=your-production-secret-key-change-this
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
|
||||||
|
|
||||||
# 阿里云 DashScope OCR API(必须配置!)
|
|
||||||
DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
|
|
||||||
|
|
||||||
# 阿里云 OSS配置
|
|
||||||
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
|
||||||
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
|
||||||
OSS_BUCKET_NAME=jiachenlong-oss
|
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
|
||||||
OSS_PUBLIC_URL=https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com
|
|
||||||
|
|
||||||
# 阿里云短信配置(必须配置!)
|
|
||||||
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
|
|
||||||
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
|
|
||||||
SMS_SIGN_NAME=苏州算力
|
|
||||||
SMS_TEMPLATE_CODE=SMS_501590956
|
|
||||||
|
|
||||||
# 服务配置
|
|
||||||
PORT=3000
|
|
||||||
HOST=0.0.0.0
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# FastAPI 后端 Docker 镜像
|
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 安装系统依赖
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# 安装 Python 依赖
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# 复制应用代码
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# 创建上传目录
|
|
||||||
RUN mkdir -p uploads
|
|
||||||
|
|
||||||
# 暴露端口
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# 启动命令
|
|
||||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# 后端服务 - FastAPI
|
|
||||||
|
|
||||||
## 启动方式
|
|
||||||
|
|
||||||
### 开发环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装依赖
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生产环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 后台运行
|
|
||||||
nohup python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 > /tmp/uvicorn.log 2>&1 &
|
|
||||||
|
|
||||||
# 或使用 systemd
|
|
||||||
sudo systemctl start zodiac-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置说明
|
|
||||||
|
|
||||||
编辑 `.env` 文件:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# 数据库
|
|
||||||
DATABASE_URL=postgresql://postgres:密码@localhost:5432/zodiac
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
SECRET_KEY=你的密钥
|
|
||||||
|
|
||||||
# OCR API
|
|
||||||
DASHSCOPE_API_KEY=sk-你的密钥
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 文档
|
|
||||||
|
|
||||||
启动后访问:http://localhost:3000/docs
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# 认证模块
|
|
||||||
import os
|
|
||||||
import bcrypt
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.database import SessionLocal
|
|
||||||
from app.models.models import User
|
|
||||||
|
|
||||||
# 配置
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
|
||||||
|
|
||||||
# HTTP Bearer 认证
|
|
||||||
security = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""验证密码"""
|
|
||||||
try:
|
|
||||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
"""生成密码哈希"""
|
|
||||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
||||||
"""创建访问令牌"""
|
|
||||||
to_encode = data.copy()
|
|
||||||
if expires_delta:
|
|
||||||
expire = datetime.utcnow() + expires_delta
|
|
||||||
else:
|
|
||||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
|
|
||||||
def decode_access_token(token: str) -> Optional[dict]:
|
|
||||||
"""解码访问令牌"""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
return payload
|
|
||||||
except JWTError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
||||||
db: Session = Depends(lambda: SessionLocal())
|
|
||||||
) -> User:
|
|
||||||
"""获取当前用户"""
|
|
||||||
if not credentials:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="未提供认证信息",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
token = credentials.credentials
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
|
|
||||||
if not payload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="无效的令牌",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id: str = payload.get("sub")
|
|
||||||
if not user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="无效的令牌",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="用户不存在",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import os
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# 支持 MySQL, PostgreSQL
|
|
||||||
DATABASE_URL = os.getenv(
|
|
||||||
"DATABASE_URL",
|
|
||||||
"postgresql://postgres:postgres@localhost:5432/zodiac"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 数据库引擎配置
|
|
||||||
engine = create_engine(
|
|
||||||
DATABASE_URL,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_size=10,
|
|
||||||
max_overflow=20,
|
|
||||||
echo=False
|
|
||||||
)
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
"""获取数据库会话"""
|
|
||||||
db = SessionLocal(expire_on_commit=False)
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
# 统一错误处理
|
|
||||||
from fastapi import FastAPI, Request, HTTPException, status
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from pydantic import ValidationError
|
|
||||||
from app.core.logging_config import logger
|
|
||||||
|
|
||||||
# 错误码定义
|
|
||||||
ERROR_CODES = {
|
|
||||||
# 认证错误 (10-19)
|
|
||||||
401: "E00010", # 未授权
|
|
||||||
403: "E00014", # 禁止访问
|
|
||||||
|
|
||||||
# 验证错误 (20-29)
|
|
||||||
400: "E00000", # 请求错误
|
|
||||||
422: "E00000", # 验证错误
|
|
||||||
|
|
||||||
# 资源错误 (30-39)
|
|
||||||
404: "E00033", # 资源不存在
|
|
||||||
|
|
||||||
# 服务器错误 (50-59)
|
|
||||||
500: "E00003", # 服务器内部错误
|
|
||||||
}
|
|
||||||
|
|
||||||
# 错误信息映射
|
|
||||||
ERROR_MESSAGES = {
|
|
||||||
"E00010": "未登录或登录已过期",
|
|
||||||
"E00011": "用户名或密码错误",
|
|
||||||
"E00012": "验证码错误",
|
|
||||||
"E00014": "无权访问此资源",
|
|
||||||
"E00015": "令牌无效或已过期",
|
|
||||||
"E00020": "请输入用户名和密码",
|
|
||||||
"E00021": "用户名至少 3 个字符",
|
|
||||||
"E00022": "密码至少 6 个字符",
|
|
||||||
"E00023": "用户名已存在",
|
|
||||||
"E00024": "邮箱已被注册",
|
|
||||||
"E00030": "藏品名称不能为空",
|
|
||||||
"E00031": "藏品名称至少 2 个字符",
|
|
||||||
"E00032": "藏品分类不能为空",
|
|
||||||
"E00033": "藏品不存在",
|
|
||||||
"E00034": "禁止重复:此冠字号已存在",
|
|
||||||
"E00035": "成本价格必须>=0",
|
|
||||||
"E00036": "目标价格必须>=0",
|
|
||||||
"E00037": "发行年份必须是 4 位数字",
|
|
||||||
"E00040": "请选择图片文件",
|
|
||||||
"E00041": "图片尺寸太小,无法识别",
|
|
||||||
"E00042": "OCR 识别失败,请重试",
|
|
||||||
"E00050": "仅管理员可访问",
|
|
||||||
"E00051": "用户不存在",
|
|
||||||
"E00000": "请求失败",
|
|
||||||
"E00001": "网络连接失败",
|
|
||||||
"E00003": "服务器内部错误",
|
|
||||||
}
|
|
||||||
|
|
||||||
def setup_error_handlers(app: FastAPI):
|
|
||||||
"""设置全局错误处理器"""
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
||||||
"""处理 HTTP 异常"""
|
|
||||||
# 从 detail 中提取错误码
|
|
||||||
detail = exc.detail
|
|
||||||
error_code = ERROR_CODES.get(exc.status_code, "E00000")
|
|
||||||
|
|
||||||
# 如果 detail 已经包含错误码,直接使用
|
|
||||||
if isinstance(detail, str) and detail.startswith("E"):
|
|
||||||
parts = detail.split(":", 1)
|
|
||||||
error_code = parts[0]
|
|
||||||
message = parts[1].strip() if len(parts) > 1 else ERROR_MESSAGES.get(error_code, detail)
|
|
||||||
else:
|
|
||||||
message = ERROR_MESSAGES.get(error_code, detail if isinstance(detail, str) else "请求失败")
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": error_code,
|
|
||||||
"message": message,
|
|
||||||
"status": exc.status_code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
||||||
"""处理请求验证错误"""
|
|
||||||
errors = exc.errors()
|
|
||||||
if errors:
|
|
||||||
error = errors[0]
|
|
||||||
field = ".".join(str(x) for x in error.get("loc", []))
|
|
||||||
msg = error.get("msg", "验证失败")
|
|
||||||
|
|
||||||
# 根据字段和消息匹配错误码
|
|
||||||
# 先检查请求路径,区分用户接口和藏品接口
|
|
||||||
path = request.url.path
|
|
||||||
|
|
||||||
if "cost_price" in field or "价格" in msg:
|
|
||||||
error_code = "E00035"
|
|
||||||
message = "成本价格必须>=0"
|
|
||||||
elif "target_price" in field:
|
|
||||||
error_code = "E00036"
|
|
||||||
message = "目标价格必须>=0"
|
|
||||||
elif "issue_year" in field or "年份" in msg:
|
|
||||||
error_code = "E00037"
|
|
||||||
message = "发行年份必须是 4 位数字"
|
|
||||||
elif "name" in field:
|
|
||||||
# 根据路径区分用户 name 和藏品 name
|
|
||||||
if "/auth/" in path or "/users/" in path or "/admin/users/" in path:
|
|
||||||
error_code = "E00021"
|
|
||||||
message = "用户名至少 3 个字符"
|
|
||||||
else:
|
|
||||||
error_code = "E00031"
|
|
||||||
message = "藏品名称至少 2 个字符"
|
|
||||||
elif "category" in field:
|
|
||||||
error_code = "E00032"
|
|
||||||
message = "藏品分类不能为空"
|
|
||||||
else:
|
|
||||||
error_code = "E00000"
|
|
||||||
message = f"{field}: {msg}"
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=422,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": error_code,
|
|
||||||
"message": message,
|
|
||||||
"status": 422,
|
|
||||||
"field": field
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=422,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": "E00000",
|
|
||||||
"message": "验证失败",
|
|
||||||
"status": 422
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(404)
|
|
||||||
async def not_found_handler(request: Request, exc: Exception):
|
|
||||||
"""处理 404 错误"""
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": "E00033",
|
|
||||||
"message": "接口不存在",
|
|
||||||
"status": 404
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
|
||||||
async def general_exception_handler(request: Request, exc: Exception):
|
|
||||||
"""处理未捕获的异常"""
|
|
||||||
import traceback
|
|
||||||
error_trace = traceback.format_exc()
|
|
||||||
logger.error(f"未捕获异常:{str(exc)}\n{error_trace}")
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={
|
|
||||||
"error": {
|
|
||||||
"code": "E00003",
|
|
||||||
"message": "服务器内部错误",
|
|
||||||
"status": 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
# 日志系统配置
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
import uuid
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
|
|
||||||
class JSONFormatter(logging.Formatter):
|
|
||||||
"""JSON 格式日志处理器"""
|
|
||||||
|
|
||||||
def format(self, record):
|
|
||||||
log_data = {
|
|
||||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
|
||||||
'level': record.levelname,
|
|
||||||
'logger': record.name,
|
|
||||||
'message': record.getMessage(),
|
|
||||||
'trace_id': getattr(record, 'trace_id', str(uuid.uuid4())),
|
|
||||||
'user_id': getattr(record, 'user_id', None),
|
|
||||||
'request_id': getattr(record, 'request_id', str(uuid.uuid4())),
|
|
||||||
'ip_address': getattr(record, 'ip_address', None),
|
|
||||||
'duration_ms': getattr(record, 'duration_ms', None),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 添加额外字段
|
|
||||||
if hasattr(record, 'data'):
|
|
||||||
log_data['data'] = record.data
|
|
||||||
|
|
||||||
# 添加异常信息
|
|
||||||
if record.exc_info:
|
|
||||||
log_data['exception'] = self.formatException(record.exc_info)
|
|
||||||
|
|
||||||
return json.dumps(log_data, ensure_ascii=False, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(
|
|
||||||
log_file: str = 'logs/app.log',
|
|
||||||
level: str = 'INFO',
|
|
||||||
max_bytes: int = 10*1024*1024, # 10MB
|
|
||||||
backup_count: int = 5
|
|
||||||
):
|
|
||||||
"""配置日志系统"""
|
|
||||||
|
|
||||||
# 创建日志目录
|
|
||||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
|
||||||
|
|
||||||
# 根日志器
|
|
||||||
logger = logging.getLogger()
|
|
||||||
logger.setLevel(level)
|
|
||||||
|
|
||||||
# 清空现有处理器
|
|
||||||
logger.handlers.clear()
|
|
||||||
|
|
||||||
# 文件处理器(带轮转)
|
|
||||||
file_handler = RotatingFileHandler(
|
|
||||||
log_file,
|
|
||||||
maxBytes=max_bytes,
|
|
||||||
backupCount=backup_count,
|
|
||||||
encoding='utf-8'
|
|
||||||
)
|
|
||||||
file_handler.setFormatter(JSONFormatter())
|
|
||||||
logger.addHandler(file_handler)
|
|
||||||
|
|
||||||
# 控制台处理器(仅开发环境)
|
|
||||||
if os.getenv('ENV', 'development') == 'development':
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setFormatter(JSONFormatter())
|
|
||||||
logger.addHandler(console_handler)
|
|
||||||
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
# 创建日志器实例
|
|
||||||
logger = setup_logging()
|
|
||||||
|
|
||||||
|
|
||||||
# 日志装饰器
|
|
||||||
def log_operation(operation_name: str):
|
|
||||||
"""记录操作日志的装饰器"""
|
|
||||||
def decorator(func):
|
|
||||||
import functools
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
duration = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"{operation_name} 成功",
|
|
||||||
extra={
|
|
||||||
'duration_ms': duration,
|
|
||||||
'data': {'function': func.__name__}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
duration = (time.time() - start_time) * 1000
|
|
||||||
logger.error(
|
|
||||||
f"{operation_name} 失败:{str(e)}",
|
|
||||||
extra={
|
|
||||||
'duration_ms': duration,
|
|
||||||
'data': {'function': func.__name__, 'error': str(e)}
|
|
||||||
},
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
# FastAPI 应用入口
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from app.core.database import engine, Base
|
|
||||||
from app.core.logging_config import logger, setup_logging
|
|
||||||
from app.core.error_handler import setup_error_handlers
|
|
||||||
from app.middleware.logging import logging_middleware
|
|
||||||
from app.routers import auth, collections, operations
|
|
||||||
from app.routers import ocr as ocr_router
|
|
||||||
from app.routers import users as users_router
|
|
||||||
|
|
||||||
# 版本信息 - 从 config/VERSION 文件读取
|
|
||||||
def get_version():
|
|
||||||
"""从 config/VERSION 文件读取版本号"""
|
|
||||||
try:
|
|
||||||
version_file = Path(__file__).parent.parent.parent / "config" / "VERSION"
|
|
||||||
if version_file.exists():
|
|
||||||
with open(version_file, 'r', encoding='utf-8') as f:
|
|
||||||
for line in f:
|
|
||||||
if line.startswith('VERSION='):
|
|
||||||
return line.strip().split('=', 1)[1]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"读取 VERSION 文件失败:{e}")
|
|
||||||
return "0.0.0" # 默认版本号
|
|
||||||
|
|
||||||
__version__ = get_version()
|
|
||||||
__app_name__ = "甲辰收藏系统 FastAPI 后端"
|
|
||||||
|
|
||||||
# 启动时创建数据库表
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
# 初始化日志系统
|
|
||||||
setup_logging()
|
|
||||||
logger.info(f"{__app_name__} v{__version__} 启动成功")
|
|
||||||
|
|
||||||
# 创建 FastAPI 应用
|
|
||||||
app = FastAPI(
|
|
||||||
title=__app_name__,
|
|
||||||
version=__version__,
|
|
||||||
description="生肖纪念钞收藏管理系统后端 API"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 设置全局错误处理器
|
|
||||||
setup_error_handlers(app)
|
|
||||||
|
|
||||||
# CORS 配置
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"], # 生产环境应该限制域名
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 挂载静态文件目录(图片上传和项目静态资源)
|
|
||||||
uploads_dir = "uploads"
|
|
||||||
os.makedirs(uploads_dir, exist_ok=True)
|
|
||||||
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
|
||||||
|
|
||||||
# 挂载项目静态资源目录(可选,生产环境建议用 Nginx)
|
|
||||||
# static_dir = Path(__file__).parent.parent.parent / "static"
|
|
||||||
# if static_dir.exists():
|
|
||||||
# app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
||||||
|
|
||||||
# 添加日志中间件
|
|
||||||
app.middleware("http")(logging_middleware)
|
|
||||||
|
|
||||||
# 注册路由
|
|
||||||
app.include_router(auth.router)
|
|
||||||
app.include_router(collections.router)
|
|
||||||
app.include_router(operations.router)
|
|
||||||
app.include_router(ocr_router.router) # OCR 识别
|
|
||||||
app.include_router(users_router.router) # 当前用户接口
|
|
||||||
app.include_router(users_router.admin_router) # 管理员用户管理
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def root():
|
|
||||||
"""根路径"""
|
|
||||||
return {
|
|
||||||
"name": __app_name__,
|
|
||||||
"version": __version__,
|
|
||||||
"status": "running"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health_check():
|
|
||||||
"""健康检查"""
|
|
||||||
return {"status": "healthy"}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
port = int(os.getenv("PORT", "3000"))
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
# 请求日志中间件 - 优化版
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from fastapi import Request, Response
|
|
||||||
from app.core.logging_config import logger
|
|
||||||
|
|
||||||
|
|
||||||
async def logging_middleware(request: Request, call_next):
|
|
||||||
"""记录所有 API 请求的日志 - 优化版"""
|
|
||||||
|
|
||||||
# 生成请求 ID
|
|
||||||
request_id = str(uuid.uuid4())
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# 获取用户信息(如果已登录)
|
|
||||||
user_id = None
|
|
||||||
try:
|
|
||||||
auth_header = request.headers.get('Authorization', '')
|
|
||||||
if auth_header.startswith('Bearer '):
|
|
||||||
user_id = "authenticated"
|
|
||||||
except Exception as e:
|
|
||||||
# 静默失败,不影响主流程
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 执行请求
|
|
||||||
response_status = 500
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
response_status = response.status_code
|
|
||||||
except Exception as e:
|
|
||||||
duration = (time.time() - start_time) * 1000
|
|
||||||
try:
|
|
||||||
logger.error(
|
|
||||||
f"API 请求异常:{request.method} {request.url.path}",
|
|
||||||
extra={
|
|
||||||
'request_id': request_id,
|
|
||||||
'user_id': user_id,
|
|
||||||
'ip_address': request.client.host if request.client else None,
|
|
||||||
'duration_ms': duration,
|
|
||||||
'data': {
|
|
||||||
'method': request.method,
|
|
||||||
'path': request.url.path,
|
|
||||||
'query': str(request.query_params),
|
|
||||||
'error': str(e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
pass # 日志记录失败不影响主流程
|
|
||||||
raise
|
|
||||||
|
|
||||||
# 记录响应
|
|
||||||
duration = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
try:
|
|
||||||
log_level = 'INFO'
|
|
||||||
if response_status >= 500:
|
|
||||||
log_level = 'ERROR'
|
|
||||||
elif response_status >= 400:
|
|
||||||
log_level = 'WARNING'
|
|
||||||
|
|
||||||
getattr(logger, log_level)(
|
|
||||||
f"API 请求完成:{request.method} {request.url.path}",
|
|
||||||
extra={
|
|
||||||
'request_id': request_id,
|
|
||||||
'user_id': user_id,
|
|
||||||
'ip_address': request.client.host if request.client else None,
|
|
||||||
'duration_ms': duration,
|
|
||||||
'data': {
|
|
||||||
'method': request.method,
|
|
||||||
'path': request.url.path,
|
|
||||||
'query': str(request.query_params),
|
|
||||||
'status_code': response_status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
# 日志记录失败不影响主流程
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 在响应头中添加请求 ID
|
|
||||||
try:
|
|
||||||
response.headers['X-Request-ID'] = request_id
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
# 数据库模型 - 使用字段编码
|
|
||||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.core.database import Base
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
def generate_uuid():
|
|
||||||
"""生成 UUID 字符串"""
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
# f99 系统字段
|
|
||||||
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True)
|
|
||||||
f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username
|
|
||||||
email = Column(String(255), unique=True, nullable=True, index=True)
|
|
||||||
phone = Column(String(50), nullable=True)
|
|
||||||
avatar = Column(String(500), nullable=True)
|
|
||||||
address = Column(String(500), nullable=True)
|
|
||||||
bio = Column(Text, nullable=True)
|
|
||||||
password = Column(String(255), nullable=False)
|
|
||||||
role = Column(String(50), default="user")
|
|
||||||
# last_login = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
custom_fields = relationship("CustomField", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class Collection(Base):
|
|
||||||
__tablename__ = "collections"
|
|
||||||
|
|
||||||
# f99 系统字段
|
|
||||||
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
f99_91_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
|
||||||
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
# f01 基本信息
|
|
||||||
f01_01_name = Column(String(255), nullable=False)
|
|
||||||
f01_02_code = Column(String(50), nullable=True, index=True)
|
|
||||||
f01_03_category = Column(String(100), nullable=False, index=True)
|
|
||||||
f01_04_status = Column(String(50), default="in_collection", index=True)
|
|
||||||
f01_05_remark = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# f02 详细字段
|
|
||||||
f02_10_prefix_serial = Column(String(50), nullable=True, index=True)
|
|
||||||
f02_11_version = Column(String(100), nullable=True, index=True)
|
|
||||||
f02_12_packaging = Column(String(100), nullable=True, index=True)
|
|
||||||
f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度
|
|
||||||
f02_14_number_category = Column(String(20), nullable=True, index=True) # 号码分类
|
|
||||||
|
|
||||||
# f03 评级信息
|
|
||||||
f03_20_is_graded = Column(Boolean, default=False, index=True)
|
|
||||||
f03_21_grading_company = Column(String(100), nullable=True, index=True)
|
|
||||||
f03_22_grading_score = Column(String(20), nullable=True, index=True)
|
|
||||||
f03_23_three_star = Column(Boolean, default=False, index=True)
|
|
||||||
|
|
||||||
# f04 特殊信息
|
|
||||||
f04_30_special_mark = Column(String(200), nullable=True, index=True)
|
|
||||||
f04_31_serial_feature = Column(String(100), nullable=True, index=True)
|
|
||||||
f04_32_issuer = Column(String(100), nullable=True, index=True)
|
|
||||||
f04_33_issue_year = Column(String(20), nullable=True, index=True)
|
|
||||||
f04_34_material = Column(String(50), nullable=True)
|
|
||||||
f04_35_denomination = Column(String(20), nullable=True)
|
|
||||||
f04_36_issue_quantity = Column(String(50), nullable=True)
|
|
||||||
|
|
||||||
# f05 价格信息
|
|
||||||
f05_40_cost_price = Column(Float, nullable=True, index=True)
|
|
||||||
f05_41_target_price = Column(Float, nullable=True, index=True)
|
|
||||||
f05_42_goal_price = Column(Float, nullable=True)
|
|
||||||
f05_43_repair_fee = Column(Float, nullable=True)
|
|
||||||
f05_44_grading_fee = Column(Float, nullable=True)
|
|
||||||
|
|
||||||
# f06 其他信息
|
|
||||||
f06_50_purpose = Column(String(100), nullable=True)
|
|
||||||
|
|
||||||
user = relationship("User", back_populates="collections")
|
|
||||||
images = relationship("CollectionImage", back_populates="collection", cascade="all, delete-orphan")
|
|
||||||
operations = relationship("Operation", back_populates="collection", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionImage(Base):
|
|
||||||
__tablename__ = "collection_images"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
filename = Column(String(255), nullable=False)
|
|
||||||
original_name = Column(String(255), nullable=True)
|
|
||||||
path = Column(String(500), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
collection = relationship("Collection", back_populates="images")
|
|
||||||
|
|
||||||
|
|
||||||
class Operation(Base):
|
|
||||||
__tablename__ = "operations"
|
|
||||||
|
|
||||||
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
f99_91_user_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
f99_92_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
type = Column(String(50), nullable=False, index=True)
|
|
||||||
price = Column(Float, nullable=True)
|
|
||||||
note = Column(Text, nullable=True)
|
|
||||||
f99_93_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
|
||||||
|
|
||||||
collection = relationship("Collection", back_populates="operations")
|
|
||||||
user = relationship("User", back_populates="operations")
|
|
||||||
|
|
||||||
|
|
||||||
class CustomField(Base):
|
|
||||||
__tablename__ = "custom_fields"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
name = Column(String(100), nullable=False)
|
|
||||||
field_type = Column(String(50), default="text")
|
|
||||||
options = Column(Text, nullable=True)
|
|
||||||
required = Column(Boolean, default=False)
|
|
||||||
visible = Column(Boolean, default=True)
|
|
||||||
sort_order = Column(Integer, default=0)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
user = relationship("User", back_populates="custom_fields")
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
# 认证路由 - 使用字段编码
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.database import get_db
|
|
||||||
from app.core.auth import verify_password, create_access_token, get_password_hash, get_current_user
|
|
||||||
from app.models.models import User
|
|
||||||
from app.schemas.schemas import Token, UserCreate, UserResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserResponse)
|
|
||||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
|
||||||
"""用户注册"""
|
|
||||||
# 检查用户名是否已存在
|
|
||||||
existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
|
|
||||||
if existing_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="f01_01_name: 用户名已存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查邮箱是否已存在
|
|
||||||
if user_data.email:
|
|
||||||
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
|
||||||
if existing_email:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="邮箱已被注册"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 创建用户
|
|
||||||
import uuid
|
|
||||||
hashed_password = get_password_hash(user_data.password)
|
|
||||||
user = User(
|
|
||||||
f99_90_id=str(uuid.uuid4()),
|
|
||||||
f99_91_user_id=str(uuid.uuid4()), # 生成唯一 user_id
|
|
||||||
f01_01_name=user_data.f01_01_name,
|
|
||||||
email=user_data.email,
|
|
||||||
phone=user_data.phone,
|
|
||||||
avatar=user_data.avatar,
|
|
||||||
address=user_data.address,
|
|
||||||
bio=user_data.bio,
|
|
||||||
password=hashed_password,
|
|
||||||
role="user"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(user)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
|
||||||
def login(
|
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""用户登录"""
|
|
||||||
# 查找用户
|
|
||||||
user = db.query(User).filter(User.f01_01_name == form_data.username).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="E00011: 用户名或密码错误",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# 验证密码
|
|
||||||
if not verify_password(form_data.password, user.password):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="E00011: 用户名或密码错误",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# 生成 token
|
|
||||||
access_token = create_access_token(data={"sub": user.f99_90_id})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"access_token": access_token,
|
|
||||||
"token_type": "bearer"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
|
||||||
def get_current_user_info(
|
|
||||||
current_user: User = Depends(lambda: None)
|
|
||||||
):
|
|
||||||
"""获取当前用户信息"""
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
||||||
detail="请使用正确的依赖注入"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
|
||||||
def change_password(
|
|
||||||
old_password: str = Body(...),
|
|
||||||
new_password: str = Body(...),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""修改当前用户密码"""
|
|
||||||
from app.core.auth import verify_password, get_password_hash
|
|
||||||
|
|
||||||
# 在当前session中重新查询用户
|
|
||||||
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
|
||||||
|
|
||||||
# 验证旧密码
|
|
||||||
if not verify_password(old_password, user.password):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="当前密码错误"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新密码
|
|
||||||
user.password = get_password_hash(new_password)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"message": "密码修改成功"}
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 短信验证码接口 ============
|
|
||||||
|
|
||||||
@router.post("/send-verification-code")
|
|
||||||
def send_verification_code(
|
|
||||||
phone: str = Body(..., min_length=11, max_length=11),
|
|
||||||
purpose: str = Body("register") # register | login | reset_password
|
|
||||||
):
|
|
||||||
"""发送短信验证码"""
|
|
||||||
from app.services.sms import send_verification_code as send_sms
|
|
||||||
|
|
||||||
# 验证手机号格式
|
|
||||||
if not phone.startswith("1") or len(phone) != 11:
|
|
||||||
return {"success": False, "message": "手机号格式不正确"}
|
|
||||||
|
|
||||||
result = send_sms(phone)
|
|
||||||
|
|
||||||
if result["success"]:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": f"验证码已发送到 {phone[:3]}****{phone[7:]}",
|
|
||||||
"expire": result.get("expire", 300)
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-code")
|
|
||||||
def verify_code(
|
|
||||||
phone: str = Body(...),
|
|
||||||
code: str = Body(..., min_length=6, max_length=6)
|
|
||||||
):
|
|
||||||
"""验证短信验证码(仅验证,不执行后续操作)"""
|
|
||||||
from app.services.sms import verify_code as check_code
|
|
||||||
|
|
||||||
is_valid = check_code(phone, code)
|
|
||||||
|
|
||||||
if is_valid:
|
|
||||||
return {"success": True, "message": "验证成功"}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "验证码错误或已过期"}
|
|
||||||
|
|
@ -1,685 +0,0 @@
|
||||||
# 藏品路由 - 使用字段编码
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import re
|
|
||||||
from typing import Optional, List
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
|
|
||||||
from sqlalchemy import func, text
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.database import get_db
|
|
||||||
from app.core.auth import get_current_user
|
|
||||||
from app.core.logging_config import logger
|
|
||||||
from app.models.models import User, Collection, CollectionImage, Operation
|
|
||||||
from app.schemas.schemas import (
|
|
||||||
CollectionCreate, CollectionUpdate, CollectionResponse,
|
|
||||||
CollectionListResponse, CollectionImageResponse
|
|
||||||
)
|
|
||||||
from app.services.oss import upload_to_oss, get_oss_path, delete_from_oss, get_public_url
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/collections", tags=["藏品"])
|
|
||||||
|
|
||||||
|
|
||||||
def to_camel_case(data: dict) -> dict:
|
|
||||||
"""将字段编码转换为 camelCase 格式"""
|
|
||||||
if not data:
|
|
||||||
return data
|
|
||||||
|
|
||||||
mapping = {
|
|
||||||
'f99_90_id': 'id',
|
|
||||||
'f99_91_user_id': 'userId',
|
|
||||||
'f99_92_created_at': 'createdAt',
|
|
||||||
'f99_93_updated_at': 'updatedAt',
|
|
||||||
'f01_01_name': 'name',
|
|
||||||
'f01_02_code': 'code',
|
|
||||||
'f01_03_category': 'category',
|
|
||||||
'f01_04_status': 'status',
|
|
||||||
'f01_05_remark': 'remark',
|
|
||||||
'f02_10_prefix_serial': 'prefixSerial',
|
|
||||||
'f02_11_version': 'version',
|
|
||||||
'f02_12_packaging': 'packaging',
|
|
||||||
'f02_13_rarity': 'rarity',
|
|
||||||
'f02_14_number_category': 'numberCategory',
|
|
||||||
'f03_20_is_graded': 'isGraded',
|
|
||||||
'f03_21_grading_company': 'gradingCompany',
|
|
||||||
'f03_22_grading_score': 'gradingScore',
|
|
||||||
'f03_23_three_star': 'threeStar',
|
|
||||||
'f04_30_special_mark': 'specialMark',
|
|
||||||
'f04_31_serial_feature': 'serialFeature',
|
|
||||||
'f04_32_issuer': 'issuer',
|
|
||||||
'f04_33_issue_year': 'issueYear',
|
|
||||||
'f04_34_material': 'material',
|
|
||||||
'f04_35_denomination': 'denomination',
|
|
||||||
'f04_36_issue_quantity': 'issueQuantity',
|
|
||||||
'f05_40_cost_price': 'costPrice',
|
|
||||||
'f05_41_target_price': 'targetPrice',
|
|
||||||
'f05_42_goal_price': 'goalPrice',
|
|
||||||
'f05_43_repair_fee': 'repairFee',
|
|
||||||
'f05_44_grading_fee': 'gradingFee',
|
|
||||||
'f06_50_purpose': 'purpose',
|
|
||||||
}
|
|
||||||
|
|
||||||
return {mapping.get(k, k): v for k, v in data.items()}
|
|
||||||
|
|
||||||
|
|
||||||
# 编码生成函数
|
|
||||||
def generate_code(version: str, user_id: str, db: Session) -> str:
|
|
||||||
"""自动生成藏品编号 - 按用户独立编码"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
# 查询当前用户的非空编码(不与其他用户混算)
|
|
||||||
user_codes = db.query(Collection.f01_02_code).filter(
|
|
||||||
Collection.f01_02_code.isnot(None),
|
|
||||||
Collection.f99_91_user_id == user_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
max_num = 0
|
|
||||||
for (code,) in user_codes:
|
|
||||||
# 处理纯数字编码(支持4位和5位)
|
|
||||||
if re.match(r'^\d{4,5}$', code):
|
|
||||||
try:
|
|
||||||
num = int(code)
|
|
||||||
if num > max_num:
|
|
||||||
max_num = num
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 当前用户最大号 +1
|
|
||||||
next_num = max_num + 1
|
|
||||||
|
|
||||||
# 如果超过9999,使用5位;否则使用4位
|
|
||||||
if next_num > 9999:
|
|
||||||
return str(next_num).zfill(5)
|
|
||||||
else:
|
|
||||||
return str(next_num).zfill(4)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/next-code")
|
|
||||||
def get_next_code(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取下一个藏品编号"""
|
|
||||||
next_code = generate_code("2024 龙", current_user.f99_90_id, db)
|
|
||||||
return {"code": 200, "data": {"nextCode": next_code}}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def get_collections(
|
|
||||||
category: Optional[str] = None,
|
|
||||||
status: Optional[str] = None,
|
|
||||||
search: Optional[str] = None,
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取藏品列表"""
|
|
||||||
# admin 用户可以看到所有藏品,普通用户只能看到自己的
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
if current_user.role == "admin":
|
|
||||||
# 联表查询获取用户名
|
|
||||||
query = db.query(Collection, User.f01_01_name.label('owner_name')).join(
|
|
||||||
User, Collection.f99_91_user_id == User.f99_90_id, isouter=True
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
query = db.query(Collection).filter(Collection.f99_91_user_id == current_user.f99_90_id)
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query = query.filter(Collection.f01_03_category == category)
|
|
||||||
if status:
|
|
||||||
query = query.filter(Collection.f01_04_status == status)
|
|
||||||
if search:
|
|
||||||
query = query.filter(
|
|
||||||
(Collection.f01_01_name.contains(search)) |
|
|
||||||
(Collection.f01_05_remark.contains(search))
|
|
||||||
)
|
|
||||||
|
|
||||||
# 总数(分开查询避免join影响count)
|
|
||||||
if current_user.role == "admin":
|
|
||||||
total = db.query(Collection).count()
|
|
||||||
else:
|
|
||||||
total = query.count()
|
|
||||||
|
|
||||||
# 分页
|
|
||||||
data = query.order_by(Collection.f99_92_created_at.desc()) \
|
|
||||||
.offset((page - 1) * limit) \
|
|
||||||
.limit(limit) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
# 转换为字典列表并转为 camelCase
|
|
||||||
data_list = []
|
|
||||||
for item in data:
|
|
||||||
# 处理联表查询结果
|
|
||||||
if current_user.role == "admin":
|
|
||||||
collection_item, owner_name = item
|
|
||||||
else:
|
|
||||||
collection_item = item
|
|
||||||
owner_name = None
|
|
||||||
|
|
||||||
item_dict = {
|
|
||||||
'f99_90_id': collection_item.f99_90_id,
|
|
||||||
'f99_91_user_id': collection_item.f99_91_user_id,
|
|
||||||
'owner_name': owner_name, # 所属用户名(仅管理员可见)
|
|
||||||
'f01_01_name': collection_item.f01_01_name,
|
|
||||||
'f01_02_code': collection_item.f01_02_code,
|
|
||||||
'f01_03_category': collection_item.f01_03_category,
|
|
||||||
'f01_04_status': collection_item.f01_04_status,
|
|
||||||
'f01_05_remark': collection_item.f01_05_remark,
|
|
||||||
'f02_10_prefix_serial': collection_item.f02_10_prefix_serial,
|
|
||||||
'f02_11_version': collection_item.f02_11_version,
|
|
||||||
'f02_12_packaging': collection_item.f02_12_packaging,
|
|
||||||
'f02_13_rarity': collection_item.f02_13_rarity,
|
|
||||||
'f02_14_number_category': collection_item.f02_14_number_category,
|
|
||||||
'f03_20_is_graded': collection_item.f03_20_is_graded,
|
|
||||||
'f03_21_grading_company': collection_item.f03_21_grading_company,
|
|
||||||
'f03_22_grading_score': collection_item.f03_22_grading_score,
|
|
||||||
'f03_23_three_star': collection_item.f03_23_three_star,
|
|
||||||
'f04_30_special_mark': collection_item.f04_30_special_mark,
|
|
||||||
'f04_31_serial_feature': collection_item.f04_31_serial_feature,
|
|
||||||
'f04_32_issuer': collection_item.f04_32_issuer,
|
|
||||||
'f04_33_issue_year': collection_item.f04_33_issue_year,
|
|
||||||
'f04_34_material': collection_item.f04_34_material,
|
|
||||||
'f04_35_denomination': collection_item.f04_35_denomination,
|
|
||||||
'f04_36_issue_quantity': collection_item.f04_36_issue_quantity,
|
|
||||||
'f05_40_cost_price': float(collection_item.f05_40_cost_price) if collection_item.f05_40_cost_price else None,
|
|
||||||
'f05_41_target_price': float(collection_item.f05_41_target_price) if collection_item.f05_41_target_price else None,
|
|
||||||
'f05_42_goal_price': float(collection_item.f05_42_goal_price) if collection_item.f05_42_goal_price else None,
|
|
||||||
'f05_43_repair_fee': float(collection_item.f05_43_repair_fee) if collection_item.f05_43_repair_fee else None,
|
|
||||||
'f05_44_grading_fee': float(collection_item.f05_44_grading_fee) if collection_item.f05_44_grading_fee else None,
|
|
||||||
'f06_50_purpose': collection_item.f06_50_purpose,
|
|
||||||
'f99_92_created_at': collection_item.f99_92_created_at.isoformat() if collection_item.f99_92_created_at else None,
|
|
||||||
'images': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# 加载图片数据
|
|
||||||
from app.models.models import CollectionImage
|
|
||||||
images = db.query(CollectionImage).filter(
|
|
||||||
CollectionImage.collection_id == collection_item.f99_90_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
for img in images:
|
|
||||||
item_dict['images'].append({
|
|
||||||
'id': img.id,
|
|
||||||
'filename': img.filename,
|
|
||||||
'original_name': img.original_name,
|
|
||||||
'path': img.path,
|
|
||||||
'created_at': img.created_at.isoformat() if img.created_at else None
|
|
||||||
})
|
|
||||||
|
|
||||||
data_list.append(to_camel_case(item_dict))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"data": data_list,
|
|
||||||
"pagination": {
|
|
||||||
"page": page,
|
|
||||||
"limit": limit,
|
|
||||||
"total": total,
|
|
||||||
"pages": (total + limit - 1) // limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def get_stats(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取藏品统计"""
|
|
||||||
# 获取所有藏品
|
|
||||||
if current_user.role == "admin":
|
|
||||||
all_collections = db.query(Collection).all()
|
|
||||||
else:
|
|
||||||
all_collections = db.query(Collection).filter(
|
|
||||||
Collection.f99_91_user_id == current_user.f99_90_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
# 总数
|
|
||||||
total_count = len(all_collections)
|
|
||||||
|
|
||||||
# 按分类统计
|
|
||||||
from collections import Counter
|
|
||||||
by_category = Counter(c.f01_03_category for c in all_collections).items()
|
|
||||||
|
|
||||||
# 按状态统计
|
|
||||||
by_status = Counter(c.f01_04_status for c in all_collections).items()
|
|
||||||
|
|
||||||
# 按是否评级统计
|
|
||||||
by_graded = Counter(c.f03_20_is_graded for c in all_collections).items()
|
|
||||||
|
|
||||||
# 新增:8 个分布统计
|
|
||||||
by_packaging = Counter(c.f02_12_packaging for c in all_collections if c.f02_12_packaging).items()
|
|
||||||
by_rarity = Counter(c.f02_13_rarity for c in all_collections if c.f02_13_rarity).items()
|
|
||||||
by_version = Counter(c.f02_11_version for c in all_collections if c.f02_11_version).items()
|
|
||||||
by_grading_company = Counter(c.f03_21_grading_company for c in all_collections if c.f03_20_is_graded and c.f03_21_grading_company).items()
|
|
||||||
by_grading_score = Counter(c.f03_22_grading_score for c in all_collections if c.f03_20_is_graded and c.f03_22_grading_score).items()
|
|
||||||
by_special_mark = Counter(c.f04_30_special_mark for c in all_collections if c.f04_30_special_mark).items()
|
|
||||||
by_number_category = Counter(c.f02_14_number_category for c in all_collections if c.f02_14_number_category).items()
|
|
||||||
|
|
||||||
# 总成本: SUM(cost_price + repair_fee + grading_fee) for ALL collections
|
|
||||||
total_cost = sum(
|
|
||||||
(c.f05_40_cost_price or 0) + (c.f05_43_repair_fee or 0) + (c.f05_44_grading_fee or 0)
|
|
||||||
for c in all_collections
|
|
||||||
)
|
|
||||||
|
|
||||||
# 预期利润: SUM(target_price - cost_price) for collections with target_price > 0
|
|
||||||
expected_profit = sum(
|
|
||||||
(c.f05_41_target_price or 0) - (c.f05_40_cost_price or 0)
|
|
||||||
for c in all_collections
|
|
||||||
if c.f05_41_target_price and c.f05_41_target_price > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# 已售商品:状态为 sold 且出售价 > 0
|
|
||||||
sold_collections = [
|
|
||||||
c for c in all_collections
|
|
||||||
if c.f01_04_status == 'sold' and c.f05_42_goal_price and c.f05_42_goal_price > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
# 总收入:SUM(出售价) for 已售商品(售价>0)
|
|
||||||
total_revenue = sum(
|
|
||||||
c.f05_42_goal_price or 0
|
|
||||||
for c in sold_collections
|
|
||||||
)
|
|
||||||
|
|
||||||
# 总利润(已实现利润):SUM(出售价 - 成本价 - 修复费 - 评级费) for 已售商品
|
|
||||||
# 单藏品总成本 = 成本价 + 修复费 + 评级费
|
|
||||||
total_profit = sum(
|
|
||||||
(c.f05_42_goal_price or 0) - (c.f05_40_cost_price or 0) - (c.f05_43_repair_fee or 0) - (c.f05_44_grading_fee or 0)
|
|
||||||
for c in sold_collections
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"totalCount": total_count,
|
|
||||||
"byCategory": [{"category": c, "count": n} for c, n in by_category],
|
|
||||||
"byStatus": [{"status": s, "count": n} for s, n in by_status],
|
|
||||||
"byGrading": [{"isGraded": g, "count": n} for g, n in by_graded],
|
|
||||||
"byPackaging": [{"packaging": p, "count": n} for p, n in by_packaging],
|
|
||||||
"byRarity": [{"rarity": r, "count": n} for r, n in by_rarity],
|
|
||||||
"byVersion": [{"version": v, "count": n} for v, n in by_version],
|
|
||||||
"byGradingCompany": [{"company": c, "count": n} for c, n in by_grading_company],
|
|
||||||
"byGradingScore": [{"score": s, "count": n} for s, n in by_grading_score],
|
|
||||||
"bySpecialMark": [{"mark": m, "count": n} for m, n in by_special_mark],
|
|
||||||
"byNumberCategory": [{"numberCategory": n, "count": c} for n, c in by_number_category],
|
|
||||||
# 盈亏统计(只统计已售且有价格的藏品)
|
|
||||||
"byProfitLoss": [
|
|
||||||
{"type": "profit", "label": "盈利", "count": sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price > c.f05_40_cost_price)},
|
|
||||||
{"type": "loss", "label": "亏损", "count": sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price <= c.f05_40_cost_price)}
|
|
||||||
],
|
|
||||||
"totalCost": total_cost,
|
|
||||||
"totalTarget": sum(c.f05_41_target_price or 0 for c in all_collections),
|
|
||||||
"expectedProfit": expected_profit,
|
|
||||||
"totalRevenue": total_revenue,
|
|
||||||
"totalProfit": total_profit
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{collection_id}")
|
|
||||||
def get_collection(
|
|
||||||
collection_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取单个藏品详情"""
|
|
||||||
result = db.execute(
|
|
||||||
text("SELECT * FROM collections WHERE f99_90_id = :id"),
|
|
||||||
{"id": collection_id}
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
|
||||||
|
|
||||||
collection = dict(result._mapping)
|
|
||||||
|
|
||||||
# 非管理员只能查看自己的藏品
|
|
||||||
if current_user.role != "admin" and collection.get('f99_91_user_id') != current_user.f99_90_id:
|
|
||||||
raise HTTPException(status_code=403, detail="无权访问")
|
|
||||||
|
|
||||||
result_dict = {
|
|
||||||
'f99_90_id': collection.get('f99_90_id'),
|
|
||||||
'f99_91_user_id': collection.get('f99_91_user_id'),
|
|
||||||
'f01_01_name': collection.get('f01_01_name'),
|
|
||||||
'f01_02_code': collection.get('f01_02_code'),
|
|
||||||
'f01_03_category': collection.get('f01_03_category'),
|
|
||||||
'f01_04_status': collection.get('f01_04_status'),
|
|
||||||
'f01_05_remark': collection.get('f01_05_remark'),
|
|
||||||
'f02_10_prefix_serial': collection.get('f02_10_prefix_serial'),
|
|
||||||
'f02_11_version': collection.get('f02_11_version'),
|
|
||||||
'f02_12_packaging': collection.get('f02_12_packaging'),
|
|
||||||
'f02_13_rarity': collection.get('f02_13_rarity'),
|
|
||||||
'f02_14_number_category': collection.get('f02_14_number_category'),
|
|
||||||
'f03_20_is_graded': collection.get('f03_20_is_graded'),
|
|
||||||
'f03_21_grading_company': collection.get('f03_21_grading_company'),
|
|
||||||
'f03_22_grading_score': collection.get('f03_22_grading_score'),
|
|
||||||
'f03_23_three_star': collection.get('f03_23_three_star'),
|
|
||||||
'f04_30_special_mark': collection.get('f04_30_special_mark'),
|
|
||||||
'f04_31_serial_feature': collection.get('f04_31_serial_feature'),
|
|
||||||
'f04_32_issuer': collection.get('f04_32_issuer'),
|
|
||||||
'f04_33_issue_year': collection.get('f04_33_issue_year'),
|
|
||||||
'f04_34_material': collection.get('f04_34_material'),
|
|
||||||
'f04_35_denomination': collection.get('f04_35_denomination'),
|
|
||||||
'f04_36_issue_quantity': collection.get('f04_36_issue_quantity'),
|
|
||||||
'f05_40_cost_price': float(collection.get('f05_40_cost_price')) if collection.get('f05_40_cost_price') else None,
|
|
||||||
'f05_41_target_price': float(collection.get('f05_41_target_price')) if collection.get('f05_41_target_price') else None,
|
|
||||||
'f05_42_goal_price': float(collection.get('f05_42_goal_price')) if collection.get('f05_42_goal_price') else None,
|
|
||||||
'f05_43_repair_fee': float(collection.get('f05_43_repair_fee')) if collection.get('f05_43_repair_fee') else None,
|
|
||||||
'f05_44_grading_fee': float(collection.get('f05_44_grading_fee')) if collection.get('f05_44_grading_fee') else None,
|
|
||||||
'f06_50_purpose': collection.get('f06_50_purpose'),
|
|
||||||
'f99_92_created_at': collection.get('f99_92_created_at').isoformat() if collection.get('f99_92_created_at') else None,
|
|
||||||
'images': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# 加载图片数据
|
|
||||||
images = db.query(CollectionImage).filter(
|
|
||||||
CollectionImage.collection_id == collection_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
for img in images:
|
|
||||||
result_dict['images'].append({
|
|
||||||
'id': img.id,
|
|
||||||
'filename': img.filename,
|
|
||||||
'original_name': img.original_name,
|
|
||||||
'path': img.path,
|
|
||||||
'created_at': img.created_at.isoformat() if img.created_at else None
|
|
||||||
})
|
|
||||||
|
|
||||||
return to_camel_case(result_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_collection(
|
|
||||||
collection_data: CollectionCreate,
|
|
||||||
force: bool = False, # 是否强制保存(忽略重复警告)
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""创建藏品 - 支持冠字号查重"""
|
|
||||||
from app.core.logging_config import logger
|
|
||||||
|
|
||||||
# 自动生成编码
|
|
||||||
final_code = collection_data.f01_02_code or generate_code(
|
|
||||||
collection_data.f02_11_version or '2024 龙',
|
|
||||||
current_user.f99_90_id,
|
|
||||||
db
|
|
||||||
)
|
|
||||||
|
|
||||||
# 编号查重(如果提供了编号且不是强制保存)
|
|
||||||
if not force and final_code:
|
|
||||||
existing_code = db.query(Collection).filter(
|
|
||||||
Collection.f01_02_code == final_code,
|
|
||||||
Collection.f99_91_user_id == current_user.f99_90_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_code:
|
|
||||||
logger.warning(f"发现重复编号:{final_code}, 已存在藏品 ID: {existing_code.f99_90_id}")
|
|
||||||
return {
|
|
||||||
"error": {
|
|
||||||
"code": "DUPLICATE_CODE",
|
|
||||||
"message": f"藏品编号 {final_code} 已存在,请使用其他编号"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 冠字号查重(如果提供了冠字号且不是强制保存)
|
|
||||||
if not force and collection_data.f02_10_prefix_serial:
|
|
||||||
# 查询当前用户是否有相同冠字号的藏品
|
|
||||||
existing = db.query(Collection).filter(
|
|
||||||
Collection.f02_10_prefix_serial == collection_data.f02_10_prefix_serial,
|
|
||||||
Collection.f99_91_user_id == current_user.f99_90_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
logger.warning(f"发现重复冠字号:{collection_data.f02_10_prefix_serial}, 已存在藏品 ID: {existing.f99_90_id}")
|
|
||||||
# 返回警告信息,让前端询问用户是否继续
|
|
||||||
return {
|
|
||||||
"warning": {
|
|
||||||
"code": "DUPLICATE_SERIAL",
|
|
||||||
"message": f"发现重复冠字号:{collection_data.f02_10_prefix_serial}",
|
|
||||||
"existing_collection": {
|
|
||||||
"id": existing.f99_90_id,
|
|
||||||
"name": existing.f01_01_name,
|
|
||||||
"code": existing.f01_02_code,
|
|
||||||
"prefix_serial": existing.f02_10_prefix_serial
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"ask_continue": True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
collection = Collection(
|
|
||||||
f99_91_user_id=current_user.f99_90_id,
|
|
||||||
f01_01_name=collection_data.f01_01_name,
|
|
||||||
f01_02_code=final_code,
|
|
||||||
f01_03_category=collection_data.f01_03_category,
|
|
||||||
f01_04_status=collection_data.f01_04_status or "in_collection",
|
|
||||||
f01_05_remark=collection_data.f01_05_remark,
|
|
||||||
f02_10_prefix_serial=collection_data.f02_10_prefix_serial,
|
|
||||||
f02_11_version=collection_data.f02_11_version,
|
|
||||||
f02_12_packaging=collection_data.f02_12_packaging,
|
|
||||||
f02_13_rarity=collection_data.f02_13_rarity,
|
|
||||||
f02_14_number_category=collection_data.f02_14_number_category,
|
|
||||||
f03_20_is_graded=collection_data.f03_20_is_graded or False,
|
|
||||||
f03_21_grading_company=collection_data.f03_21_grading_company,
|
|
||||||
f03_22_grading_score=collection_data.f03_22_grading_score,
|
|
||||||
f03_23_three_star=collection_data.f03_23_three_star or False,
|
|
||||||
f04_30_special_mark=collection_data.f04_30_special_mark,
|
|
||||||
f04_31_serial_feature=collection_data.f04_31_serial_feature,
|
|
||||||
f04_32_issuer=collection_data.f04_32_issuer,
|
|
||||||
f04_33_issue_year=collection_data.f04_33_issue_year,
|
|
||||||
f04_34_material=collection_data.f04_34_material,
|
|
||||||
f04_35_denomination=collection_data.f04_35_denomination,
|
|
||||||
f04_36_issue_quantity=collection_data.f04_36_issue_quantity,
|
|
||||||
f05_40_cost_price=collection_data.f05_40_cost_price,
|
|
||||||
f05_41_target_price=collection_data.f05_41_target_price,
|
|
||||||
f05_42_goal_price=collection_data.f05_42_goal_price,
|
|
||||||
f05_43_repair_fee=collection_data.f05_43_repair_fee,
|
|
||||||
f05_44_grading_fee=collection_data.f05_44_grading_fee,
|
|
||||||
f06_50_purpose=collection_data.f06_50_purpose
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(collection)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(collection)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'f99_90_id': collection.f99_90_id,
|
|
||||||
'f99_91_user_id': collection.f99_91_user_id,
|
|
||||||
'f01_01_name': collection.f01_01_name,
|
|
||||||
'f01_02_code': collection.f01_02_code,
|
|
||||||
'f01_03_category': collection.f01_03_category,
|
|
||||||
'f01_04_status': collection.f01_04_status,
|
|
||||||
'f01_05_remark': collection.f01_05_remark,
|
|
||||||
'f99_92_created_at': collection.f99_92_created_at.isoformat() if collection.f99_92_created_at else None,
|
|
||||||
'message': '创建成功'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{collection_id}")
|
|
||||||
def update_collection(
|
|
||||||
collection_id: str,
|
|
||||||
collection_data: CollectionUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""更新藏品"""
|
|
||||||
collection = db.query(Collection).filter(
|
|
||||||
Collection.f99_90_id == collection_id,
|
|
||||||
Collection.f99_91_user_id == current_user.f99_90_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not collection:
|
|
||||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
|
||||||
|
|
||||||
# 更新字段 - 使用 model_fields_set 检查哪些字段被设置
|
|
||||||
for field_name in collection_data.model_fields_set:
|
|
||||||
value = getattr(collection_data, field_name)
|
|
||||||
if value is not None:
|
|
||||||
setattr(collection, field_name, value)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(collection)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"f99_90_id": collection.f99_90_id,
|
|
||||||
"message": "更新成功"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{collection_id}")
|
|
||||||
def delete_collection(
|
|
||||||
collection_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""删除藏品"""
|
|
||||||
# 验证权限并检查是否存在
|
|
||||||
collection = db.query(Collection).filter(
|
|
||||||
Collection.f99_90_id == collection_id,
|
|
||||||
Collection.f99_91_user_id == current_user.f99_90_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not collection:
|
|
||||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
|
||||||
|
|
||||||
# 使用原生 SQL 删除(避免 ORM 级联查询字段不匹配问题)
|
|
||||||
from sqlalchemy import text
|
|
||||||
# 1. 删除关联的 operations(f99_91_user_id 关联到 collections.f99_90_id)
|
|
||||||
db.execute(text("DELETE FROM operations WHERE f99_91_user_id = :id"), {"id": collection_id})
|
|
||||||
# 2. 删除关联的图片
|
|
||||||
db.execute(text("DELETE FROM collection_images WHERE collection_id = :id"), {"id": collection_id})
|
|
||||||
# 3. 删除藏品本身
|
|
||||||
db.execute(text("DELETE FROM collections WHERE f99_90_id = :id"), {"id": collection_id})
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"message": "删除成功"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload-image")
|
|
||||||
async def upload_image(
|
|
||||||
collection_id: str = None,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""上传藏品图片 - 文件名格式:用户名 - 藏品编号 - 冠字号"""
|
|
||||||
try:
|
|
||||||
# 验证藏品是否存在
|
|
||||||
collection = db.query(Collection).filter(
|
|
||||||
Collection.f99_90_id == collection_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not collection:
|
|
||||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
|
||||||
|
|
||||||
# 获取用户信息(用于文件名)
|
|
||||||
owner = db.query(User).filter(User.f99_90_id == collection.f99_91_user_id).first()
|
|
||||||
username = owner.f01_01_name if owner else "unknown"
|
|
||||||
|
|
||||||
# 获取藏品信息(用于文件名)
|
|
||||||
code = collection.f01_02_code or "0000"
|
|
||||||
prefix_serial = collection.f02_10_prefix_serial or ""
|
|
||||||
|
|
||||||
# 检查文件类型
|
|
||||||
if not file.content_type.startswith('image/'):
|
|
||||||
raise HTTPException(status_code=400, detail="E00038: 只能上传图片文件")
|
|
||||||
|
|
||||||
# 检查文件大小(限制 10MB)
|
|
||||||
file_size = 0
|
|
||||||
content = await file.read()
|
|
||||||
file_size = len(content)
|
|
||||||
if file_size > 10 * 1024 * 1024: # 10MB
|
|
||||||
raise HTTPException(status_code=400, detail=f"E00039: 图片大小不能超过 10MB(当前{file_size // 1024 // 1024}MB)")
|
|
||||||
|
|
||||||
# 生成OSS存储路径
|
|
||||||
user_id = collection.f99_91_user_id
|
|
||||||
file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg'
|
|
||||||
|
|
||||||
# 清理特殊字符
|
|
||||||
clean_username = re.sub(r'[^\w\u4e00-\u9fff\-]', '', username)
|
|
||||||
clean_serial = re.sub(r'[^\w\u4e00-\u9fff\-]', '', prefix_serial)
|
|
||||||
|
|
||||||
# 文件名格式:用户名-藏品编号-冠字号.jpg
|
|
||||||
if clean_serial:
|
|
||||||
filename = f"{clean_username}-{code}-{clean_serial}.{file_extension}"
|
|
||||||
else:
|
|
||||||
filename = f"{clean_username}-{code}.{file_extension}"
|
|
||||||
|
|
||||||
# 生成OSS key
|
|
||||||
oss_key, unique_name = get_oss_path("collections", user_id=user_id, filename=filename)
|
|
||||||
|
|
||||||
# 上传到OSS
|
|
||||||
image_url = upload_to_oss(content, oss_key)
|
|
||||||
|
|
||||||
# 创建图片记录(保存OSS URL)
|
|
||||||
image = CollectionImage(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
collection_id=collection_id,
|
|
||||||
filename=unique_name,
|
|
||||||
original_name=file.filename,
|
|
||||||
path=image_url # 保存OSS URL
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(image)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(image)
|
|
||||||
|
|
||||||
logger.info(f"图片上传成功:{image_url}, collection_id={collection_id}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "上传成功",
|
|
||||||
"image_id": image.id,
|
|
||||||
"filename": unique_name,
|
|
||||||
"url": image_url
|
|
||||||
}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"图片上传失败:{str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="上传失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/images/{image_id}")
|
|
||||||
async def delete_image(
|
|
||||||
image_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""删除藏品图片"""
|
|
||||||
try:
|
|
||||||
# 查找图片记录
|
|
||||||
image = db.query(CollectionImage).filter(
|
|
||||||
CollectionImage.id == image_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not image:
|
|
||||||
raise HTTPException(status_code=404, detail="E00033: 图片不存在")
|
|
||||||
|
|
||||||
# 检查权限
|
|
||||||
collection = db.query(Collection).filter(
|
|
||||||
Collection.f99_90_id == image.collection_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if collection and current_user.role != "admin" and collection.f99_91_user_id != current_user.f99_90_id:
|
|
||||||
raise HTTPException(status_code=403, detail="E00014: 无权删除此图片")
|
|
||||||
|
|
||||||
# 删除OSS文件(如果path是OSS URL)
|
|
||||||
if image.path and image.path.startswith("https://"):
|
|
||||||
# 从OSS URL提取key
|
|
||||||
try:
|
|
||||||
oss_key = image.path.replace("https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com/", "")
|
|
||||||
delete_from_oss(oss_key)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"OSS文件删除失败: {e}")
|
|
||||||
elif image.path and os.path.exists(image.path):
|
|
||||||
# 兼容旧的本地上传
|
|
||||||
os.remove(image.path)
|
|
||||||
|
|
||||||
# 删除数据库记录
|
|
||||||
db.delete(image)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"message": "删除成功"}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"图片删除失败:{str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="删除失败")
|
|
||||||
|
|
@ -1,376 +0,0 @@
|
||||||
# OCR 识别路由 - 专业人民币生肖纪念钞鉴定
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import base64
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.database import get_db
|
|
||||||
from app.core.auth import get_current_user
|
|
||||||
from app.models.models import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"])
|
|
||||||
|
|
||||||
# 阿里云 DashScope API 配置
|
|
||||||
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f")
|
|
||||||
|
|
||||||
# 阿里云 OSS 配置
|
|
||||||
OSS_CONFIG = {
|
|
||||||
"access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
|
|
||||||
"access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
|
|
||||||
"bucket_name": "jiachenlong-oss",
|
|
||||||
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
|
||||||
"public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 临时上传目录(用于OCR识别本地备选)
|
|
||||||
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp")
|
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None):
|
|
||||||
"""生成OSS路径 - 按年/月/日分类"""
|
|
||||||
from datetime import datetime
|
|
||||||
now = datetime.now()
|
|
||||||
year = now.strftime("%Y")
|
|
||||||
month = now.strftime("%m")
|
|
||||||
day = now.strftime("%d")
|
|
||||||
|
|
||||||
if file_type == "temp":
|
|
||||||
# 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext}
|
|
||||||
import uuid
|
|
||||||
unique_id = str(uuid.uuid4())
|
|
||||||
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
|
||||||
return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id
|
|
||||||
|
|
||||||
elif file_type == "collection":
|
|
||||||
# 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename}
|
|
||||||
if not user_id or not collection_id:
|
|
||||||
raise ValueError("user_id and collection_id required for collection")
|
|
||||||
return f"collections/{user_id}/{year}/{collection_id}/{filename}"
|
|
||||||
|
|
||||||
elif file_type == "avatar":
|
|
||||||
# 头像: avatars/{user_id}/avatar.{ext}
|
|
||||||
if not user_id:
|
|
||||||
raise ValueError("user_id required for avatar")
|
|
||||||
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
|
||||||
return f"avatars/{user_id}/avatar.{ext}"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# 上传图片到OSS - 使用服务层(带压缩)
|
|
||||||
from app.services.oss import upload_to_oss as oss_upload
|
|
||||||
|
|
||||||
def upload_to_oss(file_data, oss_key):
|
|
||||||
"""上传文件到阿里云OSS(带自动压缩)"""
|
|
||||||
return oss_upload(file_data, oss_key)
|
|
||||||
|
|
||||||
# 专业提示词
|
|
||||||
PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。
|
|
||||||
|
|
||||||
【识别流程】
|
|
||||||
1. 判断类型是否评级钞:首先确认是否为裸钞还是评级钞(有封装盒和标签)
|
|
||||||
2. 验证纪念钞特征:对照生肖纪念钞特征进行确认
|
|
||||||
3. 验证评级类型:有'标十'字眼的为标十,有'百连'字眼的为标百,其他为单张
|
|
||||||
4. 提取信息:仔细阅读标签上的所有文字内容
|
|
||||||
|
|
||||||
【版别格式要求】
|
|
||||||
只需要:年份 + 属相,例如:
|
|
||||||
- 2024 龙
|
|
||||||
- 2025 蛇
|
|
||||||
- 2026 马
|
|
||||||
|
|
||||||
【输出要求】
|
|
||||||
严格按照以下格式输出,每个字段必须填写具体值:
|
|
||||||
✅ 1 发行机构:中国人民银行
|
|
||||||
✅ 2 发行版别:2024 龙
|
|
||||||
✅ 3 面额:贰拾圆
|
|
||||||
✅ 4 是否评级:是/否
|
|
||||||
✅ 5 封装类型:裸钞/单张/标十/标百
|
|
||||||
✅ 6 冠字序号:J0xxxxxxxx
|
|
||||||
✅ 7 评级机构:ACG/PCGS/PMG
|
|
||||||
✅ 8 评级分数:67/68/69
|
|
||||||
✅ 9 是否三星:是/否
|
|
||||||
✅ 10 特殊标识:金山标/天马标/红绳版等
|
|
||||||
✅ 11 号码特征:金山号 2 张,天马号 3 张等
|
|
||||||
|
|
||||||
现在请仔细分析提供的图片,按上述格式输出结果。"""
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recognize")
|
|
||||||
async def recognize_image(
|
|
||||||
image: UploadFile = File(...),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""OCR 图片识别 - 识别后自动保存图片到OSS临时目录"""
|
|
||||||
try:
|
|
||||||
# 读取图片数据
|
|
||||||
image_data = await image.read()
|
|
||||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
|
||||||
|
|
||||||
# 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext}
|
|
||||||
oss_key, temp_id = get_oss_path("temp", filename=image.filename)
|
|
||||||
|
|
||||||
# 上传到OSS
|
|
||||||
try:
|
|
||||||
image_url = upload_to_oss(image_data, oss_key)
|
|
||||||
except Exception as oss_err:
|
|
||||||
# OSS失败时保存到本地作为备选
|
|
||||||
temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1])
|
|
||||||
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
|
||||||
with open(temp_path, 'wb') as f:
|
|
||||||
f.write(image_data)
|
|
||||||
image_url = f"/uploads/temp/{oss_key.split('/')[-1]}"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型)
|
|
||||||
payload = {
|
|
||||||
"model": "qwen-vl-plus",
|
|
||||||
"input": {
|
|
||||||
"messages": [{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"image": f"data:{image.content_type};base64,{image_base64}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text": PROFESSIONAL_PROMPT
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"parameters": {
|
|
||||||
"max_tokens": 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
|
||||||
json=payload,
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
# 识别失败,删除临时文件
|
|
||||||
if os.path.exists(temp_path):
|
|
||||||
os.remove(temp_path)
|
|
||||||
raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}")
|
|
||||||
|
|
||||||
ocr_result = response.json()
|
|
||||||
text_content = ""
|
|
||||||
# 新版API返回格式
|
|
||||||
if "output" in ocr_result and "choices" in ocr_result["output"]:
|
|
||||||
choices = ocr_result["output"]["choices"]
|
|
||||||
if choices and len(choices) > 0:
|
|
||||||
content = choices[0].get("message", {}).get("content", [])
|
|
||||||
if content and len(content) > 0:
|
|
||||||
text_content = content[0].get("text", "")
|
|
||||||
|
|
||||||
fields = extract_fields(text_content)
|
|
||||||
|
|
||||||
# 返回识别结果和临时图片路径
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"text": text_content,
|
|
||||||
"fields": fields,
|
|
||||||
"temp_image": {
|
|
||||||
"id": temp_id,
|
|
||||||
"filename": oss_key.split('/')[-1],
|
|
||||||
"path": image_url,
|
|
||||||
"original_name": image.filename,
|
|
||||||
"is_oss": image_url.startswith("https://")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}"
|
|
||||||
raise HTTPException(status_code=500, detail=error_detail)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_fields(text: str) -> dict:
|
|
||||||
"""从 OCR 文本中提取字段 - 直接返回 AI 识别结果"""
|
|
||||||
import re
|
|
||||||
fields = {}
|
|
||||||
|
|
||||||
# 解析结构化输出
|
|
||||||
patterns = {
|
|
||||||
'issuer': r'✅.*?1.*?发行机构.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'version': r'✅.*?2.*?发行版别.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'denomination': r'✅.*?3.*?面额.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'is_graded_text': r'✅.*?4.*?是否评级.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'packaging': r'✅.*?5.*?封装类型.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'prefix_serial': r'✅.*?6.*?冠字序号.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'grading_company': r'✅.*?7.*?评级机构.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'grading_score': r'✅.*?8.*?评级分数.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'three_star_text': r'✅.*?9.*?是否三星.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'special_mark': r'✅.*?10.*?特殊标识.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
'serial_feature': r'✅.*?11.*?号码特征.*?[::]\s*(.+?)(?:\n|$)'
|
|
||||||
}
|
|
||||||
|
|
||||||
for field, pattern in patterns.items():
|
|
||||||
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
|
|
||||||
if match:
|
|
||||||
value = match.group(1).strip()
|
|
||||||
# 保留所有值,包括"无"和"未识别",让前端处理
|
|
||||||
fields[field] = value
|
|
||||||
|
|
||||||
# 处理是否评级
|
|
||||||
if 'is_graded_text' in fields:
|
|
||||||
fields['is_graded'] = '是' in fields.pop('is_graded_text')
|
|
||||||
|
|
||||||
# 处理是否三星
|
|
||||||
if 'three_star_text' in fields:
|
|
||||||
fields['three_star'] = '是' in fields.pop('three_star_text')
|
|
||||||
|
|
||||||
# 简化版别字段(2024 龙年贺岁纪念钞(标十) → 2024 龙)
|
|
||||||
if 'version' in fields:
|
|
||||||
version = fields['version']
|
|
||||||
# 提取年份和生肖
|
|
||||||
year_match = re.search(r'(20\d{2})', version)
|
|
||||||
animal = ''
|
|
||||||
if '龙' in version:
|
|
||||||
animal = '龙'
|
|
||||||
elif '蛇' in version:
|
|
||||||
animal = '蛇'
|
|
||||||
elif '马' in version:
|
|
||||||
animal = '马'
|
|
||||||
elif '羊' in version:
|
|
||||||
animal = '羊'
|
|
||||||
elif '猴' in version:
|
|
||||||
animal = '猴'
|
|
||||||
elif '鸡' in version:
|
|
||||||
animal = '鸡'
|
|
||||||
elif '狗' in version:
|
|
||||||
animal = '狗'
|
|
||||||
elif '猪' in version:
|
|
||||||
animal = '猪'
|
|
||||||
elif '鼠' in version:
|
|
||||||
animal = '鼠'
|
|
||||||
elif '牛' in version:
|
|
||||||
animal = '牛'
|
|
||||||
elif '虎' in version:
|
|
||||||
animal = '虎'
|
|
||||||
elif '兔' in version:
|
|
||||||
animal = '兔'
|
|
||||||
|
|
||||||
if year_match and animal:
|
|
||||||
fields['version'] = f"{year_match.group(1)}{animal}"
|
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/claim-temp-image")
|
|
||||||
async def claim_temp_image(
|
|
||||||
temp_id: str,
|
|
||||||
collection_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类"""
|
|
||||||
from app.models.models import Collection, CollectionImage
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# 验证藏品是否存在
|
|
||||||
collection = db.query(Collection).filter(
|
|
||||||
Collection.f99_90_id == collection_id,
|
|
||||||
Collection.f99_91_user_id == current_user.f99_90_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not collection:
|
|
||||||
raise HTTPException(status_code=404, detail="藏品不存在")
|
|
||||||
|
|
||||||
# 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename}
|
|
||||||
code = collection.f01_02_code or "0000"
|
|
||||||
prefix = collection.f02_10_prefix_serial or ""
|
|
||||||
username = current_user.f01_01_name
|
|
||||||
import time
|
|
||||||
final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg"
|
|
||||||
oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename)
|
|
||||||
|
|
||||||
# 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径
|
|
||||||
temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']
|
|
||||||
temp_content = None
|
|
||||||
found_key = None
|
|
||||||
|
|
||||||
# 尝试最近7天的路径
|
|
||||||
from datetime import timedelta
|
|
||||||
for i in range(7):
|
|
||||||
date = datetime.now() - timedelta(days=i)
|
|
||||||
year = date.strftime("%Y")
|
|
||||||
month = date.strftime("%m")
|
|
||||||
day = date.strftime("%d")
|
|
||||||
|
|
||||||
for ext in temp_extensions:
|
|
||||||
try:
|
|
||||||
temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}"
|
|
||||||
import oss2
|
|
||||||
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
|
|
||||||
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
|
|
||||||
temp_content = bucket.get_object(temp_oss_key).read()
|
|
||||||
found_key = temp_oss_key
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
if temp_content:
|
|
||||||
break
|
|
||||||
|
|
||||||
if temp_content:
|
|
||||||
# 上传到正式目录
|
|
||||||
bucket.put_object(oss_key, temp_content)
|
|
||||||
|
|
||||||
# 删除临时图片
|
|
||||||
try:
|
|
||||||
bucket.delete_object(found_key)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# OSS URL
|
|
||||||
image_path = f"{OSS_CONFIG['public_url']}/{oss_key}"
|
|
||||||
|
|
||||||
else:
|
|
||||||
# OSS失败,使用本地文件
|
|
||||||
temp_path = None
|
|
||||||
for ext in temp_extensions:
|
|
||||||
temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}")
|
|
||||||
if os.path.exists(temp_path):
|
|
||||||
break
|
|
||||||
|
|
||||||
if not temp_path or not os.path.exists(temp_path):
|
|
||||||
raise HTTPException(status_code=404, detail="临时图片不存在或已过期")
|
|
||||||
|
|
||||||
# 保存到本地
|
|
||||||
collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections")
|
|
||||||
os.makedirs(collection_dir, exist_ok=True)
|
|
||||||
|
|
||||||
new_path = os.path.join(collection_dir, final_filename)
|
|
||||||
import shutil
|
|
||||||
shutil.move(temp_path, new_path)
|
|
||||||
image_path = f"uploads/collections/{final_filename}"
|
|
||||||
|
|
||||||
# 创建图片记录
|
|
||||||
image_record = CollectionImage(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
collection_id=collection.f99_90_id,
|
|
||||||
filename=final_filename,
|
|
||||||
original_name=temp_id,
|
|
||||||
path=image_path
|
|
||||||
)
|
|
||||||
db.add(image_record)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"image": {
|
|
||||||
"id": image_record.id,
|
|
||||||
"filename": image_record.filename,
|
|
||||||
"path": image_record.path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
# 操作路由
|
|
||||||
from typing import List, Optional
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.database import get_db
|
|
||||||
from app.core.auth import get_current_user
|
|
||||||
from app.models.models import User, Collection, Operation
|
|
||||||
from app.schemas.schemas import OperationCreate, OperationResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["操作"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/operations", response_model=List[OperationResponse])
|
|
||||||
def get_operations(
|
|
||||||
collection_id: Optional[str] = None,
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取操作历史"""
|
|
||||||
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
|
||||||
|
|
||||||
if collection_id:
|
|
||||||
query = query.filter(Operation.collection_id == collection_id)
|
|
||||||
|
|
||||||
operations = query.order_by(Operation.created_at.desc()) \
|
|
||||||
.offset((page - 1) * limit) \
|
|
||||||
.limit(limit) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return operations
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/operations/history")
|
|
||||||
def get_operation_history(
|
|
||||||
collection_id: Optional[str] = None,
|
|
||||||
type: Optional[str] = None,
|
|
||||||
start_date: Optional[str] = None,
|
|
||||||
end_date: Optional[str] = None,
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取操作历史(带统计)"""
|
|
||||||
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
|
||||||
|
|
||||||
if collection_id:
|
|
||||||
query = query.filter(Operation.collection_id == collection_id)
|
|
||||||
if type:
|
|
||||||
query = query.filter(Operation.type == type)
|
|
||||||
if start_date:
|
|
||||||
query = query.filter(Operation.created_at >= start_date)
|
|
||||||
if end_date:
|
|
||||||
query = query.filter(Operation.created_at <= end_date)
|
|
||||||
|
|
||||||
total = query.count()
|
|
||||||
|
|
||||||
data = query.order_by(Operation.created_at.desc()) \
|
|
||||||
.offset((page - 1) * limit) \
|
|
||||||
.limit(limit) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"data": data,
|
|
||||||
"pagination": {
|
|
||||||
"page": page,
|
|
||||||
"limit": limit,
|
|
||||||
"total": total,
|
|
||||||
"pages": (total + limit - 1) // limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/operations", response_model=OperationResponse)
|
|
||||||
def create_operation(
|
|
||||||
operation_data: OperationCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""创建操作记录"""
|
|
||||||
# 验证藏品存在
|
|
||||||
collection = db.query(Collection).filter(
|
|
||||||
Collection.id == operation_data.collection_id,
|
|
||||||
Collection.user_id == current_user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not collection:
|
|
||||||
raise HTTPException(status_code=404, detail="藏品不存在")
|
|
||||||
|
|
||||||
operation = Operation(
|
|
||||||
collection_id=operation_data.collection_id,
|
|
||||||
user_id=current_user.id,
|
|
||||||
type=operation_data.type,
|
|
||||||
price=operation_data.price,
|
|
||||||
note=operation_data.note
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(operation)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(operation)
|
|
||||||
|
|
||||||
return operation
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
# 用户管理路由
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.database import get_db
|
|
||||||
from app.core.auth import get_current_user
|
|
||||||
from app.models.models import User, Collection
|
|
||||||
from app.schemas.schemas import UserResponse, UserUpdate
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["用户"])
|
|
||||||
|
|
||||||
# ============ 当前用户接口 ============
|
|
||||||
|
|
||||||
@router.get("/users/me", response_model=UserResponse)
|
|
||||||
def get_current_user_info(
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""获取当前登录用户信息"""
|
|
||||||
return {
|
|
||||||
"f99_90_id": current_user.f99_90_id,
|
|
||||||
"f01_01_name": current_user.f01_01_name,
|
|
||||||
"email": current_user.email,
|
|
||||||
"phone": current_user.phone,
|
|
||||||
"avatar": current_user.avatar,
|
|
||||||
"address": current_user.address,
|
|
||||||
"bio": current_user.bio,
|
|
||||||
"role": current_user.role,
|
|
||||||
"f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None,
|
|
||||||
"f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.put("/users/me", response_model=UserResponse)
|
|
||||||
def update_current_user(
|
|
||||||
user_update: UserUpdate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""更新当前用户信息"""
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 获取用户ID
|
|
||||||
user_id = current_user.f99_90_id
|
|
||||||
logger.info(f"Updating user {user_id}, data={user_update.model_dump()}")
|
|
||||||
|
|
||||||
# 在当前session中重新查询用户
|
|
||||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
|
||||||
|
|
||||||
# 更新字段
|
|
||||||
update_data = user_update.model_dump(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
if field == 'f01_01_name':
|
|
||||||
user.f01_01_name = value
|
|
||||||
elif field == 'username':
|
|
||||||
pass # skip, already handled as f01_01_name
|
|
||||||
elif hasattr(user, field):
|
|
||||||
setattr(user, field, value)
|
|
||||||
|
|
||||||
# 强制刷新以确保更新被提交
|
|
||||||
db.flush()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
logger.info(f"After commit, user email={user.email}")
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
# ============ 管理员用户管理 ============
|
|
||||||
|
|
||||||
admin_router = APIRouter(prefix="/api/admin/users", tags=["用户管理"])
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("")
|
|
||||||
def get_users(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取用户列表(仅管理员)"""
|
|
||||||
if current_user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="无权访问")
|
|
||||||
|
|
||||||
total = db.query(User).count()
|
|
||||||
users = db.query(User).offset((page-1)*limit).limit(limit).all()
|
|
||||||
|
|
||||||
user_list = []
|
|
||||||
for u in users:
|
|
||||||
# 统计每个用户的藏品数量
|
|
||||||
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
|
|
||||||
user_list.append({
|
|
||||||
"id": u.f99_90_id,
|
|
||||||
"username": u.f01_01_name,
|
|
||||||
"email": u.email,
|
|
||||||
"phone": u.phone,
|
|
||||||
"role": u.role,
|
|
||||||
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
|
|
||||||
"collection_count": count
|
|
||||||
})
|
|
||||||
|
|
||||||
return user_list
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/{user_id}")
|
|
||||||
def get_user(
|
|
||||||
user_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取单个用户信息"""
|
|
||||||
if current_user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="无权访问")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"email": user.email,
|
|
||||||
"phone": user.phone,
|
|
||||||
"role": user.role,
|
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/{user_id}/collections")
|
|
||||||
def get_user_collections(
|
|
||||||
user_id: str,
|
|
||||||
limit: int = Query(100, ge=1, le=100),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取指定用户的藏品列表"""
|
|
||||||
if current_user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="无权访问")
|
|
||||||
|
|
||||||
collections = db.query(Collection).filter(
|
|
||||||
Collection.user_id == user_id
|
|
||||||
).limit(limit).all()
|
|
||||||
|
|
||||||
return [c.code for c in collections]
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/{user_id}/count")
|
|
||||||
def get_user_collection_count(
|
|
||||||
user_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""获取指定用户的藏品数量"""
|
|
||||||
if current_user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
|
||||||
|
|
||||||
count = db.query(Collection).filter(Collection.user_id == user_id).count()
|
|
||||||
return {"count": count}
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.put("/{user_id}")
|
|
||||||
def update_user(
|
|
||||||
user_id: str,
|
|
||||||
username: Optional[str] = Body(None),
|
|
||||||
email: Optional[str] = Body(None),
|
|
||||||
role: Optional[str] = Body(None),
|
|
||||||
password: Optional[str] = Body(None),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""更新用户信息(仅管理员)"""
|
|
||||||
if current_user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="E00051: 用户不存在")
|
|
||||||
|
|
||||||
# 更新基本信息
|
|
||||||
if username:
|
|
||||||
user.f01_01_name = username
|
|
||||||
if email:
|
|
||||||
user.email = email
|
|
||||||
if role is not None:
|
|
||||||
user.role = role
|
|
||||||
|
|
||||||
# 更新密码
|
|
||||||
if password and password.strip():
|
|
||||||
from app.core.auth import get_password_hash
|
|
||||||
user.password = get_password_hash(password)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": user.f99_90_id,
|
|
||||||
"username": user.f01_01_name,
|
|
||||||
"email": user.email,
|
|
||||||
"role": user.role,
|
|
||||||
"message": "更新成功"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.delete("/{user_id}")
|
|
||||||
def delete_user(
|
|
||||||
user_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""删除用户(仅管理员)"""
|
|
||||||
if current_user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
|
||||||
|
|
||||||
# 不能删除自己
|
|
||||||
if user_id == str(current_user.f99_90_id):
|
|
||||||
raise HTTPException(status_code=400, detail="E00052: 不能删除自己")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="E00051: 用户不存在")
|
|
||||||
|
|
||||||
db.delete(user)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"message": "删除成功"}
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
# Pydantic Schema - 使用字段编码并支持 camelCase
|
|
||||||
from typing import Optional, List
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 用户相关 ============
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
|
||||||
f01_01_name: str = Field(..., min_length=3, max_length=255, alias="username")
|
|
||||||
email: Optional[EmailStr] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
avatar: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
bio: Optional[str] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
|
||||||
password: str = Field(..., min_length=6)
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
f01_01_name: Optional[str] = Field(None, alias="username")
|
|
||||||
email: Optional[EmailStr] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
avatar: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
bio: Optional[str] = None
|
|
||||||
password: Optional[str] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
|
||||||
f99_90_id: str = Field(..., alias="id")
|
|
||||||
f01_01_name: str = Field(..., alias="username")
|
|
||||||
role: str
|
|
||||||
f99_92_created_at: Optional[datetime] = Field(None, alias="created_at")
|
|
||||||
f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at")
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 藏品相关 ============
|
|
||||||
|
|
||||||
class CollectionBase(BaseModel):
|
|
||||||
# f01 基本信息
|
|
||||||
f01_01_name: str = Field(..., min_length=2, max_length=255, alias="name")
|
|
||||||
f01_02_code: Optional[str] = Field(None, max_length=50, alias="code")
|
|
||||||
f01_03_category: str = Field(..., max_length=100, alias="category")
|
|
||||||
f01_04_status: Optional[str] = Field("in_collection", alias="status")
|
|
||||||
f01_05_remark: Optional[str] = Field(None, alias="remark")
|
|
||||||
|
|
||||||
# f02 详细字段
|
|
||||||
f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial")
|
|
||||||
f02_11_version: Optional[str] = Field(None, alias="version")
|
|
||||||
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
|
||||||
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
|
||||||
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
|
|
||||||
|
|
||||||
# f03 评级信息
|
|
||||||
f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded")
|
|
||||||
f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany")
|
|
||||||
f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore")
|
|
||||||
f03_23_three_star: Optional[bool] = Field(False, alias="threeStar")
|
|
||||||
|
|
||||||
# f04 特殊信息
|
|
||||||
f04_30_special_mark: Optional[str] = Field(None, alias="specialMark")
|
|
||||||
f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature")
|
|
||||||
f04_32_issuer: Optional[str] = Field(None, alias="issuer")
|
|
||||||
f04_33_issue_year: Optional[str] = Field(None, alias="issueYear")
|
|
||||||
f04_34_material: Optional[str] = Field(None, alias="material")
|
|
||||||
f04_35_denomination: Optional[str] = Field(None, alias="denomination")
|
|
||||||
f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity")
|
|
||||||
|
|
||||||
# f05 价格信息
|
|
||||||
f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice")
|
|
||||||
f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice")
|
|
||||||
f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice")
|
|
||||||
f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee")
|
|
||||||
f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee")
|
|
||||||
|
|
||||||
# f06 其他信息
|
|
||||||
f06_50_purpose: Optional[str] = Field(None, alias="purpose")
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionCreate(CollectionBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionUpdate(BaseModel):
|
|
||||||
# f01 基本信息
|
|
||||||
f01_01_name: Optional[str] = Field(None, alias="name")
|
|
||||||
f01_02_code: Optional[str] = Field(None, alias="code")
|
|
||||||
f01_03_category: Optional[str] = Field(None, alias="category")
|
|
||||||
f01_04_status: Optional[str] = Field(None, alias="status")
|
|
||||||
f01_05_remark: Optional[str] = Field(None, alias="remark")
|
|
||||||
|
|
||||||
# f02 详细字段
|
|
||||||
f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial")
|
|
||||||
f02_11_version: Optional[str] = Field(None, alias="version")
|
|
||||||
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
|
||||||
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
|
||||||
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
|
|
||||||
|
|
||||||
# f03 评级信息
|
|
||||||
f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded")
|
|
||||||
f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany")
|
|
||||||
f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore")
|
|
||||||
f03_23_three_star: Optional[bool] = Field(None, alias="threeStar")
|
|
||||||
|
|
||||||
# f04 特殊信息
|
|
||||||
f04_30_special_mark: Optional[str] = Field(None, alias="specialMark")
|
|
||||||
f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature")
|
|
||||||
f04_32_issuer: Optional[str] = Field(None, alias="issuer")
|
|
||||||
f04_33_issue_year: Optional[str] = Field(None, alias="issueYear")
|
|
||||||
f04_34_material: Optional[str] = Field(None, alias="material")
|
|
||||||
f04_35_denomination: Optional[str] = Field(None, alias="denomination")
|
|
||||||
f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity")
|
|
||||||
|
|
||||||
# f05 价格信息
|
|
||||||
f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice")
|
|
||||||
f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice")
|
|
||||||
f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice")
|
|
||||||
f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee")
|
|
||||||
f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee")
|
|
||||||
|
|
||||||
# f06 其他信息
|
|
||||||
f06_50_purpose: Optional[str] = Field(None, alias="purpose")
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionImageResponse(BaseModel):
|
|
||||||
f99_90_id: str
|
|
||||||
filename: str
|
|
||||||
original_name: Optional[str] = None
|
|
||||||
path: Optional[str] = None
|
|
||||||
f99_92_created_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionResponse(CollectionBase):
|
|
||||||
f99_90_id: str = Field(..., alias="id")
|
|
||||||
f99_91_user_id: str = Field(..., alias="userId")
|
|
||||||
f99_92_created_at: Optional[datetime] = Field(None, alias="createdAt")
|
|
||||||
f99_93_updated_at: Optional[datetime] = Field(None, alias="updatedAt")
|
|
||||||
images: List[CollectionImageResponse] = []
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionListResponse(BaseModel):
|
|
||||||
data: List[CollectionResponse]
|
|
||||||
pagination: dict
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 操作日志相关 ============
|
|
||||||
|
|
||||||
class OperationBase(BaseModel):
|
|
||||||
type: str = Field(..., max_length=50)
|
|
||||||
price: Optional[float] = None
|
|
||||||
note: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class OperationCreate(OperationBase):
|
|
||||||
f99_91_user_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class OperationResponse(OperationBase):
|
|
||||||
f99_90_id: str
|
|
||||||
f99_91_user_id: str
|
|
||||||
f99_93_created_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ============ OCR 相关 ============
|
|
||||||
|
|
||||||
class OCRRequest(BaseModel):
|
|
||||||
image: str
|
|
||||||
ocr_provider: Optional[str] = "aliyun"
|
|
||||||
|
|
||||||
|
|
||||||
class OCRResponse(BaseModel):
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
fields: Optional[dict] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 通用响应 ============
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
|
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
|
||||||
f99_90_user_id: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
|
||||||
detail: str
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# 阿里云OSS服务
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import datetime
|
|
||||||
from typing import Optional
|
|
||||||
import oss2
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
|
|
||||||
# OSS配置
|
|
||||||
OSS_CONFIG = {
|
|
||||||
"access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
|
|
||||||
"access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
|
|
||||||
"bucket_name": "jiachenlong-oss",
|
|
||||||
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
|
||||||
"public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 图片压缩配置
|
|
||||||
IMAGE_CONFIG = {
|
|
||||||
"max_size": 1024 * 1024, # 1MB
|
|
||||||
"max_width": 2048,
|
|
||||||
"max_height": 2048,
|
|
||||||
"quality": 85,
|
|
||||||
"format": "JPEG"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 初始化OSS
|
|
||||||
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
|
|
||||||
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str:
|
|
||||||
"""生成OSS存储路径"""
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
year = now.strftime("%Y")
|
|
||||||
month = now.strftime("%m")
|
|
||||||
day = now.strftime("%d")
|
|
||||||
|
|
||||||
if filename:
|
|
||||||
ext = filename.split('.')[-1] if '.' in filename else 'jpg'
|
|
||||||
unique_name = f"{uuid.uuid4().hex}.{ext}"
|
|
||||||
else:
|
|
||||||
unique_name = f"{uuid.uuid4().hex}.jpg"
|
|
||||||
|
|
||||||
if user_id:
|
|
||||||
path = f"{prefix}/{user_id}/{year}/{month}/{unique_name}"
|
|
||||||
else:
|
|
||||||
path = f"{prefix}/{year}/{month}/{day}/{unique_name}"
|
|
||||||
|
|
||||||
return path, unique_name
|
|
||||||
|
|
||||||
|
|
||||||
def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str:
|
|
||||||
"""上传文件到OSS,返回公网URL"""
|
|
||||||
try:
|
|
||||||
# 如果是图片,进行压缩
|
|
||||||
if compress and any(oss_key.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp']):
|
|
||||||
file_data = compress_image(file_data)
|
|
||||||
|
|
||||||
# 上传文件
|
|
||||||
result = bucket.put_object(oss_key, file_data)
|
|
||||||
|
|
||||||
if result.status == 200:
|
|
||||||
# 返回公网URL
|
|
||||||
return f"{OSS_CONFIG['public_url']}/{oss_key}"
|
|
||||||
else:
|
|
||||||
raise Exception(f"OSS上传失败: {result.status}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"OSS上传失败: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def delete_from_oss(oss_key: str) -> bool:
|
|
||||||
"""从OSS删除文件"""
|
|
||||||
try:
|
|
||||||
result = bucket.delete_object(oss_key)
|
|
||||||
return result.status == 204
|
|
||||||
except Exception as e:
|
|
||||||
print(f"OSS删除失败: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_public_url(oss_key: str) -> str:
|
|
||||||
"""获取公网URL"""
|
|
||||||
return f"{OSS_CONFIG['public_url']}/{oss_key}"
|
|
||||||
|
|
||||||
|
|
||||||
def compress_image(image_data: bytes, max_size: int = None) -> bytes:
|
|
||||||
"""压缩图片到指定大小以内"""
|
|
||||||
if max_size is None:
|
|
||||||
max_size = IMAGE_CONFIG["max_size"]
|
|
||||||
|
|
||||||
# 如果已经小于限制,直接返回
|
|
||||||
if len(image_data) <= max_size:
|
|
||||||
return image_data
|
|
||||||
|
|
||||||
# 打开图片
|
|
||||||
img = Image.open(io.BytesIO(image_data))
|
|
||||||
|
|
||||||
# 如果是PNG且有透明通道,转换为RGB
|
|
||||||
if img.mode in ('RGBA', 'P'):
|
|
||||||
img = img.convert('RGB')
|
|
||||||
|
|
||||||
# 逐步降低质量直到达到目标大小
|
|
||||||
quality = 95
|
|
||||||
compressed_data = image_data
|
|
||||||
|
|
||||||
while quality > 30 and len(compressed_data) > max_size:
|
|
||||||
output = io.BytesIO()
|
|
||||||
img.save(output, format=IMAGE_CONFIG["format"], quality=quality, optimize=True)
|
|
||||||
compressed_data = output.getvalue()
|
|
||||||
quality -= 10
|
|
||||||
|
|
||||||
# 如果还是太大,缩小尺寸
|
|
||||||
if len(compressed_data) > max_size:
|
|
||||||
width, height = img.size
|
|
||||||
while len(compressed_data) > max_size and width > 400:
|
|
||||||
width = int(width * 0.8)
|
|
||||||
height = int(height * 0.8)
|
|
||||||
img_resized = img.resize((width, height), Image.Resampling.LANCZOS)
|
|
||||||
output = io.BytesIO()
|
|
||||||
img_resized.save(output, format=IMAGE_CONFIG["format"], quality=80, optimize=True)
|
|
||||||
compressed_data = output.getvalue()
|
|
||||||
|
|
||||||
return compressed_data
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
# 阿里云短信服务
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# 阿里云短信配置
|
|
||||||
SMS_CONFIG = {
|
|
||||||
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
|
|
||||||
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
|
|
||||||
"sign_name": "阿里云",
|
|
||||||
"template_code": "100001",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 验证码缓存(生产环境建议用Redis)
|
|
||||||
# 格式: { phone: { code: "123456", expire: 1234567890 } }
|
|
||||||
VERIFICATION_CODES = {}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_code(length: int = 6) -> str:
|
|
||||||
"""生成6位数字验证码"""
|
|
||||||
return ''.join(random.choices(string.digits, k=length))
|
|
||||||
|
|
||||||
|
|
||||||
def send_verification_code(phone: str) -> dict:
|
|
||||||
"""发送短信验证码"""
|
|
||||||
from alibabacloud_dysmsapi20170525 import models
|
|
||||||
from alibabacloud_dysmsapi20170525.client import Client
|
|
||||||
from alibabacloud_tea_openapi import models as open_models
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 生成验证码
|
|
||||||
code = generate_code(6)
|
|
||||||
|
|
||||||
# 配置客户端
|
|
||||||
config = open_models.Config(
|
|
||||||
access_key_id=SMS_CONFIG["access_key_id"],
|
|
||||||
access_key_secret=SMS_CONFIG["access_key_secret"],
|
|
||||||
)
|
|
||||||
config.endpoint = "dysmsapi.aliyuncs.com"
|
|
||||||
config.region_id = "cn-hangzhou"
|
|
||||||
|
|
||||||
client = Client(config)
|
|
||||||
|
|
||||||
# 构造请求
|
|
||||||
request = models.SendSmsRequest(
|
|
||||||
phone_numbers=phone,
|
|
||||||
sign_name=SMS_CONFIG["sign_name"],
|
|
||||||
template_code=SMS_CONFIG["template_code"],
|
|
||||||
template_param=f'{{"code":"{code}"}}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# 发送
|
|
||||||
response = client.send_sms(request)
|
|
||||||
|
|
||||||
# 检查结果
|
|
||||||
if response.body.code == "OK":
|
|
||||||
# 保存验证码
|
|
||||||
VERIFICATION_CODES[phone] = {
|
|
||||||
"code": code,
|
|
||||||
"expire": int(time.time()) + 300 # 5分钟有效
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "验证码已发送",
|
|
||||||
"expire": 300
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": f"发送失败: {response.body.message}"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": f"发送失败: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def verify_code(phone: str, code: str) -> bool:
|
|
||||||
"""验证验证码"""
|
|
||||||
if phone not in VERIFICATION_CODES:
|
|
||||||
return False
|
|
||||||
|
|
||||||
stored = VERIFICATION_CODES[phone]
|
|
||||||
|
|
||||||
# 检查是否过期
|
|
||||||
if int(time.time()) > stored["expire"]:
|
|
||||||
del VERIFICATION_CODES[phone]
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 验证码匹配
|
|
||||||
if stored["code"] == code:
|
|
||||||
# 验证成功,删除验证码
|
|
||||||
del VERIFICATION_CODES[phone]
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_code_exists(phone: str) -> bool:
|
|
||||||
"""检查是否已发送过验证码"""
|
|
||||||
return phone in VERIFICATION_CODES
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# 甲辰藏品管理系统后端 - Python 依赖
|
|
||||||
# 版本:v1.0.0
|
|
||||||
|
|
||||||
fastapi==0.109.0
|
|
||||||
uvicorn[standard]==0.27.0
|
|
||||||
sqlalchemy==2.0.25
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
pydantic==2.5.3
|
|
||||||
python-jose[cryptography]==3.3.0
|
|
||||||
bcrypt==4.1.2
|
|
||||||
python-multipart==0.0.6
|
|
||||||
pillow==10.2.0
|
|
||||||
dashscope==1.14.1
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const TEST_CONST = "test_value_12345";
|
|
||||||
12
start.sh
12
start.sh
|
|
@ -1,12 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
cd /root/jiachenlong/backend
|
|
||||||
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/zodiac
|
|
||||||
export SECRET_KEY=production-secret-key-c-env
|
|
||||||
export OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
|
||||||
export OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
|
||||||
export SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
|
|
||||||
export SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
|
|
||||||
export SMS_SIGN_NAME=苏州算力
|
|
||||||
export SMS_TEMPLATE_CODE=SMS_501590956
|
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &
|
|
||||||
echo '后端服务已启动'
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<title>用户协议 - 甲辰收藏</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; line-height: 1.8; background: #f5f5f5; }
|
|
||||||
h1 { text-align: center; color: #1a1a1a; font-size: 24px; margin-bottom: 30px; }
|
|
||||||
h2 { color: #333; font-size: 18px; margin-top: 30px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 8px; }
|
|
||||||
h3 { color: #555; font-size: 15px; margin-top: 20px; margin-bottom: 10px; }
|
|
||||||
.content { color: #444; font-size: 14px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
|
||||||
.content p { margin: 10px 0; }
|
|
||||||
.highlight { font-weight: bold; }
|
|
||||||
.version { text-align: center; color: #999; font-size: 12px; margin-bottom: 20px; }
|
|
||||||
ul { padding-left: 20px; }
|
|
||||||
li { margin: 5px 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="content">
|
|
||||||
<h1>甲辰收藏平台用户协议</h1>
|
|
||||||
<div class="version">版本生效日期:自注册之日起</div>
|
|
||||||
|
|
||||||
<p><span class="highlight">特别提示:</span>本协议涉及您的重大权利义务,加粗条款请您重点阅读。您完成注册、登录或继续使用本平台服务,即视为已充分阅读、理解并同意本协议全部内容。本协议适用《中华人民共和国民法典》《网络安全法》《个人信息保护法》《电子商务法》等法律法规。</p>
|
|
||||||
|
|
||||||
<h2>一、协议主体与适用范围</h2>
|
|
||||||
<ol>
|
|
||||||
<li>本协议由您与甲辰收藏平台运营主体(以下简称"平台")签订,约束您使用平台藏品管理,信息展示,数据存储,社区互动等全部服务。</li>
|
|
||||||
<li>平台发布的隐私政策、藏品发布规则、交易规范等均为本协议组成部分,与本协议具有同等法律效力。</li>
|
|
||||||
<li>您承诺为具备完全民事行为能力的自然人/法人;未成年人使用需监护人同意并陪同。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>二、个人信息收集与使用</h2>
|
|
||||||
<h3>(一)收集原则</h3>
|
|
||||||
<p>平台遵循合法、正当、必要、诚信、最小必要原则收集信息,仅为实现服务功能所必需,不强制收集非必要信息。</p>
|
|
||||||
|
|
||||||
<h3>(二)收集范围与目的</h3>
|
|
||||||
<ol>
|
|
||||||
<li><strong>注册与身份信息:</strong>用户名、手机号、邮箱(用于账号创建,安全验证、客服联系)。</li>
|
|
||||||
<li><strong>藏品与行为信息:</strong>藏品上传内容、收藏记录、浏览操作、发布评论、交易数据(用于藏品管理,服务优化、风控核验)。</li>
|
|
||||||
<li><strong>设备与网络信息:</strong>设备型号、系统版本、IP 地址、日志信息(用于安全防护、故障排查,防作弊)。</li>
|
|
||||||
<li><strong>位置信息:</strong>仅在您主动开启定位权限时收集,用于同城展示等可选功能,关闭不影响基础服务。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>(三)信息使用规则</h3>
|
|
||||||
<ol>
|
|
||||||
<li>平台仅在您授权范围内使用信息,不超出约定目的、范围与期限处理。</li>
|
|
||||||
<li>向第三方共享信息时,将单独告知并取得您明确同意,法律法规要求除外。</li>
|
|
||||||
<li>您有权查阅、更正、删除个人信息,申请注销账号,平台在核验身份后依法处理。</li>
|
|
||||||
<li>平台采取加密、去标识化、权限管控等措施保护信息安全,制定安全事件应急预案。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>三、公开信息使用与免责条款</h2>
|
|
||||||
<ol>
|
|
||||||
<li><strong>公开信息定义:</strong>您通过平台主动发布、设置为公开可见的藏品资料、图文、评论、动态等内容,均属于您自行公开的信息。</li>
|
|
||||||
<li><strong>授权使用:</strong>您同意平台可在合理范围内使用您的公开信息,用于藏品展示、平台运营、合规审核、服务推广等,不侵犯您合法权益。</li>
|
|
||||||
<li><strong>法定免责依据:</strong>根据《个人信息保护法》第二十七条,平台处理您自行合法公开的信息,在无明确拒绝且不对您权益造成重大影响的情形下,无需另行取得单独同意。</li>
|
|
||||||
<li><strong>第三方行为免责:</strong>
|
|
||||||
<ul>
|
|
||||||
<li>公开信息可被其他用户浏览、复制、存储、转发,平台无法完全控制第三方使用行为。</li>
|
|
||||||
<li>因第三方擅自使用、转载、篡改您公开信息引发的纠纷、损失,平台不承担责任。</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><strong>内容责任自负:</strong>
|
|
||||||
<ul>
|
|
||||||
<li>您对公开信息的真实性、合法性、原创性承担全部责任,不得侵犯第三方知识产权、肖像权、名誉权等。</li>
|
|
||||||
<li>如因您发布违法、侵权内容导致平台受损,您应承担全部赔偿责任。</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><strong>平台管理边界:</strong>平台仅依据法律法规与平台规则进行内容审核,对用户自主发布的公开信息不做修改、篡改,不承担真实性担保责任。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>四、用户权利与义务</h2>
|
|
||||||
<ol>
|
|
||||||
<li>您有权使用平台基础服务,对账号与密码安全负责,不得转借、出售账号。</li>
|
|
||||||
<li>不得发布违法违规、侵权、虚假、低俗等违反法律法规与公序良俗的内容。</li>
|
|
||||||
<li>不得利用平台从事洗钱、诈骗、非法交易等违法活动。</li>
|
|
||||||
<li>您对自行上传的藏品素材、文字等享有知识产权,授权平台在服务范围内非独占使用。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>五、知识产权条款</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台所有文字、图标、界面设计、软件代码等知识产权归平台所有,受法律保护。</li>
|
|
||||||
<li>您上传的原创内容知识产权归您所有;您授权平台为提供服务之目的,使用、存储、展示、传播该内容。</li>
|
|
||||||
<li>未经权利人书面许可,任何主体不得复制、改编、传播、商用平台内容或用户原创内容。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>六、服务变更、中断与终止</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台因维护、升级、政策调整需变更或暂停服务的,将提前公示;因不可抗力、监管要求、第三方故障导致服务中断的,平台不承担违约责任。</li>
|
|
||||||
<li>平台有权对违规账号采取警示、限流、删帖、封禁等处理措施。</li>
|
|
||||||
<li>您可随时停止使用服务;账号注销后,平台按法律法规留存相关数据,逾期依法删除。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>七、免责声明(法律允许范围内)</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台按"现状"提供服务,对服务及时性、安全性、稳定性不作绝对担保。</li>
|
|
||||||
<li>平台不对藏品真伪、价值、权属作明示或暗示保证,藏品鉴定与价值判断由您自行负责。</li>
|
|
||||||
<li>法律允许的最大范围内,平台不对间接损失、利润损失、数据丢失等承担赔偿责任;因平台故意或重大过失导致的损失除外。</li>
|
|
||||||
<li>因您自身操作不当、账号保管不善、第三方侵权等导致的损失,由您自行承担。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>八、协议修改与争议解决</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台修改协议将提前7日公示,您继续使用视为接受修订;如不同意,可停止使用并注销账号。</li>
|
|
||||||
<li>因本协议产生争议,双方协商解决;协商不成,提交平台运营主体所在地有管辖权的人民法院诉讼解决。</li>
|
|
||||||
<li>本协议适用中华人民共和国大陆地区法律(不含港澳台法律)。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>九、联系与通知</h2>
|
|
||||||
<p>平台联系方式:aicoolbot@163.com;您可通过客服渠道咨询协议、隐私、投诉等相关事宜。</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Loading…
Reference in New Issue