Compare commits

...

4 Commits
main ... v1.2.0

Author SHA1 Message Date
甲辰生产 088dc90661 feat: 管理员可查看用户藏品列表 v1.2.0 2026-03-20 01:00:48 +08:00
甲辰生产 c78640edb8 docs: 更新README.md - v1.1.22 2026-03-20 00:38:14 +08:00
甲辰生产 63718824a9 feat: 管理页面可查看用户藏品列表 - v1.1.22 2026-03-20 00:36:32 +08:00
甲辰生产 17fb08b487 fix: 管理员可编辑所有用户藏品 - v1.1.22 2026-03-20 00:25:53 +08:00
20 changed files with 191 additions and 70 deletions

102
README.md
View File

@ -1,8 +1,8 @@
# 甲辰藏品管理系统 # 甲辰藏品管理系统
**版本**: v1.0.0 **版本**: v1.1.22
**代号**: 新生 **代号**: 新生
**发布日期**: 2026-03-16 **发布日期**: 2026-03-20
生肖纪念钞收藏管理系统 - 支持藏品管理、OCR 识别、统计分析等功能。 生肖纪念钞收藏管理系统 - 支持藏品管理、OCR 识别、统计分析等功能。
@ -23,6 +23,30 @@ jiachenlong/
--- ---
## 🖥️ 环境配置
### 生产环境 (A环境) - 已锁定
| 角色 | IP | 服务 |
|------|-----|------|
| 负载均衡/Nginx | 39.106.51.77 | Nginx反向代理 |
| 前端 | 8.154.46.3 | React静态页面 |
| 后端 | 42.121.116.25:3000 | FastAPI |
| 数据库 | 47.98.171.101 | PostgreSQL |
### 测试环境 (C环境)
| 角色 | IP | 服务 |
|------|-----|------|
| 前端 | 47.103.29.111 | Nginx + React |
| 后端 | 47.103.9.192:3000 | FastAPI |
| 数据库 | 本地 | PostgreSQL |
### 运维信息
- **负载均衡密码**: Jiachenlong123
- **C环境SSH密码**: Passwd1@3
- **Gitea仓库**: http://47.253.189.47:3000/coolbot/jiachenlong.git
---
## 🚀 快速开始 ## 🚀 快速开始
### 后端启动 ### 后端启动
@ -55,61 +79,63 @@ npm run dev
npm run build npm run build
``` ```
### 配置文件 ### 部署流程
1. C环境测试 → B环境灰度 → A环境发布
所有配置文件在 `config/` 目录: 2. 每次发布需更新 config/VERSION 文件
3. 提交代码到Gitea
- `config/VERSION` - 版本号配置
- `config/docker-compose.yml` - Docker 部署配置
- `.gitignore` - Git 忽略配置(根目录)
### 静态资源
所有静态资源在 `static/` 目录:
- `static/images/` - 图片资源Logo、背景图等
- `static/icons/` - 图标资源favicon、应用图标等
- `static/fonts/` - 字体文件
--- ---
## 📋 文档 ## 📋 核心功能
所有文档都在 `docs/` 目录下: - ✅ 用户管理(管理员/普通用户)
- ✅ 藏品管理CRUD
- `docs/DEPLOYMENT_v1.0.0.md` - 部署指南 - ✅ OCR 识别阿里云DashScope
- `docs/RELEASE_v1.0.0.md` - 发布说明 - ✅ 统计分析
- `docs/ERROR_CODES.md` - 错误码 - ✅ 图片上传阿里云OSS
- `docs/部署手册.md` - 完整部署手册 - ✅ 移动端适配
- `docs/BACKEND_SERVICE_GUIDE.md` - 后端服务指南 - ✅ 操作日志记录
- `docs/TEST_REPORT.md` - 测试报告 - ✅ 管理页面查看用户藏品
--- ---
## 🛠️ 技术栈 ## 🔧 技术栈
**后端**: **后端**:
- FastAPI + SQLAlchemy - FastAPI + SQLAlchemy
- PostgreSQL - PostgreSQL 15+
- JWT 认证 - JWT 认证
- DashScope OCR - DashScope OCR (阿里云)
- 阿里云OSS存储
**前端**: **前端**:
- React + Vite - React + Vite
- 移动端适配 - 移动端适配PWA
- 暗色主题 - 暗色主题
--- ---
## 📊 核心功能 ## 📊 数据库表
- ✅ 用户管理(管理员/普通用户) - `users` - 用户表
- ✅ 藏品管理CRUD - `collections` - 藏品表
- ✅ OCR 识别 - `collection_images` - 藏品图片表
- ✅ 统计分析 - `operations` - 操作日志表
- ✅ 图片上传 - `custom_fields` - 自定义字段表
- ✅ 移动端适配
--- ---
**开发团队**: 酷博特 + 菜鸟小 D 🤖 ## 📝 字段命名规范
采用 fXX_XX_XXX 格式:
- f01 - 基本信息
- f02 - 详细字段
- f03 - 评级信息
- f04 - 特殊信息
- f05 - 价格信息
- f06 - 其他
- f99 - 系统字段
---
**开发团队**: 酷博特 + 菜鸟生产 🤖

