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 识别、统计分析等功能。
@ -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
```
### 配置文件
所有配置文件在 `config/` 目录:
- `config/VERSION` - 版本号配置
- `config/docker-compose.yml` - Docker 部署配置
- `.gitignore` - Git 忽略配置(根目录)
### 静态资源
所有静态资源在 `static/` 目录:
- `static/images/` - 图片资源Logo、背景图等
- `static/icons/` - 图标资源favicon、应用图标等
- `static/fonts/` - 字体文件
### 部署流程
1. C环境测试 → B环境灰度 → A环境发布
2. 每次发布需更新 config/VERSION 文件
3. 提交代码到Gitea
---
## 📋 文档
## 📋 核心功能
所有文档都在 `docs/` 目录下:
- `docs/DEPLOYMENT_v1.0.0.md` - 部署指南
- `docs/RELEASE_v1.0.0.md` - 发布说明
- `docs/ERROR_CODES.md` - 错误码
- `docs/部署手册.md` - 完整部署手册
- `docs/BACKEND_SERVICE_GUIDE.md` - 后端服务指南
- `docs/TEST_REPORT.md` - 测试报告
- ✅ 用户管理(管理员/普通用户)
- ✅ 藏品管理CRUD
- ✅ OCR 识别阿里云DashScope
- ✅ 统计分析
- ✅ 图片上传阿里云OSS
- ✅ 移动端适配
- ✅ 操作日志记录
- ✅ 管理页面查看用户藏品
---
## 🛠️ 技术栈
## 🔧 技术栈
**后端**:
- FastAPI + SQLAlchemy
- PostgreSQL
- PostgreSQL 15+
- JWT 认证
- DashScope OCR
- DashScope OCR (阿里云)
- 阿里云OSS存储
**前端**:
- React + Vite
- 移动端适配
- 移动端适配PWA
- 暗色主题
---
## 📊 核心功能
## 📊 数据库表
- ✅ 用户管理(管理员/普通用户)
- ✅ 藏品管理CRUD
- ✅ OCR 识别
- ✅ 统计分析
- ✅ 图片上传
- ✅ 移动端适配
- `users` - 用户表
- `collections` - 藏品表
- `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):
log_data = {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'timestamp': datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),

View File

@ -13,6 +13,43 @@ router = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/register", response_model=UserResponse)
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()
if existing_user:

View File

@ -4,7 +4,7 @@ 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 import or_, func, text
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.auth import get_current_user
@ -101,6 +101,7 @@ def get_next_code(
def get_collections(
category: Optional[str] = None,
status: Optional[str] = None,
user_id: Optional[str] = None,
search: Optional[str] = None,
page: int = Query(1, ge=1),
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
)
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:
query = query.filter(Collection.f01_03_category == category)
if user_id:
query = query.filter(Collection.f99_91_user_id == user_id)
if status:
query = query.filter(Collection.f01_04_status == status)
if search:
@ -224,7 +227,7 @@ def get_stats(
all_collections = db.query(Collection).all()
else:
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()
# 总数
@ -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}")
def get_collection(
collection_id: str,
@ -396,7 +437,7 @@ def create_collection(
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
or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin")
).first()
if existing_code:
@ -413,7 +454,7 @@ def create_collection(
# 查询当前用户是否有相同冠字号的藏品
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
or_(Collection.f99_91_user_id == current_user.f99_90_id, current_user.role == "admin")
).first()
if existing:
@ -492,7 +533,7 @@ def update_collection(
"""更新藏品"""
collection = db.query(Collection).filter(
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()
if not collection:
@ -523,7 +564,7 @@ def delete_collection(
# 验证权限并检查是否存在
collection = db.query(Collection).filter(
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()
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获取临时图片 - 尝试多种扩展名和日期路径
temp_extensions = ['jpg', 'jpeg', 'png', 'gif']
temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']
temp_content = None
found_key = None

View File

@ -48,6 +48,27 @@ def update_current_user(
if not user:
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)
for field, value in update_data.items():
@ -55,6 +76,8 @@ def update_current_user(
user.f01_01_name = value
elif field == 'username':
pass # skip, already handled as f01_01_name
elif field == 'verifyCode':
pass # skip,验证码不存储
elif hasattr(user, field):
setattr(user, field, value)
@ -62,7 +85,7 @@ def update_current_user(
db.flush()
db.commit()
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

View File

@ -7,7 +7,7 @@ from datetime import datetime
# ============ 用户相关 ============
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
phone: Optional[str] = None
avatar: Optional[str] = None
@ -18,13 +18,15 @@ class UserBase(BaseModel):
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):
f01_01_name: Optional[str] = Field(None, alias="username")
email: Optional[EmailStr] = None
email: Optional[str] = None # 改为字符串不强制验证EmailStr
phone: Optional[str] = None
verifyCode: Optional[str] = Field(None, alias="verifyCode") # 更换手机号验证码
avatar: Optional[str] = None
address: Optional[str] = None
bio: Optional[str] = None

View File

@ -8,10 +8,10 @@ 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",
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
"sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
}
# 验证码缓存生产环境建议用Redis
@ -82,6 +82,11 @@ def send_verification_code(phone: str) -> dict:
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:
return False

View File

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

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<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">
<title>甲辰收藏 v1.1.19</title>
<title>甲辰收藏 v1.1.21</title>
<!-- Favicon -->
<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