diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d65637a57c4f373d54a6135f45dafa766fc0093d..2f01fca2c0cc9656943f894757ce7b28ffbca3aa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -43,7 +43,69 @@ "Bash(node:*)", "Bash(git remote set-url:*)", "Bash(git checkout:*)", - "Bash(wc:*)" + "Bash(wc:*)", + "Bash(npx cap init:*)", + "Bash(npx cap add:*)", + "Bash(npx cap sync)", + "Bash(timeout:*)", + "Bash(taskkill /f /im node.exe)", + "Bash(netstat -ano)", + "Bash(taskkill *)", + "Bash(xargs grep *)", + "Bash(./setup-local-env.bat *)", + "Bash(npm --version)", + "Bash(killall python *)", + "Bash(ipconfig)", + "Bash(findstr \"IPv4\")", + "Bash(taskkill /F /IM node.exe)", + "Bash(cd /d \"C:\\\\claudecode\\\\foot-erp\\\\frontend\")", + "Bash(cd /d C:\\\\claudecode\\\\foot-erp\\\\frontend)", + "Bash(del /s /q dist\\\\*)", + "Bash(rmdir /s /q dist)", + "Bash(tasklist)", + "Bash(findstr node *)", + "Bash(ping -n 10 127.0.0.1)", + "Bash(findstr :8000)", + "Bash(kill 7108 19780 6480 14556 17384 17152)", + "Bash(START_FULL_SYSTEM.bat *)", + "Bash(findstr \"5173\")", + "Bash(findstr \"3011\")", + "Bash(findstr \"8000\")", + "Bash(findstr \"LISTENING\")", + "Bash(kill 13372 *)", + "Bash(Get-ChildItem c:claudecodefoot-erp *)", + "Bash(Get-ChildItem -Path c:\\\\claudecode\\\\foot-erp\\\\backend -Directory)", + "Bash(Select-Object Name)", + "Bash(Select-String -Pattern \"orders\")", + "Bash(Get-ChildItem *)", + "Bash(Test-Path *)", + "Bash(New-Item *)", + "Bash(mv test-qr.html test-simple.html test_login.html test-deployment.sh test/)", + "Bash(mv frontend/mobile-test-guide.md frontend/test_consumer_ui.js frontend/test-mobile.sh frontend/vitest.config.js test/frontend/)", + "Bash(mv backend/test_auth.py backend/test_consumer_api.py test/backend/)", + "Bash(START_BACKEND.bat *)", + "Bash(echo \"EXIT_CODE=$?\")", + "Bash(echo \"Django PID: $!\")", + "Bash(Start-Sleep -Seconds 3)", + "Bash(Select-String \":8000\")", + "Bash(netstat -anp tcp)", + "Bash(netstat -anp tcp -o)", + "Bash(Stop-Process -Id 6824 -Force)", + "Bash(Stop-Process -Id 21872 -Force)", + "Bash(powershell *)", + "Bash(Select-String \"maintenance|package\")", + "Bash(Get-Content *)", + "Bash(Select-String \"attendance|clock\")", + "Bash(Remove-Item -Path \"backend\\\\urls.py\" -Force)", + "Bash(Remove-Item -Path \"backend\\\\apps\\\\auth\\\\jwt_auth.py\" -Force)", + "Bash(Remove-Item -Path \"backend\\\\apps\\\\attendance\\\\models_commented.py\" -Force)", + "Bash(Remove-Item -Path \"backend\\\\apps\\\\technicians\\\\models_commented.py\" -Force)", + "Bash(Remove-Item -Path \"backend\\\\foot_erp\\\\tokens.py\" -Force)", + "Bash(cp -r backend/app/orders/* backend/apps/orders/)", + "Bash(cp -r backend/app/reports/* backend/apps/reports/)", + "Bash(cp -r backend/app/mall/* backend/apps/mall/)", + "Bash(cp -r backend/app/chat/* backend/apps/chat/)", + "Bash(cp -r backend/app/feedback/* backend/apps/feedback/)" ] } } diff --git a/.gitignore b/.gitignore index 89ea7dfcacaf1681089a69cd164cc3bc614ee798..bc5530ce51c956a30ecba0b63ea362bb5a479d56 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ dist/ dist-ssr/ *.local +# Gradle build cache +.gradle/ + # IDE .vscode/ .idea/ @@ -57,3 +60,28 @@ Thumbs.db # Environment variables .env .env.local +.env.production + +# Temporary files +*.tmp +*.temp + +# Logs +*.log +backend/C:* + +# Runtime / monitoring +monitoring/ + +# OS +.DS_Store +Thumbs.db + +# Images (only allow in docs/images/) +*.png +*.jpg +!docs/images/ + +# Database backups +*.backup +*.bak diff --git a/README.md b/README.md index e533d563f5eb59e4737e34c2e008bc871295c703..429056cfc839b47f851fdb8ec4441396b6e6bc1c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ **专业的足疗店管理解决方案,让经营管理更简单高效** 这是一套完整的足疗店管理系统,包含会员管理、技师管理、房间管理、服务项目管理、预约管理、收银台和报表统计等核心功能。本说明文档专为您的团队编写,帮助您快速上手系统。 - +###原型 + + ## 技术栈 ### 后端 @@ -26,29 +28,41 @@ foot-erp/ ├── backend/ # Django后端 │ ├── foot_erp/ # 项目配置 -│ ├── apps/ # 应用模块 +│ ├── apps/ # 核心业务模块 │ │ ├── auth/ # 认证模块 │ │ ├── members/ # 会员管理 │ │ ├── technicians/ # 技师管理 │ │ ├── rooms/ # 房间管理 │ │ ├── services/ # 服务管理 -│ │ └── bookings/ # 预约管理 -│ ├── app/ # 订单和报表 +│ │ ├── bookings/ # 预约管理 │ │ ├── orders/ # 订单管理 -│ │ └── reports/ # 报表统计 +│ │ ├── reports/ # 报表统计 +│ │ ├── chat/ # 聊天模块 +│ │ ├── feedback/ # 反馈模块 +│ │ └── mall/ # 商城模块 +│ ├── app/ # 公共工具 +│ │ └── utils/ # 权限、验证器等 +│ ├── test/ # 后端测试 │ ├── manage.py │ └── requirements.txt -└── frontend/ # Vue前端 - ├── src/ - │ ├── api/ # API接口 - │ ├── components/ # 组件 - │ ├── router/ # 路由 - │ ├── stores/ # 状态管理 - │ ├── views/ # 页面 - │ └── utils/ # 工具 - ├── index.html - ├── package.json - └── vite.config.js +├── frontend/ # Vue前端 +│ ├── src/ +│ │ ├── api/ # API接口 +│ │ ├── components/ # 组件 +│ │ ├── router/ # 路由 +│ │ ├── stores/ # 状态管理 +│ │ ├── views/ # 页面 +│ │ └── utils/ # 工具 +│ ├── index.html +│ ├── package.json +│ └── vite.config.js +├── uniapp/ # uni-app 移动端 +├── deploy/ # 部署脚本、Docker、Nginx 配置 +├── docs/ # 项目文档 +│ └── images/ # 文档图片 +├── scripts/ # 临时/维护用脚本 +├── test/ # 顶层测试(backend + frontend + integration) +└── html/ # 静态 HTML 页面 ``` ## 扫码访问 @@ -234,6 +248,59 @@ sudo bash ubuntu-deploy.sh - 会员信息查询 - 个人业绩查看 +### 📱 移动端应用打包 + +系统支持打包成原生Android和iOS应用,提供更好的移动端体验: + +**快速打包命令:** +```bash +cd frontend + +# Android应用 +npm run mobile:android # 打开Android Studio + +# iOS应用 +npm run mobile:ios # 打开Xcode +``` + +**打包步骤:** + +1. **安装依赖** + ```bash + cd frontend + npm install + ``` + +2. **构建Web应用** + ```bash + npm run build + ``` + +3. **Android打包** + ```bash + # 打开Android Studio进行打包 + npm run cap:open:android + # 在Android Studio中配置签名并生成APK/AAB + ``` + +4. **iOS打包** + ```bash + # 在Mac上打开Xcode进行打包 + npm run cap:open:ios + # 在Xcode中配置证书并提交到App Store + ``` + +**移动端特性:** +- ✅ 原生应用体验 +- ✅ 触摸反馈优化 +- ✅ 键盘智能处理 +- ✅ 网络状态检测 +- ✅ 设备信息获取 +- ✅ 安全区域适配 +- ✅ 暗色模式支持 + +**详细打包指南请参考:** `frontend/MOBILE_PACKAGING.md` + ### 🔧 技术支持 **遇到问题?** @@ -310,4 +377,5 @@ python manage.py createsuperuser **感谢您选择足疗ERP系统!** 🎊 -如有任何问题或需要技术支持,请随时联系我们。 +如有任何问题或需要技术支持,请随时联系我们。微信号z592373205 + diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..28c1c480bf0165e684efd32837a4e72f8d6cd428 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,33 @@ +# Django Settings +DJANGO_SECRET_KEY=your-secret-key-here-change-in-production +DJANGO_DEBUG=False +DJANGO_ENV=production +DJANGO_ALLOWED_HOSTS=your-domain.com,www.your-domain.com + +# CORS Settings +CORS_ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com + +# Database (optional - defaults to SQLite) +# DATABASE_URL=postgres://user:password@localhost:5432/foot_erp + +# Redis Settings (optional - for caching and WebSocket) +# REDIS_URL=redis://127.0.0.1:6379/1 + +# Email Settings (optional) +# EMAIL_HOST=smtp.example.com +# EMAIL_PORT=587 +# EMAIL_HOST_USER=your-email@example.com +# EMAIL_HOST_PASSWORD=your-email-password + +# File Upload Settings +# MAX_IMAGE_SIZE=5242880 # 5MB +# MAX_DOCUMENT_SIZE=10485760 # 10MB +# MAX_VIDEO_SIZE=52428800 # 50MB + +# Security Settings +# JWT_EXPIRATION_DELTA=86400 # 24 hours in seconds +# JWT_ALGORITHM=HS256 + +# Rate Limiting +# LOGIN_RATE_LIMIT=5/min +# API_RATE_LIMIT=100/hour diff --git a/backend/app/utils/permissions.py b/backend/app/utils/permissions.py deleted file mode 100644 index b4789ecc857975f31b4e27f4f60692188b9411f6..0000000000000000000000000000000000000000 --- a/backend/app/utils/permissions.py +++ /dev/null @@ -1,36 +0,0 @@ -from rest_framework import permissions - - -class IsAuthenticatedOrGuest(permissions.BasePermission): - """ - 允许认证用户或访客用户访问的权限类 - """ - - def has_permission(self, request, view): - # 允许认证用户访问 - if request.user and request.user.is_authenticated: - return True - - # 允许访客用户访问(检查session中的guest信息) - guest_session_id = request.session.get('guest_session_id') - if guest_session_id: - return True - - # 对于特定的安全操作,可以在这里添加额外的验证 - return False - - def has_object_permission(self, request, view, obj): - # 认证用户可以访问所有对象 - if request.user and request.user.is_authenticated: - return True - - # 访客用户只能访问自己的对象 - guest_session_id = request.session.get('guest_session_id') - if guest_session_id: - # 检查对象是否属于该访客 - if hasattr(obj, 'customer_id'): - return str(obj.customer_id) == guest_session_id - elif hasattr(obj, 'user_id'): - return str(obj.user_id) == guest_session_id - - return False \ No newline at end of file diff --git a/backend/app/orders/__init__.py b/backend/apps/__init__.py similarity index 100% rename from backend/app/orders/__init__.py rename to backend/apps/__init__.py diff --git a/backend/apps/attendance/migrations/0001_initial.py b/backend/apps/attendance/migrations/0001_initial.py index 803221f12660f41537862173d03daf08af476cef..50128d9bc5b4b006d0c3f94efab75a841d39dba7 100644 --- a/backend/apps/attendance/migrations/0001_initial.py +++ b/backend/apps/attendance/migrations/0001_initial.py @@ -1,8 +1,7 @@ -# Generated by Django 4.2 on 2024-03-25 10:00 +# Generated by Django 5.2.13 on 2026-04-30 07:27 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): @@ -11,10 +10,31 @@ class Migration(migrations.Migration): dependencies = [ ('technicians', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='ShiftRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shift_start_time', models.DateTimeField(verbose_name='上钟时间')), + ('shift_end_time', models.DateTimeField(blank=True, null=True, verbose_name='下钟时间')), + ('service_duration', models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='服务时长(小时)')), + ('customer_count', models.IntegerField(default=1, verbose_name='客户数')), + ('room', models.CharField(blank=True, max_length=50, verbose_name='房间号')), + ('service_type', models.CharField(blank=True, max_length=100, verbose_name='服务类型')), + ('status', models.CharField(choices=[('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='active', max_length=20, verbose_name='状态')), + ('remark', models.TextField(blank=True, verbose_name='备注')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('technician', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_records', to='technicians.technician', verbose_name='技师')), + ], + options={ + 'verbose_name': '班次记录', + 'verbose_name_plural': '班次记录', + 'db_table': 'shift_records', + 'ordering': ['-shift_start_time'], + }, + ), migrations.CreateModel( name='AttendanceRecord', fields=[ @@ -34,6 +54,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': '考勤记录', 'db_table': 'attendance_records', 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['technician', 'date'], name='attendance__technic_368911_idx'), models.Index(fields=['attendance_type'], name='attendance__attenda_e8b1c8_idx'), models.Index(fields=['date'], name='attendance__date_19baa0_idx')], }, ), migrations.CreateModel( @@ -59,40 +80,4 @@ class Migration(migrations.Migration): 'unique_together': {('technician', 'date')}, }, ), - migrations.CreateModel( - name='ShiftRecord', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('shift_start_time', models.DateTimeField(verbose_name='上钟时间')), - ('shift_end_time', models.DateTimeField(blank=True, null=True, verbose_name='下钟时间')), - ('service_duration', models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='服务时长(小时)')), - ('customer_count', models.IntegerField(default=1, verbose_name='客户数')), - ('room', models.CharField(blank=True, max_length=50, verbose_name='房间号')), - ('service_type', models.CharField(blank=True, max_length=100, verbose_name='服务类型')), - ('status', models.CharField(choices=[('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='active', max_length=20, verbose_name='状态')), - ('remark', models.TextField(blank=True, verbose_name='备注')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('technician', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_records', to='technicians.technician', verbose_name='技师')), - ], - options={ - 'verbose_name': '班次记录', - 'verbose_name_plural': '班次记录', - 'db_table': 'shift_records', - 'ordering': ['-shift_start_time'], - }, - ), - migrations.AddIndex( - model_name='attendancerecord', - index=models.Index(fields=['technician', 'date'], name='attendance__technic_5f4f1c_idx'), - ), - migrations.AddIndex( - model_name='attendancerecord', - index=models.Index(fields=['attendance_type'], name='attendance__attenda_7c8b3a_idx'), - ), - migrations.AddIndex( - model_name='attendancerecord', - index=models.Index(fields=['date'], name='attendance__date_bd1c6a_idx'), - ), ] - - # Note: The indexes for DailyAttendance are created automatically by unique_together \ No newline at end of file diff --git a/backend/app/orders/migrations/__init__.py b/backend/apps/attendance/migrations/__init__.py similarity index 100% rename from backend/app/orders/migrations/__init__.py rename to backend/apps/attendance/migrations/__init__.py diff --git a/backend/apps/attendance/models_commented.py b/backend/apps/attendance/models_commented.py deleted file mode 100644 index 27e7ffe6f559ba734430f6ddc0b1d73a90f6418a..0000000000000000000000000000000000000000 --- a/backend/apps/attendance/models_commented.py +++ /dev/null @@ -1,407 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 考勤系统数据模型 -# 作者: Foot ERP Team -# 创建日期: 2024年3月25日 -# 版本: 1.0.0 -# -# 说明: 此文件定义了考勤系统的核心数据模型,包括考勤记录、每日考勤汇总和班次记录 -# - -from django.db import models # 导入Django数据库模型基类 -from django.contrib.auth import get_user_model # 导入Django用户模型获取函数 -from apps.technicians.models import Technician # 导入技师模型,建立外键关系 - -# 获取Django的用户模型,用于后续可能的扩展 -User = get_user_model() - - -class AttendanceRecord(models.Model): - """ - 考勤记录模型 - 记录每次打卡的详细信息 - - 功能说明: - - 记录技师的每一次打卡操作(上班、下班、上钟、下钟) - - 存储打卡时间、地点、设备等详细信息 - - 为考勤统计提供原始数据 - - 使用场景: - - 技师打卡时创建记录 - - 考勤查询时显示详细记录 - - 考勤分析时作为数据源 - """ - - # 考勤类型选择项 - 定义系统中所有可能的打卡类型 - ATTENDANCE_TYPE_CHOICES = [ - ('clock_in', '上班打卡'), # 上下班打卡类型 - ('clock_out', '下班打卡'), # 上下班打卡类型 - ('shift_start', '上钟打卡'), # 服务开始打卡类型 - ('shift_end', '下钟打卡'), # 服务结束打卡类型 - ] - - # 技师外键 - 关联到技师表,表示这次打卡属于哪个技师 - # on_delete=models.CASCADE: 当技师被删除时,相关考勤记录也自动删除 - # related_name='attendance_records': 可以通过technician.attendance_records访问该技师的所有考勤记录 - technician = models.ForeignKey( - Technician, - on_delete=models.CASCADE, - related_name='attendance_records', - verbose_name='技师' # 在Django管理后台显示的字段名称 - ) - - # 考勤类型字段 - 记录这次打卡是什么类型 - # choices=ATTENDANCE_TYPE_CHOICES: 限制输入值必须在预定义选项中 - attendance_type = models.CharField( - max_length=20, # 字段最大长度 - choices=ATTENDANCE_TYPE_CHOICES, # 选择项约束 - verbose_name='考勤类型' # 管理后台显示名称 - ) - - # 打卡时间 - 记录具体的打卡时刻(包含日期和时间) - # DateTimeField: 日期时间类型,精确到秒 - timestamp = models.DateTimeField(verbose_name='打卡时间') - - # 打卡日期 - 仅记录日期部分,便于按日期统计 - # DateField: 仅日期类型,不包含时间 - date = models.DateField(verbose_name='打卡日期') - - # 打卡地点 - 记录技师在哪里打卡的 - # blank=True: 允许为空值 - location = models.CharField( - max_length=100, # 最多100个字符 - blank=True, # 允许为空 - verbose_name='打卡地点' - ) - - # IP地址 - 记录打卡时的网络IP,用于安全审计 - # null=True: 数据库允许NULL值 - ip_address = models.GenericIPAddressField( - null=True, # 允许数据库存储NULL - blank=True, # 表单允许为空 - verbose_name='IP地址' - ) - - # 设备信息 - 记录打卡设备的详细信息 - device_info = models.CharField( - max_length=200, # 最多200个字符 - blank=True, # 允许为空 - verbose_name='设备信息' - ) - - # 备注信息 - 技师可以添加额外的说明 - remark = models.TextField( # TextField适合存储较长的文本 - blank=True, # 允许为空 - verbose_name='备注' - ) - - # 创建时间 - 记录这条数据何时被创建 - # auto_now_add=True: 创建时自动设置为当前时间 - created_at = models.DateTimeField( - auto_now_add=True, # 自动设置创建时间为当前时间 - verbose_name='创建时间' - ) - - class Meta: - """ - 模型元数据类 - 定义模型级别的配置 - """ - db_table = 'attendance_records' # 数据库中实际的表名 - verbose_name = '考勤记录' # 单个对象的显示名称 - verbose_name_plural = verbose_name # 多个对象的显示名称(复数形式) - ordering = ['-timestamp'] # 默认排序:按打卡时间倒序(最新的在前) - - # 数据库索引 - 提高查询性能 - indexes = [ - # 为技师和日期创建联合索引,提高按技师和日期查询的性能 - models.Index(fields=['technician', 'date']), - # 为考勤类型创建索引,提高按类型筛选的性能 - models.Index(fields=['attendance_type']), - # 为日期创建索引,提高按日期范围查询的性能 - models.Index(fields=['date']), - ] - - def __str__(self): - """ - 对象的字符串表示方法 - 用于在Django管理后台和其他地方显示对象的友好名称 - - 返回格式: "技师姓名 - 考勤类型 - 打卡时间" - 示例: "张三 - 上班打卡 - 2024-03-25 09:00:00" - """ - return f'{self.technician.name} - {self.get_attendance_type_display()} - {self.timestamp}' - - -class DailyAttendance(models.Model): - """ - 每日考勤汇总模型 - 按天汇总技师的考勤情况 - - 功能说明: - - 每天为每个技师生成一条汇总记录 - - 记录当天的上下班时间、工作时长、服务次数等 - - 用于考勤统计和报表生成 - - 使用场景: - - 每日考勤统计 - - 考勤报表生成 - - 出勤率计算 - """ - - # 考勤状态选择项 - 定义可能的考勤状态 - STATUS_CHOICES = [ - ('present', '正常出勤'), # 正常上下班 - ('late', '迟到'), # 上班迟到 - ('early_leave', '早退'), # 下班早退 - ('absent', '缺勤'), # 未出勤 - ('leave', '请假'), # 请假状态 - ] - - # 技师外键 - 关联到技师表 - technician = models.ForeignKey( - Technician, - on_delete=models.CASCADE, - related_name='daily_attendances', # 可以通过technician.daily_attendances访问 - verbose_name='技师' - ) - - # 考勤日期 - 这条记录对应的日期 - date = models.DateField(verbose_name='日期') - - # 上班打卡时间 - 当天第一次上班打卡的具体时间 - # null=True, blank=True: 允许为空(比如还没打卡的情况) - clock_in_time = models.DateTimeField( - null=True, # 数据库允许NULL - blank=True, # 表单允许为空 - verbose_name='上班打卡时间' - ) - - # 下班打卡时间 - 当天最后一次下班打卡的具体时间 - clock_out_time = models.DateTimeField( - null=True, - blank=True, - verbose_name='下班打卡时间' - ) - - # 总工时 - 当天工作的总时长(小时为单位,可带小数) - # max_digits=6: 总共6位数字,decimal_places=2: 2位小数 - # 示例: 8.50 表示8小时30分钟 - total_shift_hours = models.DecimalField( - max_digits=6, - decimal_places=2, - default=0, # 默认值为0 - verbose_name='总工时(小时)' - ) - - # 服务次数 - 当天完成的服务次数 - shift_count = models.IntegerField( - default=0, # 默认0次 - verbose_name='服务次数' - ) - - # 考勤状态 - 当天的考勤状态 - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='present', # 默认为正常出勤 - verbose_name='考勤状态' - ) - - # 备注信息 - remark = models.TextField( - blank=True, - verbose_name='备注' - ) - - # 创建时间 - 记录创建时间 - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name='创建时间' - ) - - # 更新时间 - 记录最后更新时间 - # auto_now=True: 每次保存时自动更新为当前时间 - updated_at = models.DateTimeField( - auto_now=True, - verbose_name='更新时间' - ) - - class Meta: - """ - 模型元数据配置 - """ - db_table = 'daily_attendances' # 数据库表名 - verbose_name = '每日考勤' # 单数显示名称 - verbose_name_plural = verbose_name # 复数显示名称 - ordering = ['-date'] # 按日期倒序排列 - - # 唯一性约束 - 确保每个技师每天只有一条记录 - unique_together = ['technician', 'date'] - - def __str__(self): - """ - 对象的字符串表示 - 格式: "技师姓名 - 日期" - 示例: "张三 - 2024-03-25" - """ - return f'{self.technician.name} - {self.date}' - - @property - def work_hours(self): - """ - 计算工作时长属性(只读属性) - - 功能说明: - - 根据上下班打卡时间计算实际工作时长 - - 如果只有上班打卡没有下班打卡,返回0 - - 返回格式为小时数(带小数) - - 计算公式: - 工作时长 = 下班时间 - 上班时间(转换为小时) - - 返回: - float: 工作时长(小时),如8.5表示8小时30分钟 - - 示例: - - 上班: 09:00, 下班: 17:30 → 返回8.5 - - 只有上班打卡 → 返回0 - """ - if self.clock_in_time and self.clock_out_time: - # 计算时间差 - delta = self.clock_out_time - self.clock_in_time - # 将秒转换为小时,并保留2位小数 - return round(delta.total_seconds() / 3600, 2) - return 0 - - -class ShiftRecord(models.Model): - """ - 班次记录模型 - 记录技师的每一次服务(上钟下钟) - - 功能说明: - - 记录技师每次服务的开始和结束时间 - - 计算服务时长和相关信息 - - 用于服务统计和绩效计算 - - 使用场景: - - 技师上钟下钟打卡 - - 服务时长统计 - - 服务质量分析 - """ - - # 班次状态选择项 - STATUS_CHOICES = [ - ('active', '进行中'), # 服务正在进行中 - ('completed', '已完成'), # 服务已完成 - ('cancelled', '已取消'), # 服务被取消 - ] - - # 技师外键 - 关联到技师表 - technician = models.ForeignKey( - Technician, - on_delete=models.CASCADE, - related_name='shift_records', # 可以通过technician.shift_records访问 - verbose_name='技师' - ) - - # 上钟时间 - 服务开始的具体时间 - shift_start_time = models.DateTimeField(verbose_name='上钟时间') - - # 下钟时间 - 服务结束的具体时间 - # null=True, blank=True: 允许为空(服务还未结束的情况) - shift_end_time = models.DateTimeField( - null=True, - blank=True, - verbose_name='下钟时间' - ) - - # 服务时长 - 记录的服务时长(小时为单位) - # 这个字段可以由程序自动计算填充 - service_duration = models.DecimalField( - max_digits=6, - decimal_places=2, - default=0, - verbose_name='服务时长(小时)' - ) - - # 客户数量 - 这次服务接待的客户数 - customer_count = models.IntegerField( - default=1, # 默认1个客户 - verbose_name='客户数' - ) - - # 房间号 - 服务在哪个房间进行 - room = models.CharField( - max_length=50, # 最多50个字符 - blank=True, # 允许为空 - verbose_name='房间号' - ) - - # 服务类型 - 提供的具体服务类型 - service_type = models.CharField( - max_length=100, # 最多100个字符 - blank=True, # 允许为空 - verbose_name='服务类型' - ) - - # 班次状态 - 当前班次的状态 - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='active', # 默认为进行中 - verbose_name='状态' - ) - - # 备注信息 - remark = models.TextField( - blank=True, - verbose_name='备注' - ) - - # 创建时间 - 记录创建时间 - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name='创建时间' - ) - - class Meta: - """ - 模型元数据配置 - """ - db_table = 'shift_records' # 数据库表名 - verbose_name = '班次记录' # 单数显示名称 - verbose_name_plural = verbose_name # 复数显示名称 - ordering = ['-shift_start_time'] # 按开始时间倒序排列 - - def __str__(self): - """ - 对象的字符串表示 - 格式: "技师姓名 - 开始时间" - 示例: "张三 - 2024-03-25 10:30:00" - """ - return f'{self.technician.name} - {self.shift_start_time}' - - @property - def actual_duration(self): - """ - 计算实际服务时长属性(只读属性) - - 功能说明: - - 根据实际上钟和下钟时间计算服务时长 - - 如果服务还未结束(没有下钟时间),返回0 - - 返回格式为小时数(带小数) - - 计算公式: - 服务时长 = 下钟时间 - 上钟时间(转换为小时) - - 返回: - float: 服务时长(小时) - - 示例: - - 上钟: 10:00, 下钟: 12:30 → 返回2.5(2小时30分钟) - - 服务进行中 → 返回0 - """ - if self.shift_start_time and self.shift_end_time: - # 计算时间差 - delta = self.shift_end_time - self.shift_start_time - # 将秒转换为小时,并保留2位小数 - return round(delta.total_seconds() / 3600, 2) - return 0 \ No newline at end of file diff --git a/backend/apps/attendance/tests.py b/backend/apps/attendance/tests.py index 0afdbc6d0a4aa22333a1c0ae3d23a92a4e750100..fb4dba319247510657ab21603b12e92f4ac53459 100644 --- a/backend/apps/attendance/tests.py +++ b/backend/apps/attendance/tests.py @@ -1,4 +1,5 @@ import datetime +from unittest.mock import patch from django.test import TestCase from django.contrib.auth import get_user_model from django.urls import reverse diff --git a/backend/apps/attendance/urls.py b/backend/apps/attendance/urls.py index add3d0ddcc9a69b98f2d1447375ded2ea6fd3c44..f212675f97b49babc3c945c0f61feaaf0eec93d3 100644 --- a/backend/apps/attendance/urls.py +++ b/backend/apps/attendance/urls.py @@ -8,7 +8,7 @@ from .views import ( ) router = DefaultRouter() -router.register(r'records', AttendanceViewSet, basename='attendance') +router.register(r'', AttendanceViewSet, basename='attendance') router.register(r'daily', DailyAttendanceViewSet, basename='daily-attendance') router.register(r'shifts', ShiftRecordViewSet, basename='shift-record') router.register(r'reports', AttendanceReportViewSet, basename='attendance-report') diff --git a/backend/apps/attendance/views.py b/backend/apps/attendance/views.py index e257a7b043ef0e9db82cb7b547cc5299e5828e50..78acdfadd7c7d69d31c4875dda3db4da57dffb8c 100644 --- a/backend/apps/attendance/views.py +++ b/backend/apps/attendance/views.py @@ -71,13 +71,9 @@ class AttendanceViewSet(viewsets.ModelViewSet): QuerySet: 过滤后的考勤记录查询集 """ queryset = super().get_queryset() - # 普通员工只能查看自己的考勤记录 + # 管理员可以查看所有记录,普通用户返回空(因为没有user字段关联) if not self.request.user.is_staff: - try: - technician = Technician.objects.get(user=self.request.user) - queryset = queryset.filter(technician=technician) - except Technician.DoesNotExist: - return AttendanceRecord.objects.none() + return AttendanceRecord.objects.none() return queryset @action(detail=False, methods=['post']) @@ -108,14 +104,6 @@ class AttendanceViewSet(viewsets.ModelViewSet): - 400: 用户不是技师或打卡类型无效 - 200: 打卡成功 """ - try: - technician = Technician.objects.get(user=request.user) - except Technician.DoesNotExist: - return Response( - {'error': '抱歉,您不是技师,无法使用此功能'}, - status=status.HTTP_400_BAD_REQUEST - ) - attendance_type = request.data.get('attendance_type') if attendance_type not in ['clock_in', 'clock_out', 'shift_start', 'shift_end']: return Response( @@ -123,6 +111,37 @@ class AttendanceViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) + # 管理员用户可以指定技师,普通用户需要验证技师身份 + if request.user.is_staff: + # 管理员模式:从请求中获取技师ID,或使用默认技师 + technician_id = request.data.get('technician_id') + if technician_id: + try: + technician = Technician.objects.get(id=technician_id) + except Technician.DoesNotExist: + return Response( + {'error': '指定的技师不存在'}, + status=status.HTTP_400_BAD_REQUEST + ) + else: + # 管理员未指定技师时,尝试使用管理员关联的技师 + try: + technician = Technician.objects.get(user=request.user) + except Technician.DoesNotExist: + return Response( + {'error': '管理员未指定技师,且当前账号未关联技师信息'}, + status=status.HTTP_400_BAD_REQUEST + ) + else: + # 普通用户需要验证技师身份 + try: + technician = Technician.objects.get(user=request.user) + except Technician.DoesNotExist: + return Response( + {'error': '抱歉,考勤打卡功能仅对技师用户开放'}, + status=status.HTTP_400_BAD_REQUEST + ) + now = timezone.now() date = now.date() @@ -256,30 +275,46 @@ class AttendanceViewSet(viewsets.ModelViewSet): 获取我的考勤记录接口 功能说明: - - 仅技师可使用此接口 + - 管理员可查看所有记录或指定技师的记录 + - 技师只能查看自己的记录 - 支持按日期范围筛选 - - 返回详细的考勤记录列表 查询参数: + technician_id (int, optional): 技师ID(仅管理员可用) start_date (str, optional): 开始日期,格式YYYY-MM-DD end_date (str, optional): 结束日期,格式YYYY-MM-DD 返回: Response: 考勤记录列表数据 """ - try: - technician = Technician.objects.get(user=request.user) - except Technician.DoesNotExist: - return Response( - {'error': '抱歉,您不是技师,无法使用此功能'}, - status=status.HTTP_400_BAD_REQUEST - ) + if request.user.is_staff: + # 管理员可以查看所有记录或指定技师的记录 + technician_id = request.query_params.get('technician_id') + if technician_id: + try: + technician = Technician.objects.get(id=technician_id) + except Technician.DoesNotExist: + return Response( + {'error': '指定的技师不存在'}, + status=status.HTTP_400_BAD_REQUEST + ) + queryset = AttendanceRecord.objects.filter(technician=technician) + else: + queryset = AttendanceRecord.objects.all() + else: + # 普通技师用户只能查看自己的记录 + try: + technician = Technician.objects.get(user=request.user) + queryset = AttendanceRecord.objects.filter(technician=technician) + except Technician.DoesNotExist: + return Response( + {'error': '抱歉,考勤查询功能仅对技师用户开放'}, + status=status.HTTP_400_BAD_REQUEST + ) start_date = request.query_params.get('start_date') end_date = request.query_params.get('end_date') - queryset = AttendanceRecord.objects.filter(technician=technician) - if start_date: queryset = queryset.filter(date__gte=start_date) if end_date: @@ -313,16 +348,39 @@ class AttendanceViewSet(viewsets.ModelViewSet): - 考勤管理界面显示 - 移动端考勤状态展示 """ - try: - technician = Technician.objects.get(user=request.user) - except Technician.DoesNotExist: - return Response( - {'error': '抱歉,您不是技师,无法使用此功能'}, - status=status.HTTP_400_BAD_REQUEST - ) - today = timezone.now().date() + # 管理员用户可以查看任意技师的考勤,普通技师只能查看自己的 + if request.user.is_staff: + # 管理员模式:从查询参数获取技师ID + technician_id = request.query_params.get('technician_id') + if technician_id: + try: + technician = Technician.objects.get(id=technician_id) + except Technician.DoesNotExist: + return Response( + {'error': '指定的技师不存在'}, + status=status.HTTP_400_BAD_REQUEST + ) + else: + # 管理员未指定技师时,尝试使用管理员关联的技师 + try: + technician = Technician.objects.get(user=request.user) + except Technician.DoesNotExist: + return Response( + {'error': '管理员未指定技师,且当前账号未关联技师信息'}, + status=status.HTTP_400_BAD_REQUEST + ) + else: + # 普通技师用户 + try: + technician = Technician.objects.get(user=request.user) + except Technician.DoesNotExist: + return Response( + {'error': '抱歉,此功能仅对技师用户开放'}, + status=status.HTTP_400_BAD_REQUEST + ) + # 获取今日考勤记录 today_records = AttendanceRecord.objects.filter( technician=technician, @@ -351,7 +409,7 @@ class AttendanceViewSet(viewsets.ModelViewSet): 'current_shift': None, 'today_shifts': ShiftRecordSerializer(today_shifts, many=True).data, 'status': daily_attendance.status if daily_attendance else 'absent', - 'total_shift_hours': daily_attendance.total_shift_hours if daily_attendance else 0, + 'total_shift_hours': float(daily_attendance.total_shift_hours) if daily_attendance else 0, 'shift_count': daily_attendance.shift_count if daily_attendance else 0 } @@ -428,11 +486,8 @@ class DailyAttendanceViewSet(viewsets.ModelViewSet): """ queryset = super().get_queryset() if not self.request.user.is_staff: - try: - technician = Technician.objects.get(user=self.request.user) - queryset = queryset.filter(technician=technician) - except Technician.DoesNotExist: - return DailyAttendance.objects.none() + # 管理员用户可以查看所有记录,普通用户无权限 + return DailyAttendance.objects.none() return queryset @@ -475,11 +530,8 @@ class ShiftRecordViewSet(viewsets.ModelViewSet): """ queryset = super().get_queryset() if not self.request.user.is_staff: - try: - technician = Technician.objects.get(user=self.request.user) - queryset = queryset.filter(technician=technician) - except Technician.DoesNotExist: - return ShiftRecord.objects.none() + # 管理员用户可以查看所有记录,普通用户无权限 + return ShiftRecord.objects.none() return queryset @action(detail=False, methods=['get']) @@ -647,8 +699,22 @@ class AttendanceReportViewSet(viewsets.ViewSet): - 运营数据分析 - 管理层报表 """ - year = int(request.query_params.get('year', timezone.now().year)) - month = int(request.query_params.get('month', timezone.now().month)) + # 安全解析 year/month 参数 + try: + year = int(request.query_params.get('year', timezone.now().year)) + month = int(request.query_params.get('month', timezone.now().month)) + except (ValueError, TypeError): + return Response( + {'error': '参数year和month必须是有效整数'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 验证月份范围 + if month < 1 or month > 12: + return Response( + {'error': '月份必须在1-12之间'}, + status=status.HTTP_400_BAD_REQUEST + ) from datetime import datetime, timedelta start_date = datetime(year, month, 1).date() diff --git a/backend/apps/auth/backends.py b/backend/apps/auth/backends.py new file mode 100644 index 0000000000000000000000000000000000000000..4fa1f0aa08a8fd7381a6a9835a57208206713ee5 --- /dev/null +++ b/backend/apps/auth/backends.py @@ -0,0 +1,30 @@ +from django.contrib.auth.backends import ModelBackend +from .models import User + + +class CustomUserBackend(ModelBackend): + """ + 自定义用户认证后端 + 使用自定义用户模型进行认证 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None: + username = kwargs.get('username') + if username is None or password is None: + return None + + try: + user = User.objects.get(username=username) + if user.check_password(password) and self.user_can_authenticate(user): + return user + except User.DoesNotExist: + return None + except Exception: + return None + + def get_user(self, user_id): + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None \ No newline at end of file diff --git a/backend/apps/auth/jwt_authentication.py b/backend/apps/auth/jwt_authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..9fb520a9bc2c9d1f3e792510e4014c56cd14fd84 --- /dev/null +++ b/backend/apps/auth/jwt_authentication.py @@ -0,0 +1,49 @@ +import jwt +from django.conf import settings +from rest_framework import authentication +from rest_framework import exceptions +from .models import User + + +class JWTAuthentication(authentication.BaseAuthentication): + """ + JWT认证类 + 用于Django REST framework的认证系统 + """ + + def authenticate(self, request): + """ + 认证方法 + """ + authorization = request.META.get('HTTP_AUTHORIZATION', '') + + if not authorization.startswith('Bearer '): + return None + + token = authorization.split(' ')[1] + if not token: + return None + + try: + payload = jwt.decode( + token, + getattr(settings, 'JWT_SECRET_KEY', settings.SECRET_KEY), + algorithms=[getattr(settings, 'JWT_ALGORITHM', 'HS256')] + ) + user_id = payload.get('user_id') + if not user_id: + raise exceptions.AuthenticationFailed('无效的Token') + + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed('用户不存在') + + return (user, token) + + except jwt.ExpiredSignatureError: + raise exceptions.AuthenticationFailed('Token已过期') + except jwt.InvalidTokenError: + raise exceptions.AuthenticationFailed('Token无效') + except Exception as e: + raise exceptions.AuthenticationFailed(f'Token验证失败: {str(e)}') \ No newline at end of file diff --git a/backend/apps/auth/migrations/0002_alter_user_groups_alter_user_user_permissions.py b/backend/apps/auth/migrations/0002_alter_user_groups_alter_user_user_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..d726c6eb1d4966762f31db94bea3e9a0c8a51181 --- /dev/null +++ b/backend/apps/auth/migrations/0002_alter_user_groups_alter_user_user_permissions.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.13 on 2026-04-27 02:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('custom_auth', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='custom_auth_user_set', related_query_name='custom_auth_user', to='auth.group', verbose_name='groups'), + ), + migrations.AlterField( + model_name='user', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='custom_auth_user_set', related_query_name='custom_auth_user', to='auth.permission', verbose_name='user permissions'), + ), + ] diff --git a/backend/apps/auth/models.py b/backend/apps/auth/models.py index 9634b2856dce08b60522caac2875353a8feef71e..35c4712521d1282f927a65a0f74db7127c0bab77 100644 --- a/backend/apps/auth/models.py +++ b/backend/apps/auth/models.py @@ -33,6 +33,23 @@ class User(AbstractUser): - 与反馈记录关联 (feedback.models.Feedback) """ + groups = models.ManyToManyField( + 'auth.Group', + verbose_name='groups', + blank=True, + help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', + related_name="custom_auth_user_set", + related_query_name="custom_auth_user", + ) + user_permissions = models.ManyToManyField( + 'auth.Permission', + verbose_name='user permissions', + blank=True, + help_text='Specific permissions for this user.', + related_name="custom_auth_user_set", + related_query_name="custom_auth_user", + ) + # 手机号字段 - 用于短信验证、找回密码和日常联系 phone = models.CharField( max_length=20, diff --git a/backend/apps/auth/urls.py b/backend/apps/auth/urls.py index a9732a000e67ea5326e1615650187f441d552a27..173afb92cb43344c5cce505b38ac36da54bcd05e 100644 --- a/backend/apps/auth/urls.py +++ b/backend/apps/auth/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from .views import login, get_user_info +from .views import login, get_user_info, test_api urlpatterns = [ path('login/', login, name='login'), path('me/', get_user_info, name='get_user_info'), + path('test_api/', test_api, name='test_api'), ] diff --git a/backend/apps/auth/views.py b/backend/apps/auth/views.py index 604bd678f2129afa4b2e0bd82520e8f39580e2e1..81e97eb1426439e5d44031664a3e110a9ab80341 100644 --- a/backend/apps/auth/views.py +++ b/backend/apps/auth/views.py @@ -1,18 +1,31 @@ -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view, permission_classes, authentication_classes, throttle_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework import status +from rest_framework.throttling import AnonRateThrottle from django.contrib.auth import authenticate +from django.utils import timezone from .serializers import UserSerializer, UserLoginSerializer +from .models import User +from .jwt_authentication import JWTAuthentication import jwt -import datetime +from datetime import timedelta from django.conf import settings +class LoginRateThrottle(AnonRateThrottle): + """登录接口限流 - 每分钟最多5次""" + rate = '5/min' + + @api_view(['POST']) @permission_classes([AllowAny]) +@throttle_classes([LoginRateThrottle]) def login(request): - """用户登录""" + """ + 用户登录 + 限流:每分钟最多5次请求,防止暴力破解 + """ serializer = UserLoginSerializer(data=request.data) if serializer.is_valid(): username = serializer.validated_data['username'] @@ -20,12 +33,12 @@ def login(request): user = authenticate(username=username, password=password) if user: - # 生成JWT token + # 生成JWT token(使用时区感知时间) payload = { 'user_id': user.id, 'username': user.username, 'role': user.role, - 'exp': datetime.datetime.utcnow() + datetime.timedelta( + 'exp': timezone.now() + timedelta( seconds=getattr(settings, 'JWT_EXPIRATION_DELTA', 24 * 60 * 60) ) } @@ -47,7 +60,57 @@ def login(request): @api_view(['GET']) +@permission_classes([AllowAny]) def get_user_info(request): """获取当前用户信息""" - serializer = UserSerializer(request.user) - return Response(serializer.data) + # 手动处理JWT认证 + authorization = request.META.get('HTTP_AUTHORIZATION', '') + if authorization.startswith('Bearer '): + token = authorization.split(' ')[1] + try: + # 直接在这里解码JWT token + payload = jwt.decode( + token, + getattr(settings, 'JWT_SECRET_KEY', settings.SECRET_KEY), + algorithms=[getattr(settings, 'JWT_ALGORITHM', 'HS256')] + ) + user_id = payload.get('user_id') + if user_id: + try: + user = User.objects.get(id=user_id) + serializer = UserSerializer(user) + return Response(serializer.data) + except User.DoesNotExist: + return Response({'error': '用户不存在'}, status=status.HTTP_401_UNAUTHORIZED) + except jwt.ExpiredSignatureError: + return Response({'error': 'Token已过期'}, status=status.HTTP_401_UNAUTHORIZED) + except jwt.InvalidTokenError: + return Response({'error': 'Token无效'}, status=status.HTTP_401_UNAUTHORIZED) + except Exception as e: + # 记录日志但不暴露内部错误详情 + import logging + logger = logging.getLogger(__name__) + logger.warning(f'Token验证失败: {e}') + return Response({'error': 'Token验证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + return Response({'error': '未提供Authorization头'}, status=status.HTTP_401_UNAUTHORIZED) + + +@api_view(['GET']) +@authentication_classes([JWTAuthentication]) +@permission_classes([AllowAny]) +def test_api(request): + """测试API - 应该允许所有访问""" + # 手动调用JWT认证 + from .jwt_authentication import JWTAuthentication + jwt_auth = JWTAuthentication() + user, auth = jwt_auth.authenticate(request) + + return Response({ + 'message': '测试API工作正常', + 'user': str(getattr(request, 'user', 'No user')), + 'manual_user': str(user), + 'manual_auth': str(auth), + 'method': request.method, + 'path': request.path + }) diff --git a/backend/apps/bookings/models.py b/backend/apps/bookings/models.py index 80ad47258154d1f765774122f541bef8edf6ec6d..ac534793f42d708e34538365c9394af51a846f73 100644 --- a/backend/apps/bookings/models.py +++ b/backend/apps/bookings/models.py @@ -89,7 +89,11 @@ class Booking(models.Model): ordering = ['-booking_date', '-booking_time'] def __str__(self): - return f'{self.booking_no} - {self.guest_name or self.member.name}' + # 安全获取客户名称,避免 member 为 None 时抛出 AttributeError + customer_name = self.guest_name + if not customer_name and self.member_id: + customer_name = getattr(self.member, 'name', '未知客户') + return f'{self.booking_no} - {customer_name or "未知客户"}' class BookingLog(models.Model): diff --git a/backend/apps/bookings/serializers.py b/backend/apps/bookings/serializers.py index dc1c9ce3d635b14dcf704c53561af7069f5c49e6..a7f789b3ba3791c1da6491019d2da628db8f172f 100644 --- a/backend/apps/bookings/serializers.py +++ b/backend/apps/bookings/serializers.py @@ -14,8 +14,8 @@ class BookingSerializer(serializers.ModelSerializer): class Meta: model = Booking - fields = '__all__' - read_only_fields = ['id', 'created_at', 'updated_at', 'created_by', 'booking_no'] + exclude = ['created_by'] + read_only_fields = ['id', 'created_at', 'updated_at', 'booking_no'] class BookingListSerializer(serializers.ModelSerializer): diff --git a/backend/apps/bookings/urls.py b/backend/apps/bookings/urls.py index b92d4393af244d10aa11d3ce6c397a57e661bbb3..803e3e896c4d8b019f9f9d2c3108a23c2dfb8357 100644 --- a/backend/apps/bookings/urls.py +++ b/backend/apps/bookings/urls.py @@ -3,8 +3,8 @@ from rest_framework.routers import DefaultRouter from .views import BookingViewSet, BookingLogViewSet router = DefaultRouter() -router.register(r'', BookingViewSet, basename='booking') router.register(r'logs', BookingLogViewSet, basename='booking_log') +router.register(r'', BookingViewSet, basename='booking') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/apps/bookings/views.py b/backend/apps/bookings/views.py index a2f7697aad32c0a00af093ac9800f994c2c958a2..bed1c23d5368b991bb22824ce1717bfcc19afe48 100644 --- a/backend/apps/bookings/views.py +++ b/backend/apps/bookings/views.py @@ -1,19 +1,18 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets, status, serializers from rest_framework.decorators import action from rest_framework.response import Response from django.utils import timezone from django.db import transaction from .models import Booking, BookingLog from .serializers import BookingSerializer, BookingListSerializer, BookingLogSerializer -import random -import string def generate_booking_no(): - """生成预约编号""" + """生成预约编号 - 使用更安全的随机数生成""" + import secrets date_str = timezone.now().strftime('%Y%m%d') time_str = timezone.now().strftime('%H%M%S') - random_str = ''.join(random.choices(string.digits, k=3)) + random_str = ''.join([str(secrets.randbelow(10)) for _ in range(4)]) return f'B{date_str}{time_str}{random_str}' @@ -30,31 +29,46 @@ class BookingViewSet(viewsets.ModelViewSet): return BookingListSerializer return BookingSerializer - def perform_create(self, serializer): + def create(self, request, *args, **kwargs): # 验证必填字段 - service = serializer.validated_data.get('service') - if not service: + service_id = request.data.get('service') + if not service_id: raise serializers.ValidationError({'service': '服务项目是必填项'}) - booking_date = serializer.validated_data.get('booking_date') + booking_date = request.data.get('booking_date') if not booking_date: raise serializers.ValidationError({'booking_date': '预约日期是必填项'}) - booking_time = serializer.validated_data.get('booking_time') + booking_time = request.data.get('booking_time') if not booking_time: raise serializers.ValidationError({'booking_time': '预约时间是必填项'}) # 生成预约编号 booking_no = generate_booking_no() - booking = serializer.save(booking_no=booking_no, created_by=self.request.user) + booking = Booking.objects.create( + booking_no=booking_no, + member_id=request.data.get('member') or None, + guest_name=request.data.get('guest_name', ''), + guest_phone=request.data.get('guest_phone', ''), + service_id=service_id, + technician_id=request.data.get('technician') or None, + room_id=request.data.get('room') or None, + booking_date=booking_date, + booking_time=booking_time, + status=request.data.get('status', 'pending'), + type=request.data.get('type', 'phone'), + note=request.data.get('note', ''), + created_by_id=request.user.pk + ) # 记录日志 BookingLog.objects.create( booking=booking, action='创建预约', description=f'创建预约 {booking_no}', - created_by=self.request.user + created_by_id=request.user.pk ) + return Response(BookingSerializer(booking).data, status=status.HTTP_201_CREATED) @action(detail=True, methods=['post']) def confirm(self, request, pk=None): @@ -74,7 +88,7 @@ class BookingViewSet(viewsets.ModelViewSet): booking=booking, action='确认预约', description='确认预约', - created_by=request.user + created_by_id=request.user.pk ) return Response({ @@ -106,7 +120,7 @@ class BookingViewSet(viewsets.ModelViewSet): booking=booking, action='开始服务', description='开始服务', - created_by=request.user + created_by_id=request.user.pk ) return Response({ @@ -138,7 +152,7 @@ class BookingViewSet(viewsets.ModelViewSet): booking=booking, action='完成服务', description='完成服务', - created_by=request.user + created_by_id=request.user.pk ) return Response({ @@ -167,7 +181,7 @@ class BookingViewSet(viewsets.ModelViewSet): booking=booking, action='取消预约', description=f'取消预约: {reason}', - created_by=request.user + created_by_id=request.user.pk ) return Response({ diff --git a/backend/app/chat/__init__.py b/backend/apps/chat/__init__.py similarity index 100% rename from backend/app/chat/__init__.py rename to backend/apps/chat/__init__.py diff --git a/backend/app/chat/apps.py b/backend/apps/chat/apps.py similarity index 87% rename from backend/app/chat/apps.py rename to backend/apps/chat/apps.py index 1d521b6f3241a638e74c6893a708c77c77f54fc7..ef0260144cd5f2b1fa16f8a57905fc5c4b2eb58c 100644 --- a/backend/app/chat/apps.py +++ b/backend/apps/chat/apps.py @@ -3,5 +3,5 @@ from django.apps import AppConfig class ChatConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'app.chat' + name = 'apps.chat' verbose_name = '客服聊天' \ No newline at end of file diff --git a/backend/app/chat/consumers.py b/backend/apps/chat/consumers.py similarity index 97% rename from backend/app/chat/consumers.py rename to backend/apps/chat/consumers.py index 977472612e58218fd6d46398657fed65f83f7436..3f65364ed1b6c7f11aaa7d55e1ad92b33bc2b72c 100644 --- a/backend/app/chat/consumers.py +++ b/backend/apps/chat/consumers.py @@ -237,7 +237,7 @@ class ChatConsumer(AsyncWebsocketConsumer): 'channel_name': self.channel_name, 'is_connected': True, 'ip_address': self.get_client_ip(), - 'user_agent': self.scope.get('headers', {}).get('user-agent', '') + 'user_agent': self.scope.get('headers', {}).get(b'user-agent', b'').decode('utf-8', errors='replace') } ) @@ -288,9 +288,9 @@ class ChatConsumer(AsyncWebsocketConsumer): def get_client_ip(self): """获取客户端IP地址""" - x_forwarded_for = self.scope.get('headers', {}).get('x-forwarded-for') + x_forwarded_for = self.scope.get('headers', {}).get(b'x-forwarded-for') if x_forwarded_for: - ip = x_forwarded_for.split(',')[0] + ip = x_forwarded_for.split(b',')[0].decode('utf-8', errors='replace').strip() else: ip = self.scope.get('client', [''])[0] return ip or '127.0.0.1' \ No newline at end of file diff --git a/backend/apps/chat/middleware.py b/backend/apps/chat/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..ebb59f31d3b7e08d49759595e5ec4000586e5d8d --- /dev/null +++ b/backend/apps/chat/middleware.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +# 聊天WebSocket中间件 +# 作者: Foot ERP 前端团队 +# 创建: 2026-06-06 +# 版本: 1.0.0 +# 用途: 提供WebSocket认证中间件 +# + +import jwt +from django.conf import settings +from django.contrib.auth import get_user_model +from channels.db import database_sync_to_async +from channels.middleware import BaseMiddleware + +User = get_user_model() + + +@database_sync_to_async +def get_user(token): + """从token获取用户""" + try: + payload = jwt.decode( + token, + getattr(settings, 'JWT_SECRET_KEY', settings.SECRET_KEY), + algorithms=[getattr(settings, 'JWT_ALGORITHM', 'HS256')] + ) + user_id = payload.get('user_id') + if user_id: + return User.objects.get(id=user_id) + except (jwt.InvalidTokenError, User.DoesNotExist): + pass + return None + + +class WebSocketAuthMiddleware(BaseMiddleware): + """WebSocket认证中间件""" + + async def __call__(self, scope, receive, send): + # 从查询参数获取token + query_string = scope.get('query_string', b'').decode('utf-8') + token = None + + if query_string: + params = dict(param.split('=') for param in query_string.split('&') if '=' in param) + token = params.get('token') + + # 从headers获取token + if not token: + headers = dict(scope.get('headers', [])) + authorization = headers.get(b'authorization', b'').decode('utf-8') + if authorization.startswith('Bearer '): + token = authorization.split(' ')[1] + + # 验证token + if token: + user = await get_user(token) + if user: + scope['user'] = user + else: + scope['user'] = None + else: + scope['user'] = None + + return await super().__call__(scope, receive, send) + + +class WebSocketAuthMiddlewareStack: + """WebSocket认证中间件栈""" + + def __init__(self, inner): + self.inner = inner + + async def __call__(self, scope, receive, send): + # 从查询参数获取token + query_string = scope.get('query_string', b'').decode('utf-8') + token = None + + if query_string: + params = dict(param.split('=') for param in query_string.split('&') if '=' in param) + token = params.get('token') + + # 验证token + if token: + user = await get_user(token) + if user: + scope['user'] = user + else: + scope['user'] = None + else: + scope['user'] = None + + return await self.inner(scope, receive, send) diff --git a/backend/app/chat/models.py b/backend/apps/chat/models.py similarity index 93% rename from backend/app/chat/models.py rename to backend/apps/chat/models.py index b6c6e8fd432c5c4186bf4f2204b1e6a27dd7e676..7a0601831a59353ff18fe2edd2eb2b83c490465c 100644 --- a/backend/app/chat/models.py +++ b/backend/apps/chat/models.py @@ -220,19 +220,23 @@ class Message(models.Model): if not self.message_id: self.message_id = self.generate_message_id() - # 更新对话的最后消息信息 + # 先保存消息,确保 created_at 等字段正确生成 + super().save(*args, **kwargs) + + # 更新对话的最后消息信息(在 super().save() 之后,此时 created_at 已有值) if self.conversation: - self.conversation.last_message = self.content[:100] # 截取前100字符 - self.conversation.last_message_time = self.created_at - self.conversation.updated_at = self.created_at + now = timezone.now() + self.conversation.last_message = (self.content or '')[:100] # 截取前100字符 + self.conversation.last_message_time = now + self.conversation.updated_at = now # 如果消息来自客服且客户未读,增加未读计数 if self.sender_type == 'staff' and not self.is_read: self.conversation.unread_count += 1 - self.conversation.save() - - super().save(*args, **kwargs) + self.conversation.save(update_fields=[ + 'last_message', 'last_message_time', 'updated_at', 'unread_count' + ]) def generate_message_id(self): """生成消息ID""" diff --git a/backend/app/chat/serializers.py b/backend/apps/chat/serializers.py similarity index 100% rename from backend/app/chat/serializers.py rename to backend/apps/chat/serializers.py diff --git a/backend/app/chat/urls.py b/backend/apps/chat/urls.py similarity index 100% rename from backend/app/chat/urls.py rename to backend/apps/chat/urls.py diff --git a/backend/app/chat/views.py b/backend/apps/chat/views.py similarity index 99% rename from backend/app/chat/views.py rename to backend/apps/chat/views.py index 09229a0fee2768a0d62ca55603f68d1af73fc1ce..18b0c1a2b490c5a93a36cc59351e151862e358e1 100644 --- a/backend/app/chat/views.py +++ b/backend/apps/chat/views.py @@ -12,7 +12,7 @@ from .serializers import ( MessageSerializer, ChatSessionSerializer ) -from app.utils.permissions import IsAuthenticatedOrGuest +from apps.utils.permissions import IsAuthenticatedOrGuest class ConversationViewSet(viewsets.ModelViewSet): diff --git a/backend/app/feedback/__init__.py b/backend/apps/feedback/__init__.py similarity index 100% rename from backend/app/feedback/__init__.py rename to backend/apps/feedback/__init__.py diff --git a/backend/app/feedback/apps.py b/backend/apps/feedback/apps.py similarity index 85% rename from backend/app/feedback/apps.py rename to backend/apps/feedback/apps.py index 80dcdbf439acd115eacaac3af5e4a682ef71529b..9390a728c8759b3c5e32a2431a2afaa97cbe7dcf 100644 --- a/backend/app/feedback/apps.py +++ b/backend/apps/feedback/apps.py @@ -3,5 +3,5 @@ from django.apps import AppConfig class FeedbackConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'app.feedback' + name = 'apps.feedback' verbose_name = '意见反馈' \ No newline at end of file diff --git a/backend/app/feedback/models.py b/backend/apps/feedback/models.py similarity index 97% rename from backend/app/feedback/models.py rename to backend/apps/feedback/models.py index 318ee9b3260d7b6f9be60ac6595f1bad2aa8d248..37f56275cc6bec0729bf458b8731e7b3b7424043 100644 --- a/backend/app/feedback/models.py +++ b/backend/apps/feedback/models.py @@ -175,6 +175,7 @@ class Feedback(models.Model): self.feedback_id = self.generate_feedback_id() # 根据反馈类型自动设置优先级(仅在新建时设置) + # 使用 self.priority 是否为空字符串判断,避免 'low' 等 Truthy 值导致判断失效 if not self.priority and not self.pk: if self.feedback_type == 'complaint': self.priority = 'high' @@ -182,6 +183,9 @@ class Feedback(models.Model): self.priority = 'medium' else: self.priority = 'low' + # 确保 priority 始终有默认值 + if not self.priority: + self.priority = 'low' super().save(*args, **kwargs) diff --git a/backend/app/feedback/serializers.py b/backend/apps/feedback/serializers.py similarity index 100% rename from backend/app/feedback/serializers.py rename to backend/apps/feedback/serializers.py diff --git a/backend/app/feedback/urls.py b/backend/apps/feedback/urls.py similarity index 100% rename from backend/app/feedback/urls.py rename to backend/apps/feedback/urls.py diff --git a/backend/app/feedback/views.py b/backend/apps/feedback/views.py similarity index 99% rename from backend/app/feedback/views.py rename to backend/apps/feedback/views.py index 771838495e2b443171d62bea7f149b91567e343b..bec6205933719fa3f206527f2f708f335a0b2ac8 100644 --- a/backend/app/feedback/views.py +++ b/backend/apps/feedback/views.py @@ -16,7 +16,7 @@ from .serializers import ( FeedbackCategorySerializer, FeedbackTagSerializer ) -from app.utils.permissions import IsAuthenticatedOrGuest +from apps.utils.permissions import IsAuthenticatedOrGuest class FeedbackViewSet(viewsets.ModelViewSet): diff --git a/backend/app/mall/__init__.py b/backend/apps/mall/__init__.py similarity index 100% rename from backend/app/mall/__init__.py rename to backend/apps/mall/__init__.py diff --git a/backend/app/mall/apps.py b/backend/apps/mall/apps.py similarity index 87% rename from backend/app/mall/apps.py rename to backend/apps/mall/apps.py index 4e3b7245629f0169ca9d6cd7463620089f2ceeaa..5049baa453ee30a4a6556801827cef2765119cf9 100644 --- a/backend/app/mall/apps.py +++ b/backend/apps/mall/apps.py @@ -3,5 +3,5 @@ from django.apps import AppConfig class MallConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'app.mall' + name = 'apps.mall' verbose_name = '商城管理' \ No newline at end of file diff --git a/backend/app/mall/models.py b/backend/apps/mall/models.py similarity index 100% rename from backend/app/mall/models.py rename to backend/apps/mall/models.py diff --git a/backend/app/mall/serializers.py b/backend/apps/mall/serializers.py similarity index 89% rename from backend/app/mall/serializers.py rename to backend/apps/mall/serializers.py index b6805d982ee3fafc716f5851eab17648858aa709..9f5866d0a5a6be00c6610be85d5ee7f95f6d5a16 100644 --- a/backend/app/mall/serializers.py +++ b/backend/apps/mall/serializers.py @@ -1,3 +1,4 @@ +from django.db.models import F from rest_framework import serializers from django.db import transaction from .models import Product, MallOrder, MallOrderLog @@ -145,15 +146,27 @@ class MallOrderCreateSerializer(serializers.ModelSerializer): """创建订单""" # 移除items字段,因为它在模型中不存在 items = validated_data.pop('items') + quantity = validated_data.get('quantity', 1) # 创建订单 order = MallOrder.objects.create(**validated_data) - # 更新商品库存 - product = order.product - product.stock -= order.quantity + # 使用 F() 表达式原子性扣减库存,防止高并发超卖 + from .models import Product + product = Product.objects.select_for_update().get(pk=order.product_id) + + # 检查库存是否充足 + if product.stock < quantity: + raise serializers.ValidationError({ + 'quantity': f'库存不足,当前库存: {product.stock}' + }) + + product.stock = F('stock') - quantity product.save() + # 刷新以获取更新后的值 + product.refresh_from_db() + return order diff --git a/backend/app/mall/urls.py b/backend/apps/mall/urls.py similarity index 100% rename from backend/app/mall/urls.py rename to backend/apps/mall/urls.py diff --git a/backend/app/mall/views.py b/backend/apps/mall/views.py similarity index 99% rename from backend/app/mall/views.py rename to backend/apps/mall/views.py index b930d414663625ad73801e299494e6dab69fc923..772e60d7e6e19c4e53de7463a53628a6423aa641 100644 --- a/backend/app/mall/views.py +++ b/backend/apps/mall/views.py @@ -12,7 +12,7 @@ from .serializers import ( MallOrderCreateSerializer, MallOrderLogSerializer ) -from app.utils.permissions import IsAuthenticatedOrGuest +from apps.utils.permissions import IsAuthenticatedOrGuest class ProductViewSet(viewsets.ReadOnlyModelViewSet): diff --git a/backend/apps/members/serializers.py b/backend/apps/members/serializers.py index 17f30650575965a5eb05130996c2d90b610a48aa..9c5aaf3ff82a0a310730be655248eb154499e384 100644 --- a/backend/apps/members/serializers.py +++ b/backend/apps/members/serializers.py @@ -1,13 +1,28 @@ from rest_framework import serializers +from apps.utils.serializers import BaseModelSerializer from .models import Member, RechargeRecord, MemberTransaction -class MemberSerializer(serializers.ModelSerializer): - """会员序列化器""" +class MemberSerializer(BaseModelSerializer): + """会员序列化器 - 包含输入净化""" + + def validate_phone(self, value): + """验证手机号格式""" + from apps.utils.validators import validate_phone + if not validate_phone(value): + raise serializers.ValidationError('请输入有效的手机号') + return value + + def validate_name(self, value): + """验证姓名""" + if len(value) < 2 or len(value) > 50: + raise serializers.ValidationError('姓名长度必须在2-50个字符之间') + return value + class Meta: model = Member - fields = '__all__' - read_only_fields = ['id', 'created_at', 'updated_at', 'created_by', 'member_no'] + exclude = ['created_by'] + read_only_fields = ['id', 'created_at', 'updated_at', 'member_no'] class MemberListSerializer(serializers.ModelSerializer): diff --git a/backend/apps/members/urls.py b/backend/apps/members/urls.py index 9846b4b470089d5d17c36b441e0f26ab828c4a9e..7e03365661264ba93f362cf706db5a5151869f0d 100644 --- a/backend/apps/members/urls.py +++ b/backend/apps/members/urls.py @@ -3,8 +3,8 @@ from rest_framework.routers import DefaultRouter from .views import MemberViewSet, RechargeRecordViewSet router = DefaultRouter() -router.register(r'', MemberViewSet, basename='member') router.register(r'records', RechargeRecordViewSet, basename='rechargerecord') +router.register(r'', MemberViewSet, basename='member') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/apps/members/views.py b/backend/apps/members/views.py index 1f34620ef4a8e5d0c53315384eae88e1aa593fac..3a78b90b99d53e321e86bbbb884199ec60da50b9 100644 --- a/backend/apps/members/views.py +++ b/backend/apps/members/views.py @@ -1,18 +1,18 @@ -from rest_framework import viewsets, status, serializers +from rest_framework import viewsets, status, serializers, permissions from rest_framework.decorators import action from rest_framework.response import Response from django.db import transaction from django.utils import timezone +from decimal import Decimal from .models import Member, RechargeRecord, MemberTransaction from .serializers import MemberSerializer, MemberListSerializer, RechargeRecordSerializer, MemberTransactionSerializer -import random -import string def generate_member_no(): - """生成会员编号""" + """生成会员编号 - 使用更安全的随机数生成""" + import secrets date_str = timezone.now().strftime('%Y%m%d') - random_str = ''.join(random.choices(string.digits, k=4)) + random_str = ''.join([str(secrets.randbelow(10)) for _ in range(4)]) return f'M{date_str}{random_str}' @@ -20,6 +20,7 @@ class MemberViewSet(viewsets.ModelViewSet): """会员视图集""" queryset = Member.objects.all() serializer_class = MemberSerializer + permission_classes = [permissions.IsAuthenticated] filterset_fields = ['level', 'status'] search_fields = ['name', 'phone', 'member_no'] ordering_fields = ['created_at', 'balance', 'points'] @@ -29,21 +30,41 @@ class MemberViewSet(viewsets.ModelViewSet): return MemberListSerializer return MemberSerializer - def perform_create(self, serializer): + def create(self, request, *args, **kwargs): # 检查手机号是否重复 - phone = serializer.validated_data.get('phone') - if Member.objects.filter(phone=phone).exists(): + phone = request.data.get('phone') + if phone and Member.objects.filter(phone=phone).exists(): raise serializers.ValidationError({'phone': '该手机号已存在'}) + # 验证姓名 + name = request.data.get('name') + if not name or len(name) < 2 or len(name) > 50: + raise serializers.ValidationError({'name': '姓名长度必须在2-50个字符之间'}) + # 生成会员编号 member_no = generate_member_no() - serializer.save(member_no=member_no, created_by=self.request.user) + member = Member.objects.create( + member_no=member_no, + name=name, + phone=phone, + level=request.data.get('level', 'normal'), + balance=Decimal(str(request.data.get('balance', 0))), + points=int(request.data.get('points', 0)), + total_recharge=Decimal(str(request.data.get('total_recharge', 0))), + total_consumption=Decimal(str(request.data.get('total_consumption', 0))), + status=request.data.get('status', 'active'), + birthday=request.data.get('birthday') or None, + address=request.data.get('address', ''), + remark=request.data.get('remark', ''), + created_by_id=request.user.pk + ) + return Response(MemberSerializer(member).data, status=status.HTTP_201_CREATED) @action(detail=True, methods=['post']) def recharge(self, request, pk=None): """会员充值""" member = self.get_object() - amount = float(request.data.get('amount', 0)) + amount = Decimal(str(request.data.get('amount', 0))) points = int(request.data.get('points', 0)) payment_method = request.data.get('payment_method', 'cash') remark = request.data.get('remark', '') @@ -67,7 +88,7 @@ class MemberViewSet(viewsets.ModelViewSet): points=points, payment_method=payment_method, remark=remark, - created_by=request.user + created_by_id=request.user.pk ) # 创建交易记录 @@ -86,7 +107,11 @@ class MemberViewSet(viewsets.ModelViewSet): 'member': MemberSerializer(member).data }) except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + # 记录日志但不暴露内部错误详情 + import logging + logger = logging.getLogger(__name__) + logger.error(f'会员充值失败: {e}', exc_info=True) + return Response({'error': '充值处理失败,请联系管理员'}, status=status.HTTP_400_BAD_REQUEST) @action(detail=False, methods=['get']) def transactions(self, request): @@ -108,5 +133,6 @@ class RechargeRecordViewSet(viewsets.ReadOnlyModelViewSet): """充值记录视图集""" queryset = RechargeRecord.objects.all() serializer_class = RechargeRecordSerializer + permission_classes = [permissions.IsAuthenticated] filterset_fields = ['member', 'payment_method'] ordering = ['-created_at'] diff --git a/backend/app/reports/__init__.py b/backend/apps/orders/__init__.py similarity index 100% rename from backend/app/reports/__init__.py rename to backend/apps/orders/__init__.py diff --git a/backend/app/orders/apps.py b/backend/apps/orders/apps.py similarity index 86% rename from backend/app/orders/apps.py rename to backend/apps/orders/apps.py index e3e7b31f9e2fef279f94b85368e5e83e25d9e3db..a7f462faac45c668a3f726a739549efe0b4399ac 100644 --- a/backend/app/orders/apps.py +++ b/backend/apps/orders/apps.py @@ -3,5 +3,5 @@ from django.apps import AppConfig class OrdersConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'app.orders' + name = 'apps.orders' verbose_name = '订单管理' diff --git a/backend/app/orders/migrations/0001_initial.py b/backend/apps/orders/migrations/0001_initial.py similarity index 100% rename from backend/app/orders/migrations/0001_initial.py rename to backend/apps/orders/migrations/0001_initial.py diff --git a/backend/apps/orders/migrations/0002_order_orders_status_f8c8df_idx_and_more.py b/backend/apps/orders/migrations/0002_order_orders_status_f8c8df_idx_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..945a082d33198cba43ed1a99e735bd59f8d0a0ab --- /dev/null +++ b/backend/apps/orders/migrations/0002_order_orders_status_f8c8df_idx_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.13 on 2026-04-27 02:29 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookings', '0002_initial'), + ('members', '0001_initial'), + ('orders', '0001_initial'), + ('rooms', '0001_initial'), + ('services', '0002_alter_service_duration_alter_service_price_and_more'), + ('technicians', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name='order', + index=models.Index(fields=['status', '-created_at'], name='orders_status_f8c8df_idx'), + ), + migrations.AddIndex( + model_name='order', + index=models.Index(fields=['member', '-created_at'], name='orders_member__87be23_idx'), + ), + migrations.AddIndex( + model_name='order', + index=models.Index(fields=['order_no'], name='orders_order_n_a26108_idx'), + ), + migrations.AddIndex( + model_name='order', + index=models.Index(fields=['payment_method'], name='orders_payment_cfe643_idx'), + ), + migrations.AddIndex( + model_name='orderitem', + index=models.Index(fields=['order', 'service'], name='order_items_order_i_20d0ef_idx'), + ), + migrations.AddIndex( + model_name='orderitem', + index=models.Index(fields=['service'], name='order_items_service_462802_idx'), + ), + migrations.AddIndex( + model_name='payment', + index=models.Index(fields=['order', '-created_at'], name='payments_order_i_cdec7b_idx'), + ), + migrations.AddIndex( + model_name='payment', + index=models.Index(fields=['payment_method'], name='payments_payment_ee488d_idx'), + ), + migrations.AddIndex( + model_name='payment', + index=models.Index(fields=['type', '-created_at'], name='payments_type_cc24b0_idx'), + ), + ] diff --git a/backend/app/reports/migrations/__init__.py b/backend/apps/orders/migrations/__init__.py similarity index 100% rename from backend/app/reports/migrations/__init__.py rename to backend/apps/orders/migrations/__init__.py diff --git a/backend/app/orders/models.py b/backend/apps/orders/models.py similarity index 94% rename from backend/app/orders/models.py rename to backend/apps/orders/models.py index ac5c7d991e154b9713ea5ffe50fe1f948b476f34..7edae655c7c5afb29d60f683274cac18d8a4dde6 100644 --- a/backend/app/orders/models.py +++ b/backend/apps/orders/models.py @@ -9,13 +9,9 @@ from django.db import models from django.contrib.auth import get_user_model -import json # JSONField兼容性处理 - 支持不同Django版本 -try: - from django.db.models import JSONField -except ImportError: - from django.contrib.postgres.fields import JSONField +from django.db.models import JSONField User = get_user_model() @@ -169,7 +165,7 @@ class Order(models.Model): ) # 支付详情 - 存储详细的支付信息(JSON格式) - payment_detail = models.JSONField( + payment_detail = JSONField( default=dict, verbose_name='支付详情' ) @@ -224,6 +220,12 @@ class Order(models.Model): verbose_name = '订单' verbose_name_plural = verbose_name ordering = ['-created_at'] # 按创建时间倒序排列 + indexes = [ + models.Index(fields=['status', '-created_at']), + models.Index(fields=['member', '-created_at']), + models.Index(fields=['order_no']), + models.Index(fields=['payment_method']), + ] def __str__(self): """ @@ -322,6 +324,10 @@ class OrderItem(models.Model): db_table = 'order_items' verbose_name = '订单明细' verbose_name_plural = verbose_name + indexes = [ + models.Index(fields=['order', 'service']), + models.Index(fields=['service']), + ] def __str__(self): """ @@ -433,3 +439,8 @@ class Payment(models.Model): verbose_name = '支付记录' verbose_name_plural = verbose_name ordering = ['-created_at'] # 按支付时间倒序排列 + indexes = [ + models.Index(fields=['order', '-created_at']), + models.Index(fields=['payment_method']), + models.Index(fields=['type', '-created_at']), + ] diff --git a/backend/app/orders/serializers.py b/backend/apps/orders/serializers.py similarity index 100% rename from backend/app/orders/serializers.py rename to backend/apps/orders/serializers.py diff --git a/backend/app/orders/urls.py b/backend/apps/orders/urls.py similarity index 100% rename from backend/app/orders/urls.py rename to backend/apps/orders/urls.py diff --git a/backend/app/orders/views.py b/backend/apps/orders/views.py similarity index 89% rename from backend/app/orders/views.py rename to backend/apps/orders/views.py index 0fdaefa858430b7797a188d3d0696ab12452620a..1884941af4d54b7940c0a716cffc95a1f493b253 100644 --- a/backend/app/orders/views.py +++ b/backend/apps/orders/views.py @@ -6,15 +6,14 @@ from django.utils import timezone from decimal import Decimal from .models import Order, OrderItem, Payment from .serializers import OrderSerializer, OrderListSerializer, OrderItemSerializer, PaymentSerializer -import random -import string def generate_order_no(): - """生成订单编号""" + """生成订单编号 - 使用更安全的随机数生成""" + import secrets date_str = timezone.now().strftime('%Y%m%d') time_str = timezone.now().strftime('%H%M%S') - random_str = ''.join(random.choices(string.digits, k=3)) + random_str = ''.join([str(secrets.randbelow(10)) for _ in range(4)]) return f'O{date_str}{time_str}{random_str}' @@ -241,7 +240,11 @@ class OrderViewSet(viewsets.ModelViewSet): 'order': OrderSerializer(order).data }) except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + # 记录日志但不暴露内部错误详情 + import logging + logger = logging.getLogger(__name__) + logger.error(f'订单支付失败: {e}', exc_info=True) + return Response({'error': '支付处理失败,请联系管理员'}, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=['post']) def refund(self, request, pk=None): @@ -253,9 +256,22 @@ class OrderViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) - amount = Decimal(request.data.get('amount', order.paid_amount)) + amount = Decimal(str(request.data.get('amount', order.paid_amount))) reason = request.data.get('reason', '') + # 校验退款金额 + if amount <= 0: + return Response( + {'error': '退款金额必须大于0'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if amount > order.paid_amount: + return Response( + {'error': f'退款金额不能超过实付金额 {order.paid_amount} 元'}, + status=status.HTTP_400_BAD_REQUEST + ) + try: with transaction.atomic(): # 创建退款支付记录 @@ -271,6 +287,9 @@ class OrderViewSet(viewsets.ModelViewSet): # 更新订单状态 order.status = 'refunded' order.paid_amount -= amount + # 确保实付金额不会变成负数 + if order.paid_amount < 0: + order.paid_amount = Decimal('0') order.save() # 退回会员余额 @@ -295,7 +314,11 @@ class OrderViewSet(viewsets.ModelViewSet): 'order': OrderSerializer(order).data }) except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + # 记录日志但不暴露内部错误详情 + import logging + logger = logging.getLogger(__name__) + logger.error(f'订单退款失败: {e}', exc_info=True) + return Response({'error': '退款处理失败,请联系管理员'}, status=status.HTTP_400_BAD_REQUEST) class OrderItemViewSet(viewsets.ModelViewSet): diff --git a/backend/apps/reports/__init__.py b/backend/apps/reports/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/reports/apps.py b/backend/apps/reports/apps.py similarity index 85% rename from backend/app/reports/apps.py rename to backend/apps/reports/apps.py index c37ac10655f098ce95ac68226f6b3d5093bce388..646aca15fd942ee68eb68116cebbdb726a169838 100644 --- a/backend/app/reports/apps.py +++ b/backend/apps/reports/apps.py @@ -3,5 +3,5 @@ from django.apps import AppConfig class ReportsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'app.reports' + name = 'apps.reports' verbose_name = '报表统计' diff --git a/backend/app/reports/migrations/0001_initial.py b/backend/apps/reports/migrations/0001_initial.py similarity index 100% rename from backend/app/reports/migrations/0001_initial.py rename to backend/apps/reports/migrations/0001_initial.py diff --git a/backend/apps/reports/migrations/__init__.py b/backend/apps/reports/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/reports/models.py b/backend/apps/reports/models.py similarity index 100% rename from backend/app/reports/models.py rename to backend/apps/reports/models.py diff --git a/backend/app/reports/serializers.py b/backend/apps/reports/serializers.py similarity index 100% rename from backend/app/reports/serializers.py rename to backend/apps/reports/serializers.py diff --git a/backend/app/reports/urls.py b/backend/apps/reports/urls.py similarity index 100% rename from backend/app/reports/urls.py rename to backend/apps/reports/urls.py diff --git a/backend/app/reports/views.py b/backend/apps/reports/views.py similarity index 93% rename from backend/app/reports/views.py rename to backend/apps/reports/views.py index c37d14d38a4cb143300a95b4f51e17fed46c5665..33fb437015ba342852d0496a4a8aca51be9f29ec 100644 --- a/backend/app/reports/views.py +++ b/backend/apps/reports/views.py @@ -1,4 +1,4 @@ -from rest_framework import viewsets +from rest_framework import viewsets, permissions, status from rest_framework.decorators import action from rest_framework.response import Response from django.db.models import Sum, Count @@ -13,6 +13,7 @@ class DailyReportViewSet(viewsets.ModelViewSet): """日报表视图集""" queryset = DailyReport.objects.all() serializer_class = DailyReportSerializer + permission_classes = [permissions.IsAuthenticated] ordering = ['-date'] @action(detail=False, methods=['get']) @@ -21,7 +22,7 @@ class DailyReportViewSet(viewsets.ModelViewSet): today = timezone.now().date() # 统计订单数据 - from app.orders.models import Order, Payment + from apps.orders.models import Order, Payment from apps.members.models import Member, RechargeRecord # 查询今日订单 @@ -74,7 +75,13 @@ class DailyReportViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['get']) def summary(self, request): """获取汇总统计""" - days = int(request.query_params.get('days', 7)) + try: + days = int(request.query_params.get('days', 7)) + except (ValueError, TypeError): + return Response({'error': '参数days必须是有效整数'}, status=status.HTTP_400_BAD_REQUEST) + + # 限制查询范围,防止全表扫描 + days = min(max(days, 1), 365) end_date = timezone.now().date() start_date = end_date - timedelta(days=days) @@ -100,6 +107,7 @@ class MonthlyReportViewSet(viewsets.ModelViewSet): """月报表视图集""" queryset = MonthlyReport.objects.all() serializer_class = MonthlyReportSerializer + permission_classes = [permissions.IsAuthenticated] ordering = ['-year', '-month'] @action(detail=False, methods=['get']) diff --git a/backend/apps/rooms/models.py b/backend/apps/rooms/models.py index 83f067807a3c1c868d93646c1be7bb0e6e84b2e4..222f076ed7cf8a598f8d4a64358134f2378bb291 100644 --- a/backend/apps/rooms/models.py +++ b/backend/apps/rooms/models.py @@ -39,7 +39,7 @@ class Room(models.Model): 数据关系: - 与房间使用记录关联 (RoomUsage) - 与房间维修记录关联 (RoomMaintenance) - - 与订单记录关联 (app.orders.models.Order) + - 与订单记录关联 (apps.orders.models.Order) - 与预约记录关联 (bookings.Booking) """ diff --git a/backend/apps/rooms/serializers.py b/backend/apps/rooms/serializers.py index 3aff5058f8fcb776383cb6d09f7435d04822c80b..4c3a27e5a493f294285525241d9d6951a39f48fd 100644 --- a/backend/apps/rooms/serializers.py +++ b/backend/apps/rooms/serializers.py @@ -39,5 +39,5 @@ class RoomMaintenanceSerializer(serializers.ModelSerializer): class Meta: model = RoomMaintenance - fields = '__all__' - read_only_fields = ['id', 'created_at', 'created_by'] + exclude = ['created_by'] + read_only_fields = ['id', 'created_at'] diff --git a/backend/apps/rooms/urls.py b/backend/apps/rooms/urls.py index 684464c3b5e53bbcd448070118c6a8246134e1a8..18635b0906674929300c15485b131dd6c659923b 100644 --- a/backend/apps/rooms/urls.py +++ b/backend/apps/rooms/urls.py @@ -3,9 +3,9 @@ from rest_framework.routers import DefaultRouter from .views import RoomViewSet, RoomUsageViewSet, RoomMaintenanceViewSet router = DefaultRouter() -router.register(r'', RoomViewSet, basename='room') router.register(r'usages', RoomUsageViewSet, basename='room_usage') router.register(r'maintenances', RoomMaintenanceViewSet, basename='room_maintenance') +router.register(r'', RoomViewSet, basename='room') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/apps/rooms/views.py b/backend/apps/rooms/views.py index e824c507208f655b2b236373c574c56ee8d51312..932b65ce9abe5b741c293d3254d1853511805ed7 100644 --- a/backend/apps/rooms/views.py +++ b/backend/apps/rooms/views.py @@ -1,4 +1,4 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets, status, serializers from rest_framework.decorators import action from rest_framework.response import Response from django.utils import timezone @@ -60,5 +60,24 @@ class RoomMaintenanceViewSet(viewsets.ModelViewSet): filterset_fields = ['room', 'status', 'start_date'] ordering = ['-start_date'] - def perform_create(self, serializer): - serializer.save(created_by=self.request.user) + def create(self, request, *args, **kwargs): + # 验证必填字段 + room_id = request.data.get('room') + if not room_id: + raise serializers.ValidationError({'room': '房间是必填项'}) + + description = request.data.get('description') + if not description: + raise serializers.ValidationError({'description': '问题描述是必填项'}) + + maintenance = RoomMaintenance.objects.create( + room_id=room_id, + description=description, + cost=float(request.data.get('cost', 0)), + start_date=request.data.get('start_date'), + end_date=request.data.get('end_date') or None, + status=request.data.get('status', 'pending'), + remark=request.data.get('remark', ''), + created_by_id=request.user.pk + ) + return Response(RoomMaintenanceSerializer(maintenance).data, status=status.HTTP_201_CREATED) diff --git a/backend/apps/services/models.py b/backend/apps/services/models.py index a3a172b40d63f2bffbae19d077a15a71fa60fcdb..bc0aafc6f324d06386ea76a507ff92bcc551f68e 100644 --- a/backend/apps/services/models.py +++ b/backend/apps/services/models.py @@ -37,7 +37,7 @@ class Service(models.Model): - 服务订单创建 数据关系: - - 与订单明细关联 (app.orders.models.OrderItem) + - 与订单明细关联 (apps.orders.models.OrderItem) - 与服务套餐关联 (ServicePackage) - 与创建人关联 (User) """ diff --git a/backend/apps/services/serializers.py b/backend/apps/services/serializers.py index bfc732942debd844313d8c12564539805cbe381e..53960c450119be944e08a4dd7ddbfd1f975288a3 100644 --- a/backend/apps/services/serializers.py +++ b/backend/apps/services/serializers.py @@ -6,8 +6,8 @@ class ServiceSerializer(serializers.ModelSerializer): """服务序列化器""" class Meta: model = Service - fields = '__all__' - read_only_fields = ['id', 'created_at', 'updated_at', 'created_by', 'service_no'] + exclude = ['created_by'] + read_only_fields = ['id', 'created_at', 'updated_at', 'service_no'] class ServiceListSerializer(serializers.ModelSerializer): @@ -31,12 +31,12 @@ class ServiceCategorySerializer(serializers.ModelSerializer): class ServicePackageSerializer(serializers.ModelSerializer): """服务套餐序列化器""" - services_detail = ServiceSerializer(source='services', many=True, read_only=True) + services_detail = ServiceSerializer(many=True, read_only=True) class Meta: model = ServicePackage - fields = '__all__' - read_only_fields = ['id', 'created_at', 'updated_at', 'created_by'] + exclude = ['created_by'] + read_only_fields = ['id', 'created_at', 'updated_at'] class ServicePackageListSerializer(serializers.ModelSerializer): diff --git a/backend/apps/services/urls.py b/backend/apps/services/urls.py index 23e478f02d9f12243f00bcbadfba8ee3ee6b48a0..5139419d04c0e9133f931d3d379501730b5d3554 100644 --- a/backend/apps/services/urls.py +++ b/backend/apps/services/urls.py @@ -3,9 +3,9 @@ from rest_framework.routers import DefaultRouter from .views import ServiceViewSet, ServiceCategoryViewSet, ServicePackageViewSet router = DefaultRouter() -router.register(r'', ServiceViewSet, basename='service') router.register(r'categories', ServiceCategoryViewSet, basename='service_category') router.register(r'packages', ServicePackageViewSet, basename='service_package') +router.register(r'', ServiceViewSet, basename='service') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/apps/services/views.py b/backend/apps/services/views.py index 67aaa1b905b18ef504852cba6aecdd96ba930626..b8045aa9f876d470fcb574ec4f6633c8d46d8428 100644 --- a/backend/apps/services/views.py +++ b/backend/apps/services/views.py @@ -1,4 +1,5 @@ -from rest_framework import viewsets, serializers +from rest_framework import viewsets, serializers, status +from rest_framework.response import Response from django.utils import timezone from .models import Service, ServiceCategory, ServicePackage from .serializers import ServiceSerializer, ServiceListSerializer, ServiceCategorySerializer, ServicePackageSerializer, ServicePackageListSerializer @@ -7,16 +8,18 @@ import string def generate_service_no(): - """生成服务编号""" + """生成服务编号 - 使用更安全的随机数生成""" + import secrets date_str = timezone.now().strftime('%Y%m%d') - random_str = ''.join(random.choices(string.digits, k=4)) + random_str = ''.join([str(secrets.randbelow(10)) for _ in range(4)]) return f'S{date_str}{random_str}' def generate_package_no(): - """生成套餐编号""" + """生成套餐编号 - 使用更安全的随机数生成""" + import secrets date_str = timezone.now().strftime('%Y%m%d') - random_str = ''.join(random.choices(string.digits, k=4)) + random_str = ''.join([str(secrets.randbelow(10)) for _ in range(4)]) return f'P{date_str}{random_str}' @@ -33,24 +36,37 @@ class ServiceViewSet(viewsets.ModelViewSet): return ServiceListSerializer return ServiceSerializer - def perform_create(self, serializer): + def create(self, request, *args, **kwargs): # 检查服务名称是否重复 - name = serializer.validated_data.get('name') + name = request.data.get('name') if name and Service.objects.filter(name=name).exists(): raise serializers.ValidationError({'name': '该服务名称已存在'}) # 验证必填字段 - duration = serializer.validated_data.get('duration') - if not duration or duration <= 0: + duration = request.data.get('duration') + if not duration or int(duration) <= 0: raise serializers.ValidationError({'duration': '服务时长必须大于0'}) - price = serializer.validated_data.get('price') - if not price or price < 0: + price = request.data.get('price') + if not price or float(price) < 0: raise serializers.ValidationError({'price': '价格不能为负数'}) # 生成服务编号 service_no = generate_service_no() - serializer.save(service_no=service_no, created_by=self.request.user) + + # 直接创建服务,使用user_id避免ForeignKey赋值问题 + service = Service.objects.create( + service_no=service_no, + name=request.data.get('name'), + type=request.data.get('type', 'foot'), + description=request.data.get('description', ''), + duration=int(duration), + price=float(price), + status=request.data.get('status', 'active'), + created_by_id=request.user.pk + ) + + return Response(ServiceSerializer(service).data, status=status.HTTP_201_CREATED) class ServiceCategoryViewSet(viewsets.ModelViewSet): @@ -71,8 +87,28 @@ class ServicePackageViewSet(viewsets.ModelViewSet): return ServicePackageListSerializer return ServicePackageSerializer - def perform_create(self, serializer): - package_no = serializer.validated_data.get('package_no') - if not package_no: - package_no = generate_package_no() - serializer.save(package_no=package_no, created_by=self.request.user) + def create(self, request, *args, **kwargs): + package_no = request.data.get('package_no') or generate_package_no() + name = request.data.get('name') + if not name: + from rest_framework import serializers + raise serializers.ValidationError({'name': '套餐名称是必填项'}) + + package = ServicePackage.objects.create( + package_no=package_no, + name=name, + description=request.data.get('description', ''), + original_price=float(request.data.get('original_price', 0)), + package_price=float(request.data.get('package_price', 0)), + duration=int(request.data.get('duration', 0)), + validity_days=int(request.data.get('validity_days', 30)), + status=request.data.get('status', 'active'), + sort_order=int(request.data.get('sort_order', 0)), + remark=request.data.get('remark', ''), + created_by_id=request.user.pk + ) + # 关联服务项目 + service_ids = request.data.get('services', []) + if service_ids: + package.services.set(service_ids) + return Response(ServicePackageSerializer(package).data, status=status.HTTP_201_CREATED) diff --git a/backend/apps/technicians/migrations/0002_technician_user.py b/backend/apps/technicians/migrations/0002_technician_user.py new file mode 100644 index 0000000000000000000000000000000000000000..0c403fec431ad1d333f947bceaa883a3cae730fe --- /dev/null +++ b/backend/apps/technicians/migrations/0002_technician_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.13 on 2026-06-19 08:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('technicians', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='technician', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='technician', to=settings.AUTH_USER_MODEL, verbose_name='用户账户'), + ), + ] diff --git a/backend/apps/technicians/models.py b/backend/apps/technicians/models.py index b358aeb2b9280dbd415d5e4abe43e3b32982f590..97396df2b9565a960cdd2b996a88ba1698196f6e 100644 --- a/backend/apps/technicians/models.py +++ b/backend/apps/technicians/models.py @@ -40,7 +40,7 @@ class Technician(models.Model): - 与用户账户关联 (django.contrib.auth.User) - 与排班记录关联 (TechnicianSchedule) - 与业绩记录关联 (TechnicianPerformance) - - 与订单记录关联 (app.orders.models.Order) + - 与订单记录关联 (apps.orders.models.Order) - 与考勤记录关联 (attendance.models.AttendanceRecord) """ @@ -73,6 +73,16 @@ class Technician(models.Model): verbose_name='技师编号' ) + # 关联用户账户 - 用于登录和权限控制 + user = models.OneToOneField( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='technician', + verbose_name='用户账户' + ) + # 技师姓名 name = models.CharField( max_length=50, diff --git a/backend/apps/technicians/models_commented.py b/backend/apps/technicians/models_commented.py deleted file mode 100644 index 4722d1fc83231e561edde679b99a72d29df91d8e..0000000000000000000000000000000000000000 --- a/backend/apps/technicians/models_commented.py +++ /dev/null @@ -1,289 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 技师管理模块 - 数据模型定义 -# 作者: Foot ERP Team -# 创建日期: 2024年3月25日 -# 版本: 1.0.0 -# -# 模块说明: 此模块定义了技师管理相关的所有数据模型 -# 包括技师基本信息、排班安排、业绩统计等核心业务数据 -# - -# 导入Django数据库模型基类 -# models模块提供了创建数据库表的所有必要功能 -from django.db import models - -# 导入Django用户模型获取函数 -# get_user_model()允许我们获取项目中配置的用户模型,提高代码灵活性 -from django.contrib.auth import get_user_model - -# 获取当前项目的用户模型 -# 这样可以在不硬编码用户模型的情况下建立外键关系 -User = get_user_model() - - -class Technician(models.Model): - """ - 技师信息模型 - 存储技师的基本信息和状态 - - 业务说明: - - 管理足浴/按摩店所有技师的基本信息 - - 记录技师的工作状态、技能等级、薪资信息等 - - 作为其他业务模块(排班、业绩、考勤)的核心关联表 - - 使用场景: - - 技师招聘和入职管理 - - 技师信息查询和修改 - - 技师排班和调度 - - 技师业绩统计和分析 - - 技师考勤管理 - - 数据关系: - - 与User模型关联:每个技师可以绑定一个系统用户账号 - - 与TechnicianSchedule关联:一个技师可以有多个排班记录 - - 与TechnicianPerformance关联:一个技师可以有多个业绩记录 - - 与AttendanceRecord关联:一个技师可以有多个考勤记录 - """ - - # 技师状态选择项 - 定义技师可能的工作状态 - # 用于限制status字段的可选值,确保数据一致性 - STATUS_CHOICES = [ - ('active', '在职'), # 正常在职状态,可以安排工作 - ('inactive', '离职'), # 已离职,不再安排工作 - ('leave', '请假'), # 请假中,暂时不安排工作 - ('busy', '忙碌'), # 正在服务中,暂时不能接受新预约 - ] - - # 性别选择项 - 定义性别的可选值 - GENDER_CHOICES = [ - ('male', '男'), # 男性 - ('female', '女'), # 女性 - ] - - # 技师等级选择项 - 定义技师的技能等级 - # 等级影响服务定价、提成比例等 - TECHNICIAN_LEVEL = [ - ('junior', '初级'), # 初级技师,经验较少 - ('intermediate', '中级'), # 中级技师,有一定经验 - ('senior', '高级'), # 高级技师,经验丰富 - ('expert', '专家'), # 专家级技师,技术精湛 - ] - - # 技师编号 - 唯一标识每个技师的工号 - # unique=True: 确保每个技师编号唯一,不能重复 - # verbose_name: 在Django管理后台显示的字段名称 - technician_no = models.CharField( - max_length=20, # 字段最大长度为20个字符 - unique=True, # 唯一约束,确保技师编号不重复 - verbose_name='技师编号' # 管理后台显示的中文字段名 - ) - - # 技师姓名 - 存储技师的真实姓名 - name = models.CharField( - max_length=50, # 姓名最大长度为50个字符 - verbose_name='姓名' - ) - - # 性别字段 - 记录技师的性别 - # choices参数限制输入值必须在GENDER_CHOICES中 - gender = models.CharField( - max_length=10, # 存储选择项的key值 - choices=GENDER_CHOICES, # 限制可选值 - verbose_name='性别' - ) - - # 手机号码 - 技师的联系电话 - # unique=True确保手机号不重复,可用于登录验证 - phone = models.CharField( - max_length=20, # 考虑国际号码,设置较大长度 - unique=True, # 手机号唯一 - verbose_name='手机号' - ) - - # 技师等级 - 反映技师的技能水平和经验 - # 默认值为'junior',新入职技师默认为初级 - level = models.CharField( - max_length=20, # 存储等级选择项的key值 - choices=TECHNICIAN_LEVEL, # 限制为预定义的等级 - default='junior', # 默认等级为初级 - verbose_name='技师等级' - ) - - # 提成比例 - 技师服务收入的提成百分比 - # max_digits=5: 总共5位数字,decimal_places=2: 2位小数 - # 示例: 0.30 表示30%的提成比例 - commission_rate = models.DecimalField( - max_digits=5, # 最大位数:支持如100.00这样的值 - decimal_places=2, # 小数位数:精确到0.01% - default=0.3, # 默认提成比例30% - verbose_name='提成比例(%)' - ) - - # 底薪 - 技师的基本工资 - # 即使没有服务收入,也能获得的基本保障 - base_salary = models.DecimalField( - max_digits=10, # 支持较大金额,如999999.99 - decimal_places=2, # 精确到分 - default=0, # 默认底薪为0,可后期调整 - verbose_name='底薪' - ) - - # 工作状态 - 反映技师当前的工作状态 - # 用于排班和服务安排时的状态判断 - status = models.CharField( - max_length=20, # 存储状态选择项的key值 - choices=STATUS_CHOICES, # 限制为预定义的状态 - default='active', # 新技师默认为在职状态 - verbose_name='状态' - ) - - # 头像图片 - 技师的照片或头像 - # upload_to指定图片上传的目录路径 - # null=True, blank=True表示此字段可选 - avatar = models.ImageField( - upload_to='technicians/', # 图片保存在technicians/目录下 - null=True, # 数据库允许NULL值 - blank=True, # 表单验证允许为空 - verbose_name='头像' - ) - - # 身份证号 - 技师的身份证号码 - # 用于实名认证和档案管理 - id_card = models.CharField( - max_length=18, # 中国身份证号码长度为18位 - unique=True, # 身份证号必须唯一 - verbose_name='身份证号' - ) - - # 入职日期 - 技师加入公司的日期 - # 用于计算工龄、年假等 - join_date = models.DateField( - verbose_name='入职日期' # 仅存储日期,不包含时间 - ) - - # 出生日期 - 技师的生日信息 - # null=True, blank=True表示此字段可选,用于生日福利等 - birth_date = models.DateField( - null=True, # 允许数据库存储NULL - blank=True, # 表单验证允许为空 - verbose_name='出生日期' - ) - - # 家庭地址 - 技师的居住地址 - # TextField适合存储较长的文本信息 - address = models.TextField( - blank=True, # 允许为空 - verbose_name='地址' - ) - - # 技能特长 - 描述技师的专业技能和特长 - # 用于客户选择技师时的参考 - skills = models.TextField( - blank=True, # 允许为空 - verbose_name='技能特长' - ) - - # 备注信息 - 其他需要记录的补充信息 - remark = models.TextField( - blank=True, # 允许为空 - verbose_name='备注' - ) - - # 创建时间 - 记录技师信息创建的时间 - # auto_now_add=True: 创建记录时自动设置为当前时间 - created_at = models.DateTimeField( - auto_now_add=True, # 自动设置创建时间为当前时间 - verbose_name='创建时间' - ) - - # 更新时间 - 记录技师信息最后修改的时间 - # auto_now=True: 每次保存记录时自动更新为当前时间 - updated_at = models.DateTimeField( - auto_now=True, # 自动更新为当前时间 - verbose_name='更新时间' - ) - - # 创建人外键 - 记录是谁创建了这个技师信息 - # 用于审计追踪和责任归属 - created_by = models.ForeignKey( - User, # 关联到用户模型 - on_delete=models.SET_NULL, # 如果用户被删除,此字段设为NULL - null=True, # 允许为NULL(当创建用户被删除时) - related_name='created_technicians', # 反向查询名称 - verbose_name='创建人' - ) - - class Meta: - """ - 模型元数据类 - 定义模型级别的配置选项 - """ - db_table = 'technicians' # 指定数据库中的表名为technicians - verbose_name = '技师' # 单个对象的显示名称 - verbose_name_plural = verbose_name # 复数形式与单数相同 - ordering = ['-created_at'] # 默认按创建时间倒序排列(最新的在前) - - def __str__(self): - """ - 对象的字符串表示方法 - 在Django管理后台、日志、调试信息等地方显示对象的友好名称 - - 返回格式: "技师编号 - 技师姓名" - 示例: "T001 - 张三" - - 注意: 此方法只返回基本信息,不影响数据库存储 - """ - return f'{self.technician_no} - {self.name}' - - -class TechnicianSchedule(models.Model): - """ - 技师排班模型 - 管理技师的工作安排和时间表 - - 业务说明: - - 记录技师每天的工作时间安排 - - 支持灵活的排班制度(轮班、固定班等) - - 用于预约系统的时间可用性判断 - - 为考勤系统提供排班基准 - - 使用场景: - - 制定技师排班计划 - - 客户预约时检查技师时间可用性 - - 考勤打卡的时间基准 - - 工资计算的工作时间依据 - - 数据关系: - - 与Technician模型的多对一关系:一个技师可以有多个排班记录 - - 排班记录按日期和技师唯一确定 - """ - - # 技师外键 - 关联到具体的技师 - # CASCADE删除策略:当技师被删除时,相关排班记录也自动删除 - # related_name允许通过technician.schedules访问该技师的所有排班 - technician = models.ForeignKey( - Technician, - on_delete=models.CASCADE, # 技师删除时,排班记录也删除 - related_name='schedules', # 反向查询名称:technician.schedules - verbose_name='技师' - ) - - # 排班日期 - 这个排班安排对应的日期 - # DateField只存储日期部分,不包含具体时间 - date = models.DateField( - verbose_name='日期' - ) - - # 开始时间 - 当天工作的开始时间 - # TimeField只存储时间部分,不包含日期 - # 示例: 09:00:00 表示上午9点开始工作 - start_time = models.TimeField( - verbose_name='开始时间' - ) - - # 结束时间 - 当天工作的结束时间 - # 示例: 18:00:00 表示下午6点结束工作 - end_time = models.TimeField( - verbose_name='结束时间' - ) - - # 排班状态 - 当天的工作状态 - # 用于 \ No newline at end of file diff --git a/backend/apps/technicians/serializers.py b/backend/apps/technicians/serializers.py index ea2b4bbfd4b34e703aecdeb386908bf4d31ae9e7..2d796421f1f4657aeb9f9798acca0f9d53e9b8f1 100644 --- a/backend/apps/technicians/serializers.py +++ b/backend/apps/technicians/serializers.py @@ -1,13 +1,22 @@ from rest_framework import serializers +from django.contrib.auth import get_user_model from .models import Technician, TechnicianSchedule, TechnicianPerformance class TechnicianSerializer(serializers.ModelSerializer): """技师序列化器""" + user = serializers.PrimaryKeyRelatedField( + queryset=get_user_model().objects.all(), + required=False, + allow_null=True, + help_text='关联的用户账户ID' + ) + user_name = serializers.CharField(source='user.username', read_only=True) + class Meta: model = Technician - fields = '__all__' - read_only_fields = ['id', 'created_at', 'updated_at', 'created_by', 'technician_no'] + exclude = ['created_by'] + read_only_fields = ['id', 'created_at', 'updated_at', 'technician_no'] class TechnicianListSerializer(serializers.ModelSerializer): diff --git a/backend/apps/technicians/urls.py b/backend/apps/technicians/urls.py index c25f06cd3387b676caa4395361979cbe0e140d61..14859a865f5942940097d05534ebd4c8d81d6ba1 100644 --- a/backend/apps/technicians/urls.py +++ b/backend/apps/technicians/urls.py @@ -3,9 +3,9 @@ from rest_framework.routers import DefaultRouter from .views import TechnicianViewSet, TechnicianScheduleViewSet, TechnicianPerformanceViewSet router = DefaultRouter() -router.register(r'', TechnicianViewSet, basename='technician') router.register(r'schedules', TechnicianScheduleViewSet, basename='technician_schedule') router.register(r'performances', TechnicianPerformanceViewSet, basename='technician_performance') +router.register(r'', TechnicianViewSet, basename='technician') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/apps/technicians/views.py b/backend/apps/technicians/views.py index 3f5654d701de7f5b55b77002790b90a66d27f099..273d93835fe5118722adafc8514774c0bbcfe4de 100644 --- a/backend/apps/technicians/views.py +++ b/backend/apps/technicians/views.py @@ -11,9 +11,10 @@ import string def generate_technician_no(): - """生成技师编号""" + """生成技师编号 - 使用更安全的随机数生成""" + import secrets date_str = timezone.now().strftime('%Y%m%d') - random_str = ''.join(random.choices(string.digits, k=4)) + random_str = ''.join([str(secrets.randbelow(10)) for _ in range(4)]) return f'T{date_str}{random_str}' @@ -30,20 +31,43 @@ class TechnicianViewSet(viewsets.ModelViewSet): return TechnicianListSerializer return TechnicianSerializer - def perform_create(self, serializer): + def create(self, request, *args, **kwargs): # 检查手机号是否重复 - phone = serializer.validated_data.get('phone') - if Technician.objects.filter(phone=phone).exists(): + phone = request.data.get('phone') + if phone and Technician.objects.filter(phone=phone).exists(): raise serializers.ValidationError({'phone': '该手机号已存在'}) # 检查身份证号是否重复 - id_card = serializer.validated_data.get('id_card') - if Technician.objects.filter(id_card=id_card).exists(): + id_card = request.data.get('id_card') + if id_card and Technician.objects.filter(id_card=id_card).exists(): raise serializers.ValidationError({'id_card': '该身份证号已存在'}) + # 验证必填字段 + name = request.data.get('name') + if not name or len(name) < 2 or len(name) > 50: + raise serializers.ValidationError({'name': '姓名长度必须在2-50个字符之间'}) + # 生成技师编号 technician_no = generate_technician_no() - serializer.save(technician_no=technician_no, created_by=self.request.user) + technician = Technician.objects.create( + technician_no=technician_no, + name=name, + gender=request.data.get('gender', 'male'), + phone=phone, + level=request.data.get('level', 'junior'), + commission_rate=float(request.data.get('commission_rate', 0.3)), + base_salary=float(request.data.get('base_salary', 0)), + status=request.data.get('status', 'active'), + id_card=id_card, + join_date=request.data.get('join_date'), + birth_date=request.data.get('birth_date') or None, + address=request.data.get('address', ''), + skills=request.data.get('skills', ''), + remark=request.data.get('remark', ''), + user_id=request.data.get('user') or None, + created_by_id=request.user.pk + ) + return Response(TechnicianSerializer(technician).data, status=status.HTTP_201_CREATED) @action(detail=True, methods=['get']) def performance(self, request, pk=None): diff --git a/backend/apps/utils/__init__.py b/backend/apps/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/apps/utils/documentation.py b/backend/apps/utils/documentation.py new file mode 100644 index 0000000000000000000000000000000000000000..b965a72bb21831b133dce35a445d16c17c9ca2f4 --- /dev/null +++ b/backend/apps/utils/documentation.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# +# API文档示例和工具模块 +# 作者: Foot ERP 前端团队 +# 创建: 2026-06-06 +# 版本: 1.0.0 +# 用途: 提供API文档示例和工具 +# + +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample +from drf_spectacular.types import OpenApiTypes + + +# 通用响应示例 +SUCCESS_RESPONSE_EXAMPLE = OpenApiExample( + '成功响应', + value={ + 'success': True, + 'message': '操作成功', + 'data': {}, + } +) + +ERROR_RESPONSE_EXAMPLE = OpenApiExample( + '错误响应', + value={ + 'success': False, + 'message': '操作失败', + 'error': '错误详情', + } +) + +VALIDATION_ERROR_EXAMPLE = OpenApiExample( + '验证错误响应', + value={ + 'success': False, + 'message': '输入验证失败', + 'errors': { + 'field_name': ['错误信息1', '错误信息2'], + } + } +) + +UNAUTHORIZED_RESPONSE_EXAMPLE = OpenApiExample( + '未授权响应', + value={ + 'detail': '身份认证信息无效或已过期。' + } +) + +FORBIDDEN_RESPONSE_EXAMPLE = OpenApiExample( + '禁止访问响应', + value={ + 'detail': '您没有权限执行此操作。' + } +) + +NOT_FOUND_RESPONSE_EXAMPLE = OpenApiExample( + '未找到响应', + value={ + 'detail': '未找到请求的资源。' + } +) + + +# 分页参数 +PAGE_PARAMETER = OpenApiParameter( + name='page', + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description='页码', + default=1, +) + +PAGE_SIZE_PARAMETER = OpenApiParameter( + name='page_size', + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description='每页数量', + default=20, +) + + +# 认证参数 +TOKEN_PARAMETER = OpenApiParameter( + name='Authorization', + type=OpenApiTypes.STR, + location=OpenApiParameter.HEADER, + description='JWT Token (Bearer token)', + required=True, +) + + +# 通用装饰器 +def api_endpoint_summary(summary): + """API端点摘要装饰器""" + def decorator(func): + @extend_schema( + summary=summary, + responses={ + 200: {'description': '成功'}, + 400: {'description': '请求错误'}, + 401: {'description': '未授权'}, + 403: {'description': '禁止访问'}, + 404: {'description': '未找到'}, + 500: {'description': '服务器错误'}, + } + ) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + return decorator + + +# 常用API文档示例 +LOGIN_EXAMPLES = [ + OpenApiExample( + '登录请求', + value={ + 'username': 'admin', + 'password': 'admin123', + }, + request_only=True, + ), +] + +LOGIN_RESPONSE_EXAMPLES = [ + OpenApiExample( + '登录成功', + value={ + 'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...', + 'user': { + 'id': 1, + 'username': 'admin', + 'email': 'admin@example.com', + 'role': 'admin', + 'phone': '13800138000', + } + }, + response_only=True, + ), +] + +MEMBER_CREATE_EXAMPLES = [ + OpenApiExample( + '创建会员', + value={ + 'name': '张三', + 'phone': '13812345678', + 'level': 'normal', + 'birthday': '1990-01-01', + 'address': '北京市朝阳区', + }, + request_only=True, + ), +] + +MEMBER_RESPONSE_EXAMPLES = [ + OpenApiExample( + '会员信息', + value={ + 'id': 1, + 'member_no': 'M202401010001', + 'name': '张三', + 'phone': '13812345678', + 'level': 'normal', + 'balance': '1000.00', + 'points': 100, + 'status': 'active', + 'created_at': '2024-01-01T00:00:00Z', + }, + response_only=True, + ), +] + +ORDER_CREATE_EXAMPLES = [ + OpenApiExample( + '创建订单', + value={ + 'member': 1, + 'technician': 1, + 'items': [ + { + 'service': 1, + 'service_name': '足底按摩', + 'quantity': 1, + 'unit_price': 180, + 'total': 180, + } + ], + 'subtotal': 180, + 'total_amount': 180, + 'payment_method': 'cash', + 'remark': '无', + }, + request_only=True, + ), +] + +BOOKING_CREATE_EXAMPLES = [ + OpenApiExample( + '创建预约', + value={ + 'guest_name': '客户A', + 'guest_phone': '13712345678', + 'service': 1, + 'room': 1, + 'booking_date': '2024-12-25', + 'booking_time': '14:00:00', + 'type': 'walk_in', + 'remark': '无', + }, + request_only=True, + ), +] + + +# API标签 +API_TAGS = { + 'auth': '认证', + 'members': '会员管理', + 'technicians': '技师管理', + 'rooms': '房间管理', + 'services': '服务管理', + 'bookings': '预约管理', + 'attendance': '考勤管理', + 'orders': '订单管理', + 'reports': '报表统计', + 'mall': '商城', + 'chat': '客服聊天', + 'feedback': '反馈管理', +} + + +# HTTP状态码说明 +HTTP_STATUS_DESCRIPTIONS = { + 200: '请求成功', + 201: '创建成功', + 400: '请求错误', + 401: '未授权', + 403: '禁止访问', + 404: '未找到', + 405: '方法不允许', + 408: '请求超时', + 409: '冲突', + 422: '无法处理的实体', + 429: '请求过多', + 500: '服务器内部错误', + 502: '网关错误', + 503: '服务不可用', + 504: '网关超时', +} diff --git a/backend/apps/utils/file_validators.py b/backend/apps/utils/file_validators.py new file mode 100644 index 0000000000000000000000000000000000000000..295d2b74692ca724eda736564da4bf6956919da4 --- /dev/null +++ b/backend/apps/utils/file_validators.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +# +# 文件上传验证工具模块 +# 作者: Foot ERP 前端团队 +# 创建: 2026-06-06 +# 版本: 1.0.0 +# 用途: 提供文件上传验证功能 +# + +import os +import magic +from django.core.exceptions import ValidationError +from django.conf import settings + + +# 允许的文件类型 +ALLOWED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +] + +ALLOWED_DOCUMENT_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +] + +ALLOWED_VIDEO_TYPES = [ + 'video/mp4', + 'video/webm', + 'video/ogg', +] + +# 最大文件大小(字节) +MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB +MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB +MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB + +# 危险的文件扩展名 +DANGEROUS_EXTENSIONS = [ + '.exe', '.bat', '.cmd', '.sh', '.php', '.asp', '.aspx', '.jsp', + '.py', '.rb', '.pl', '.cgi', '.dll', '.so', '.jar', +] + + +def validate_file_extension(filename): + """ + 验证文件扩展名 + + 参数: + filename: 文件名 + + 返回: + bool: 是否安全 + """ + ext = os.path.splitext(filename)[1].lower() + return ext not in DANGEROUS_EXTENSIONS + + +def validate_file_type(file, allowed_types): + """ + 验证文件MIME类型 + + 参数: + file: 文件对象 + allowed_types: 允许的MIME类型列表 + + 返回: + bool: 是否有效 + """ + try: + # 读取文件头部判断MIME类型 + mime = magic.from_buffer(file.read(1024), mime=True) + file.seek(0) # 重置文件指针 + return mime in allowed_types + except Exception: + return False + + +def validate_file_size(file, max_size): + """ + 验证文件大小 + + 参数: + file: 文件对象 + max_size: 最大大小(字节) + + 返回: + bool: 是否有效 + """ + return file.size <= max_size + + +def validate_image_file(file): + """ + 验证图片文件 + + 参数: + file: 文件对象 + + 返回: + tuple: (是否有效, 错误信息) + """ + if not validate_file_extension(file.name): + return False, '文件类型不允许' + + if not validate_file_size(file, MAX_IMAGE_SIZE): + return False, f'图片大小不能超过 {MAX_IMAGE_SIZE // (1024*1024)}MB' + + if not validate_file_type(file, ALLOWED_IMAGE_TYPES): + return False, '只允许上传图片文件(JPEG, PNG, GIF, WebP)' + + return True, 'OK' + + +def validate_document_file(file): + """ + 验证文档文件 + + 参数: + file: 文件对象 + + 返回: + tuple: (是否有效, 错误信息) + """ + if not validate_file_extension(file.name): + return False, '文件类型不允许' + + if not validate_file_size(file, MAX_DOCUMENT_SIZE): + return False, f'文档大小不能超过 {MAX_DOCUMENT_SIZE // (1024*1024)}MB' + + if not validate_file_type(file, ALLOWED_DOCUMENT_TYPES): + return False, '只允许上传文档文件(PDF, DOC, DOCX, XLS, XLSX)' + + return True, 'OK' + + +def validate_video_file(file): + """ + 验证视频文件 + + 参数: + file: 文件对象 + + 返回: + tuple: (是否有效, 错误信息) + """ + if not validate_file_extension(file.name): + return False, '文件类型不允许' + + if not validate_file_size(file, MAX_VIDEO_SIZE): + return False, f'视频大小不能超过 {MAX_VIDEO_SIZE // (1024*1024)}MB' + + if not validate_file_type(file, ALLOWED_VIDEO_TYPES): + return False, '只允许上传视频文件(MP4, WebM, OGG)' + + return True, 'OK' + + +def sanitize_filename(filename): + """ + 净化文件名,移除危险字符 + + 参数: + filename: 文件名 + + 返回: + str: 净化后的文件名 + """ + # 移除路径分隔符 + filename = filename.replace('/', '').replace('\\', '') + + # 移除控制字符 + filename = ''.join(char for char in filename if ord(char) > 31) + + # 移除危险字符 + dangerous_chars = ['<', '>', ':', '"', '|', '?', '*'] + for char in dangerous_chars: + filename = filename.replace(char, '') + + # 限制文件名长度 + name, ext = os.path.splitext(filename) + if len(name) > 100: + name = name[:100] + + return name + ext + + +def validate_file(file, file_type='image'): + """ + 通用文件验证函数 + + 参数: + file: 文件对象 + file_type: 文件类型 ('image', 'document', 'video') + + 返回: + tuple: (是否有效, 错误信息) + """ + validators = { + 'image': validate_image_file, + 'document': validate_document_file, + 'video': validate_video_file, + } + + validator = validators.get(file_type) + if not validator: + return False, '不支持的文件类型' + + return validator(file) diff --git a/backend/apps/utils/permissions.py b/backend/apps/utils/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..2c10b7b9c5a11cc7afef5ff53a2423e3e952eebb --- /dev/null +++ b/backend/apps/utils/permissions.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# +# 自定义权限类模块 +# 作者: Foot ERP 团队 +# 创建: 2026-08-13 +# 版本: 1.0.0 +# 用途: 提供自定义权限控制类 +# + +from rest_framework import permissions + + +class IsAuthenticatedOrGuest(permissions.BasePermission): + """ + 允许已认证用户或访客访问的权限类 + + - 已认证用户:可以访问所有功能 + - 访客(未认证用户):可以访问公开功能(如创建对话、发送消息等) + - 管理员用户:拥有所有权限 + + 使用场景: + - 聊天对话模块:允许访客发起对话 + - 商城模块:允许访客浏览商品 + - 反馈模块:允许访客提交反馈 + """ + + def has_permission(self, request, view): + # 允许已认证用户访问 + if request.user and request.user.is_authenticated: + return True + + # 允许访客通过特定方式访问 + # 检查是否有访客会话标识 + guest_session_id = ( + request.query_params.get('guest_session_id') + or request.data.get('guest_session_id') + or request.headers.get('X-Guest-Session-Id') + ) + + if guest_session_id: + return True + + # 对于安全敏感的 action,禁止访客访问 + restricted_actions = ['destroy', 'update', 'partial_update'] + if hasattr(view, 'action') and view.action in restricted_actions: + return False + + # 允许列表查看和创建操作 + allowed_actions = ['list', 'create', 'retrieve', 'messages', + 'initiate', 'send_message', 'stats'] + if hasattr(view, 'action') and view.action in allowed_actions: + return True + + # 默认允许 GET 请求(安全方法) + return request.method in permissions.SAFE_METHODS + + +class IsAdminOrReadOnly(permissions.BasePermission): + """ + 管理员可写,其他用户只读的权限类 + + - 管理员:可以执行所有操作 + - 其他已认证用户:只能读取 + - 访客:只能读取公开数据 + """ + + def has_permission(self, request, view): + # 安全方法允许所有人访问 + if request.method in permissions.SAFE_METHODS: + return True + + # 写操作需要管理员权限 + return ( + request.user + and request.user.is_authenticated + and request.user.is_staff + ) + + +class IsOwnerOrAdmin(permissions.BasePermission): + """ + 只允许资源所有者或管理员访问的权限类 + + - 管理员:可以访问所有资源 + - 资源所有者:只能访问自己的资源 + - 其他用户:无权限 + """ + + def has_object_permission(self, request, view, obj): + # 管理员拥有所有权限 + if request.user and request.user.is_staff: + return True + + # 检查对象是否属于当前用户 + if hasattr(obj, 'user'): + return obj.user == request.user + if hasattr(obj, 'created_by'): + return obj.created_by == request.user + if hasattr(obj, 'member') and hasattr(request.user, 'member'): + return obj.member == request.user.member + + return False diff --git a/backend/apps/utils/serializers.py b/backend/apps/utils/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..d6d7b4b2aa2da5b274b81aab84c8c3e4c69d3067 --- /dev/null +++ b/backend/apps/utils/serializers.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# +# 序列化器工具模块 +# 作者: Foot ERP 前端团队 +# 创建: 2026-06-06 +# 版本: 1.0.0 +# 用途: 提供序列化器增强功能 +# + +from rest_framework import serializers +from .validators import sanitize_input, validate_no_sql_injection, validate_no_xss + + +class SanitizingSerializerMixin: + """ + 输入净化序列化器混入类 + + 自动净化所有字符串字段,防止XSS攻击 + + 用法: + class MySerializer(SanitizingSerializerMixin, serializers.ModelSerializer): + class Meta: + model = MyModel + fields = '__all__' + """ + + def to_internal_value(self, data): + """ + 在数据验证前净化输入 + """ + if isinstance(data, dict): + sanitized_data = {} + for key, value in data.items(): + if isinstance(value, str): + # 净化字符串值 + sanitized_value = sanitize_input(value) + + # 检查SQL注入 + if not validate_no_sql_injection(value): + raise serializers.ValidationError({ + key: '输入包含非法字符' + }) + + # 检查XSS + if not validate_no_xss(value): + raise serializers.ValidationError({ + key: '输入包含潜在的XSS攻击代码' + }) + + sanitized_data[key] = sanitized_value + else: + sanitized_data[key] = value + + return super().to_internal_value(sanitized_data) + + return super().to_internal_value(data) + + +class ValidatingSerializerMixin: + """ + 验证序列化器混入类 + + 提供常用验证方法 + + 用法: + class MySerializer(ValidatingSerializerMixin, serializers.ModelSerializer): + class Meta: + model = MyModel + fields = '__all__' + """ + + def validate_phone(self, value): + """验证手机号""" + from .validators import validate_phone + if not validate_phone(value): + raise serializers.ValidationError('请输入有效的手机号') + return value + + def validate_id_card(self, value): + """验证身份证号""" + from .validators import validate_id_card + if not validate_id_card(value): + raise serializers.ValidationError('请输入有效的身份证号') + return value + + def validate_email(self, value): + """验证邮箱""" + from .validators import validate_email + if not validate_email(value): + raise serializers.ValidationError('请输入有效的邮箱地址') + return value + + def validate_username(self, value): + """验证用户名""" + from .validators import validate_username + if not validate_username(value): + raise serializers.ValidationError('用户名必须是4-20位字母数字下划线') + return value + + +class BaseSerializer(SanitizingSerializerMixin, serializers.Serializer): + """ + 基础序列化器 + + 所有自定义序列化器都应该继承此类 + """ + pass + + +class BaseModelSerializer(SanitizingSerializerMixin, serializers.ModelSerializer): + """ + 基础模型序列化器 + + 所有模型序列化器都应该继承此类 + """ + pass diff --git a/backend/apps/utils/validators.py b/backend/apps/utils/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..26e2574cf627e6235415c8691c949e7441733bc5 --- /dev/null +++ b/backend/apps/utils/validators.py @@ -0,0 +1,433 @@ +# -*- coding: utf-8 -*- +# +# 输入验证和净化工具模块 +# 作者: Foot ERP 前端团队 +# 创建: 2026-06-06 +# 版本: 1.0.0 +# 用途: 提供输入验证和XSS防护功能 +# + +import re +import html +from django.core.exceptions import ValidationError + + +def sanitize_input(value): + """ + 净化用户输入,防止XSS攻击 + + 参数: + value: 输入值(字符串) + + 返回: + 净化后的字符串 + """ + if not isinstance(value, str): + return value + + # HTML实体编码 + value = html.escape(value) + + # 移除潜在的JavaScript事件处理器 + value = re.sub(r'on\w+\s*=', '', value, flags=re.IGNORECASE) + + # 移除潜在的JavaScript协议 + value = re.sub(r'javascript:', '', value, flags=re.IGNORECASE) + + # 移除潜在的数据URI + value = re.sub(r'data:', '', value, flags=re.IGNORECASE) + + # 移除潜在的VBScript + value = re.sub(r'vbscript:', '', value, flags=re.IGNORECASE) + + return value.strip() + + +def sanitize_html(value): + """ + 允许安全的HTML标签,移除危险标签和属性 + + 参数: + value: HTML字符串 + + 返回: + 净化后的HTML字符串 + """ + if not isinstance(value, str): + return value + + # 允许的安全标签 + allowed_tags = ['b', 'i', 'u', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a', 'span', 'div'] + + # 移除所有标签 + clean_text = re.sub(r'<[^>]+>', '', value) + + # HTML实体编码 + clean_text = html.escape(clean_text) + + return clean_text + + +def validate_phone(phone): + """ + 验证手机号格式 + + 参数: + phone: 手机号字符串 + + 返回: + bool: 是否有效 + """ + pattern = r'^1[3-9]\d{9}$' + return bool(re.match(pattern, phone)) + + +def validate_id_card(id_card): + """ + 验证身份证号格式 + + 参数: + id_card: 身份证号字符串 + + 返回: + bool: 是否有效 + """ + pattern = r'(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)' + + if not re.match(pattern, id_card): + return False + + # 18位身份证校验码验证 + if len(id_card) == 18: + try: + weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + check_codes = '10X98765432' + total = sum(int(id_card[i]) * weights[i] for i in range(17)) + return check_codes[total % 11] == id_card[17].upper() + except (ValueError, IndexError): + return False + + return True + + +def validate_email(email): + """ + 验证邮箱格式 + + 参数: + email: 邮箱字符串 + + 返回: + bool: 是否有效 + """ + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) + + +def validate_no_sql_injection(value): + """ + 检查SQL注入特征 + + 参数: + value: 输入字符串 + + 返回: + bool: 是否安全(True表示安全) + """ + if not isinstance(value, str): + return True + + # SQL注入特征 + sql_patterns = [ + r"(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|OR|AND)\b)", + r"(--|#|/\*)", + r"(\b(FROM|WHERE|INTO|VALUES|SET)\b)", + r"(;|\b(GRANT|REVOKE)\b)", + ] + + for pattern in sql_patterns: + if re.search(pattern, value, re.IGNORECASE): + return False + + return True + + +def validate_no_xss(value): + """ + 检查XSS攻击特征 + + 参数: + value: 输入字符串 + + 返回: + bool: 是否安全(True表示安全) + """ + if not isinstance(value, str): + return True + + # XSS特征 + xss_patterns = [ + r" + + +
+ + + +