清理多余目录和文件

This commit is contained in:
甲辰生产 2026-03-31 21:04:48 +08:00
parent 83c8410627
commit c522b772f6
39 changed files with 0 additions and 5900 deletions

View File

@ -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

View File

@ -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"
}
}

View File

@ -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>
)
}

View File

@ -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;
}

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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'
}
})

View File

@ -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>

View File

@ -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>

View File

@ -1,9 +0,0 @@
__pycache__/
*.pyc
*.pyo
.git
.env
uploads/*
!uploads/.gitkeep
logs/*
*.log

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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
}
}
)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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": "验证码错误或已过期"}

View File

@ -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. 删除关联的 operationsf99_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="删除失败")

View File

@ -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
}
}

View File

@ -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

View File

@ -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": "删除成功"}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1 +0,0 @@
export const TEST_CONST = "test_value_12345";

View File

@ -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 '后端服务已启动'

View File

@ -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>