View File

@ -12,7 +12,7 @@ class JSONFormatter(logging.Formatter):
def format(self, record): def format(self, record):
log_data = { log_data = {
'timestamp': datetime.utcnow().isoformat() + 'Z', 'timestamp': datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'level': record.levelname, 'level': record.levelname,
'logger': record.name, 'logger': record.name,
'message': record.getMessage(), 'message': record.getMessage(),

View File

@ -13,6 +13,43 @@ router = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/register", response_model=UserResponse) @router.post("/register", response_model=UserResponse)
def register(user_data: UserCreate, db: Session = Depends(get_db)): def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""用户注册""" """用户注册"""
# 验证短信验证码
if user_data.phone and user_data.verifyCode:
from app.services.sms import verify_code
if not verify_code(user_data.phone, user_data.verifyCode):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期"
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="手机号和验证码为必填项"
)
# 验证密码复杂度至少8位包含大写、小写、数字
if len(user_data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="密码至少8位"
)
import re
if not re.search(r'[A-Z]', user_data.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="密码必须包含大写字母"
)
if not re.search(r'[a-z]', user_data.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="密码必须包含小写字母"
)
if not re.search(r'[0-9]', user_data.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="密码必须包含数字"
)
# 检查用户名是否已存在 # 检查用户名是否已存在
existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first() existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
if existing_user: if existing_user:

View File

@ -4,7 +4,7 @@ import uuid
import re import re
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
from sqlalchemy import func, text from sqlalchemy import or_, func, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.auth import get_current_user from app.core.auth import get_current_user
@ -101,6 +101,7 @@ def get_next_code(
def get_collections( def get_collections(
category: Optional[str] = None, category: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
user_id: Optional[str] = None,
search: Optional[str] = None, search: Optional[str] = None,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
@ -117,10 +118,12 @@ def get_collections(
User, Collection.f99_91_user_id == User.f99_90_id, isouter=True User, Collection.f99_91_user_id == User.f99_90_id, isouter=True
) )
else: else:
query = db.query(Collection).filter(Collection.f99_91_user_id == current_user.f99_90_id) query = db.query(Collection).filter(or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin"))
if category: if category:
query = query.filter(Collection.f01_03_category == category) query = query.filter(Collection.f01_03_category == category)
if user_id:
query = query.filter(Collection.f99_91_user_id == user_id)
if status: if status:
query = query.filter(Collection.f01_04_status == status) query = query.filter(Collection.f01_04_status == status)
if search: if search:
@ -224,7 +227,7 @@ def get_stats(
all_collections = db.query(Collection).all() all_collections = db.query(Collection).all()
else: else:
all_collections = db.query(Collection).filter( all_collections = db.query(Collection).filter(
Collection.f99_91_user_id == current_user.f99_90_id or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin")
).all() ).all()
# 总数 # 总数
@ -304,6 +307,44 @@ def get_stats(
} }
@router.get("/by-user/{user_id}")
def get_collections_by_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: 仅管理员可访问")
collections = db.query(Collection).filter(
Collection.f99_91_user_id == user_id
).all()
result = []
for c in collections:
result.append({
"f99_90_id": c.f99_90_id,
"name": c.f01_01_name,
"code": c.f01_02_code,
"prefixSerial": c.f02_10_prefix_serial,
"version": c.f02_11_version,
"category": c.f01_03_category,
"status": c.f01_04_status,
"packaging": c.f02_12_packaging,
"isGraded": c.f03_20_is_graded,
"costPrice": c.f05_40_cost_price,
"f01_01_name": c.f01_01_name,
"f02_10_prefix_serial": c.f02_10_prefix_serial,
"f02_11_version": c.f02_11_version,
"f01_04_status": c.f01_04_status,
"userId": c.f99_91_user_id,
"f05_40_cost_price": c.f05_40_cost_price,
"f99_92_created_at": c.f99_92_created_at.isoformat() if c.f99_92_created_at else None
})
return result
@router.get("/{collection_id}") @router.get("/{collection_id}")
def get_collection( def get_collection(
collection_id: str, collection_id: str,
@ -396,7 +437,7 @@ def create_collection(
if not force and final_code: if not force and final_code:
existing_code = db.query(Collection).filter( existing_code = db.query(Collection).filter(
Collection.f01_02_code == final_code, Collection.f01_02_code == final_code,
Collection.f99_91_user_id == current_user.f99_90_id or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin")
).first() ).first()
if existing_code: if existing_code:
@ -413,7 +454,7 @@ def create_collection(
# 查询当前用户是否有相同冠字号的藏品 # 查询当前用户是否有相同冠字号的藏品
existing = db.query(Collection).filter( existing = db.query(Collection).filter(
Collection.f02_10_prefix_serial == collection_data.f02_10_prefix_serial, Collection.f02_10_prefix_serial == collection_data.f02_10_prefix_serial,
Collection.f99_91_user_id == current_user.f99_90_id or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin")
).first() ).first()
if existing: if existing:
@ -492,7 +533,7 @@ def update_collection(
"""更新藏品""" """更新藏品"""
collection = db.query(Collection).filter( collection = db.query(Collection).filter(
Collection.f99_90_id == collection_id, Collection.f99_90_id == collection_id,
Collection.f99_91_user_id == current_user.f99_90_id or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin")
).first() ).first()
if not collection: if not collection:
@ -523,7 +564,7 @@ def delete_collection(
# 验证权限并检查是否存在 # 验证权限并检查是否存在
collection = db.query(Collection).filter( collection = db.query(Collection).filter(
Collection.f99_90_id == collection_id, Collection.f99_90_id == collection_id,
Collection.f99_91_user_id == current_user.f99_90_id or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin")
).first() ).first()
if not collection: if not collection:

View File

@ -296,7 +296,7 @@ async def claim_temp_image(
oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename) oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename)
# 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径 # 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径
temp_extensions = ['jpg', 'jpeg', 'png', 'gif'] temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']
temp_content = None temp_content = None
found_key = None found_key = None

View File

@ -48,6 +48,27 @@ def update_current_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 处理手机号更换验证码
new_phone = user_update.phone
verify_code = user_update.verifyCode
if new_phone and new_phone != user.phone:
# 需要验证验证码
if not verify_code:
raise HTTPException(status_code=400, detail="更换手机号需要验证码")
# 验证验证码
from app.services.sms import verify_code as sms_verify
import os
if os.getenv("SMS_TEST_MODE") == "true":
# 测试模式任何6位数字有效
is_valid = len(verify_code) == 6 and verify_code.isdigit()
else:
is_valid = sms_verify(new_phone, verify_code)
if not is_valid:
raise HTTPException(status_code=400, detail="验证码错误或已过期")
# 更新字段 # 更新字段
update_data = user_update.model_dump(exclude_unset=True) update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@ -55,6 +76,8 @@ def update_current_user(
user.f01_01_name = value user.f01_01_name = value
elif field == 'username': elif field == 'username':
pass # skip, already handled as f01_01_name pass # skip, already handled as f01_01_name
elif field == 'verifyCode':
pass # skip,验证码不存储
elif hasattr(user, field): elif hasattr(user, field):
setattr(user, field, value) setattr(user, field, value)
@ -62,7 +85,7 @@ def update_current_user(
db.flush() db.flush()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
logger.info(f"After commit, user email={user.email}") logger.info(f"After commit, user email={user.email}, phone={user.phone}")
return user return user

View File

@ -7,7 +7,7 @@ from datetime import datetime
# ============ 用户相关 ============ # ============ 用户相关 ============
class UserBase(BaseModel): class UserBase(BaseModel):
f01_01_name: str = Field(..., min_length=3, max_length=255, alias="username") f01_01_name: str = Field(..., min_length=2, max_length=255, alias="username")
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
phone: Optional[str] = None phone: Optional[str] = None
avatar: Optional[str] = None avatar: Optional[str] = None
@ -18,13 +18,15 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str = Field(..., min_length=6) password: str = Field(..., min_length=8)
verifyCode: Optional[str] = Field(None, alias="verifyCode")
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
f01_01_name: Optional[str] = Field(None, alias="username") f01_01_name: Optional[str] = Field(None, alias="username")
email: Optional[EmailStr] = None email: Optional[str] = None # 改为字符串不强制验证EmailStr
phone: Optional[str] = None phone: Optional[str] = None
verifyCode: Optional[str] = Field(None, alias="verifyCode") # 更换手机号验证码
avatar: Optional[str] = None avatar: Optional[str] = None
address: Optional[str] = None address: Optional[str] = None
bio: Optional[str] = None bio: Optional[str] = None

View File

@ -8,10 +8,10 @@ from typing import Optional
# 阿里云短信配置 # 阿里云短信配置
SMS_CONFIG = { SMS_CONFIG = {
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"), "access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"), "access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
"sign_name": "阿里云", "sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
"template_code": "100001", "template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
} }
# 验证码缓存生产环境建议用Redis # 验证码缓存生产环境建议用Redis
@ -82,6 +82,11 @@ def send_verification_code(phone: str) -> dict:
def verify_code(phone: str, code: str) -> bool: def verify_code(phone: str, code: str) -> bool:
"""验证验证码""" """验证验证码"""
# 测试模式任何6位数字验证码都有效
import os
if os.getenv("SMS_TEST_MODE") == "true":
return len(code) == 6 and code.isdigit()
if phone not in VERIFICATION_CODES: if phone not in VERIFICATION_CODES:
return False return False

View File

@ -1,14 +1 @@
# 甲辰藏品管理系统 - 版本配置 v1.2.0
# Version Configuration for Zodiac Collection Management System
# 当前版本号 (语义化版本:主版本。次版本.修订版)
VERSION=1.1.21
# 版本代号 (可选)
VERSION_CODENAME="新生"
# 发布日期
RELEASE_DATE=2026-03-18
# 版本说明
VERSION_NOTES="性能优化 - Nginx反向代理、图片OSS直连、自动压缩、上传限制20MB"

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>甲辰收藏 v1.1.19</title> <title>甲辰收藏 v1.1.21</title>
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" />

0
static/README.md Normal file → Executable file
View File

0
static/fonts/.gitkeep Normal file → Executable file
View File

0
static/fonts/README.md Normal file → Executable file
View File

0
static/icons/.gitkeep Normal file → Executable file
View File

0
static/icons/README.md Normal file → Executable file
View File

0
static/images/.gitkeep Normal file → Executable file
View File

0
static/images/LOGO_GUIDE.md Normal file → Executable file
View File

0
static/images/README.md Normal file → Executable file
View File

0
static/images/jiachenlong-logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

0
static/images/title_logo.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB