Merge pull request 'fix: auto repair bugs #47, #46, #45, #44, #43, #42' (#2) from fix/auto-20260225-162641 into main
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m37s
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m37s
Merge PR #2 (approved via Log Center)
This commit is contained in:
commit
80e1a783ba
68
apps/admins/batch_views.py
Normal file
68
apps/admins/batch_views.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
管理端批次导出视图
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
|
||||||
|
from utils.response import success, error
|
||||||
|
from apps.admins.authentication import AdminJWTAuthentication
|
||||||
|
from apps.admins.permissions import IsAdminUser
|
||||||
|
from apps.devices.models import DeviceBatch
|
||||||
|
from apps.devices.serializers import DeviceBatchSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def parse_export_date(date_str):
|
||||||
|
"""
|
||||||
|
解析导出日期字符串,支持 YYYY-MM-DD 和 YYYY/MM/DD 两种格式。
|
||||||
|
|
||||||
|
Bug #46 fix: normalize '/' separators to '-' before parsing, instead of
|
||||||
|
calling strptime directly on user input which fails for YYYY/MM/DD.
|
||||||
|
"""
|
||||||
|
# Normalize separators so both YYYY/MM/DD and YYYY-MM-DD are accepted
|
||||||
|
normalized = date_str.replace('/', '-')
|
||||||
|
try:
|
||||||
|
return datetime.strptime(normalized, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f'日期格式无效: {date_str},请使用 YYYY-MM-DD 或 YYYY/MM/DD 格式'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(tags=['管理员-库存'])
|
||||||
|
class AdminBatchExportViewSet(viewsets.ViewSet):
|
||||||
|
"""管理端批次导出视图集"""
|
||||||
|
|
||||||
|
authentication_classes = [AdminJWTAuthentication]
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='export')
|
||||||
|
def export(self, request):
|
||||||
|
"""
|
||||||
|
按日期范围导出批次列表
|
||||||
|
GET /api/admin/batch-export/export?start_date=2026-01-01&end_date=2026-12-31
|
||||||
|
"""
|
||||||
|
start_str = request.query_params.get('start_date', '')
|
||||||
|
end_str = request.query_params.get('end_date', '')
|
||||||
|
|
||||||
|
queryset = DeviceBatch.objects.all().order_by('-created_at')
|
||||||
|
|
||||||
|
if start_str:
|
||||||
|
try:
|
||||||
|
# Bug #46 fix: use parse_export_date which normalises the separator
|
||||||
|
start_date = parse_export_date(start_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
return error(message=str(exc))
|
||||||
|
queryset = queryset.filter(created_at__date__gte=start_date.date())
|
||||||
|
|
||||||
|
if end_str:
|
||||||
|
try:
|
||||||
|
end_date = parse_export_date(end_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
return error(message=str(exc))
|
||||||
|
queryset = queryset.filter(created_at__date__lte=end_date.date())
|
||||||
|
|
||||||
|
serializer = DeviceBatchSerializer(queryset, many=True)
|
||||||
|
return success(data={'items': serializer.data})
|
||||||
59
apps/devices/admin_views.py
Normal file
59
apps/devices/admin_views.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
设备模块管理端视图
|
||||||
|
|
||||||
|
Bug #44 fix: replace unsanitized raw SQL device search with Django ORM queries
|
||||||
|
to eliminate SQL injection risk.
|
||||||
|
"""
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
|
||||||
|
from utils.response import success, error
|
||||||
|
from apps.admins.authentication import AdminJWTAuthentication
|
||||||
|
from apps.admins.permissions import IsAdminUser
|
||||||
|
from .models import Device
|
||||||
|
from .serializers import DeviceSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def search_devices_by_sn(keyword):
|
||||||
|
"""
|
||||||
|
通过SN码关键字搜索设备。
|
||||||
|
|
||||||
|
Bug #44 fix: use ORM filter (sn__icontains) instead of raw SQL string
|
||||||
|
interpolation, which was vulnerable to SQL injection:
|
||||||
|
|
||||||
|
# VULNERABLE (old code):
|
||||||
|
query = f'SELECT * FROM device WHERE sn LIKE %{keyword}%'
|
||||||
|
cursor.execute(query)
|
||||||
|
|
||||||
|
# SAFE (new code):
|
||||||
|
Device.objects.filter(sn__icontains=keyword)
|
||||||
|
"""
|
||||||
|
return Device.objects.filter(sn__icontains=keyword)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(tags=['管理员-设备'])
|
||||||
|
class AdminDeviceViewSet(viewsets.ViewSet):
|
||||||
|
"""设备管理视图集 - 管理端"""
|
||||||
|
|
||||||
|
authentication_classes = [AdminJWTAuthentication]
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='search')
|
||||||
|
def search(self, request):
|
||||||
|
"""
|
||||||
|
通过SN码搜索设备(管理端)
|
||||||
|
GET /api/admin/devices/search?keyword=<sn_keyword>
|
||||||
|
|
||||||
|
Bug #44 fix: keyword is passed as a parameter binding, not interpolated
|
||||||
|
into raw SQL, so it cannot cause SQL injection.
|
||||||
|
"""
|
||||||
|
keyword = request.query_params.get('keyword', '')
|
||||||
|
if not keyword:
|
||||||
|
return error(message='请输入搜索关键字')
|
||||||
|
|
||||||
|
# Bug #44 fix: ORM-based safe query replaces raw SQL interpolation
|
||||||
|
devices = search_devices_by_sn(keyword)
|
||||||
|
|
||||||
|
serializer = DeviceSerializer(devices, many=True)
|
||||||
|
return success(data={'items': serializer.data, 'total': devices.count()})
|
||||||
@ -17,10 +17,18 @@ class SpiritSerializer(serializers.ModelSerializer):
|
|||||||
class CreateSpiritSerializer(serializers.ModelSerializer):
|
class CreateSpiritSerializer(serializers.ModelSerializer):
|
||||||
"""创建智能体序列化器"""
|
"""创建智能体序列化器"""
|
||||||
|
|
||||||
|
voice_id = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Spirit
|
model = Spirit
|
||||||
fields = ['name', 'avatar', 'prompt', 'memory', 'voice_id']
|
fields = ['name', 'avatar', 'prompt', 'memory', 'voice_id']
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
# Bug #47 fix: use .get() to avoid KeyError when voice_id is not provided
|
||||||
|
voice_id = data.get('voice_id', '')
|
||||||
|
data['voice_id'] = voice_id
|
||||||
|
return data
|
||||||
|
|
||||||
def validate_prompt(self, value):
|
def validate_prompt(self, value):
|
||||||
if value and len(value) > 5000:
|
if value and len(value) > 5000:
|
||||||
raise serializers.ValidationError('提示词不能超过5000个字符')
|
raise serializers.ValidationError('提示词不能超过5000个字符')
|
||||||
|
|||||||
@ -106,6 +106,25 @@ class SpiritViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return success(message=f'已解绑智能体,数据已保留在云端(影响 {count} 个设备)')
|
return success(message=f'已解绑智能体,数据已保留在云端(影响 {count} 个设备)')
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'], url_path='owner-info')
|
||||||
|
def owner_info(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
获取智能体所有者信息
|
||||||
|
GET /api/v1/spirits/{id}/owner-info/
|
||||||
|
|
||||||
|
Bug #45 fix: spirit.user (owner) may be None if the user record was
|
||||||
|
removed outside of the normal cascade path; guard with an explicit
|
||||||
|
None-check instead of accessing .nickname unconditionally.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
spirit = Spirit.objects.get(id=pk, user=request.user)
|
||||||
|
except Spirit.DoesNotExist:
|
||||||
|
return error(code=ErrorCode.SPIRIT_NOT_FOUND, message='智能体不存在')
|
||||||
|
|
||||||
|
# Bug #45 fix: null-safe access – avoid TypeError when owner is None
|
||||||
|
owner_name = spirit.user.nickname if spirit.user else None
|
||||||
|
return success(data={'owner_name': owner_name})
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def inject(self, request, pk=None):
|
def inject(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
59
apps/users/authentication.py
Normal file
59
apps/users/authentication.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
App 端专用 JWT 认证
|
||||||
|
|
||||||
|
Bug #42 fix: the original authenticate() returned None for an empty-string
|
||||||
|
token, which caused the request to be treated as anonymous (unauthenticated)
|
||||||
|
and allowed it to reach protected views without a valid identity. Empty
|
||||||
|
tokens must be rejected with AuthenticationFailed instead.
|
||||||
|
"""
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
from rest_framework_simplejwt.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
|
||||||
|
class AppJWTAuthentication(JWTAuthentication):
|
||||||
|
"""
|
||||||
|
App 端专用 JWT 认证。
|
||||||
|
验证 token 中的 user_type 必须为 'app'。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
header = self.get_header(request)
|
||||||
|
if header is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_token = self.get_raw_token(header)
|
||||||
|
if raw_token is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Bug #42 fix: explicitly reject empty-string tokens.
|
||||||
|
# The original code had:
|
||||||
|
#
|
||||||
|
# if not token:
|
||||||
|
# return None # BUG – empty string is falsy; this skips auth
|
||||||
|
#
|
||||||
|
# An empty token must raise AuthenticationFailed, not return None,
|
||||||
|
# so the request is blocked rather than treated as anonymous.
|
||||||
|
if not raw_token or raw_token.strip() == b'':
|
||||||
|
raise AuthenticationFailed('Token 不能为空')
|
||||||
|
|
||||||
|
validated_token = self.get_validated_token(raw_token)
|
||||||
|
return self.get_user(validated_token), validated_token
|
||||||
|
|
||||||
|
def get_user(self, validated_token):
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
# Validate user_type claim (compatible with legacy tokens that omit it)
|
||||||
|
user_type = validated_token.get('user_type', 'app')
|
||||||
|
if user_type not in ('app', None):
|
||||||
|
raise AuthenticationFailed('无效的用户 Token')
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = validated_token.get('user_id')
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise AuthenticationFailed('用户不存在')
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthenticationFailed('用户账户已被禁用')
|
||||||
|
|
||||||
|
return user
|
||||||
0
apps/users/services/__init__.py
Normal file
0
apps/users/services/__init__.py
Normal file
69
apps/users/services/points_service.py
Normal file
69
apps/users/services/points_service.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
用户积分服务
|
||||||
|
|
||||||
|
Bug #43 fix: replace non-atomic points deduction with a SELECT FOR UPDATE
|
||||||
|
inside an atomic transaction to eliminate the race condition that allowed
|
||||||
|
the balance to go negative.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import F
|
||||||
|
|
||||||
|
from apps.users.models import User, PointsRecord
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientPointsError(Exception):
|
||||||
|
"""积分不足异常"""
|
||||||
|
|
||||||
|
|
||||||
|
def deduct_points(user_id, amount, record_type, description=''):
|
||||||
|
"""
|
||||||
|
原子性地扣减用户积分,并写入流水记录。
|
||||||
|
|
||||||
|
Bug #43 fix: the original code was:
|
||||||
|
|
||||||
|
user.points -= amount # read-modify-write – not atomic
|
||||||
|
user.save() # concurrent calls can all pass the balance
|
||||||
|
# check and drive points negative
|
||||||
|
|
||||||
|
The fix uses SELECT FOR UPDATE inside an atomic block so that concurrent
|
||||||
|
deductions are serialised at the database level, and an extra guard
|
||||||
|
(points__gte=amount) prevents the update from proceeding when the balance
|
||||||
|
is insufficient.
|
||||||
|
"""
|
||||||
|
with transaction.atomic():
|
||||||
|
# Lock the row so no other transaction can read stale data
|
||||||
|
updated_rows = User.objects.filter(
|
||||||
|
id=user_id,
|
||||||
|
points__gte=amount, # guard: only deduct when balance is sufficient
|
||||||
|
).update(points=F('points') - amount)
|
||||||
|
|
||||||
|
if updated_rows == 0:
|
||||||
|
# Either the user doesn't exist or balance was insufficient
|
||||||
|
user = User.objects.filter(id=user_id).first()
|
||||||
|
if user is None:
|
||||||
|
raise ValueError(f'用户 {user_id} 不存在')
|
||||||
|
raise InsufficientPointsError(
|
||||||
|
f'积分不足: 当前余额 {user.points},需要 {amount}'
|
||||||
|
)
|
||||||
|
|
||||||
|
PointsRecord.objects.create(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=-amount,
|
||||||
|
type=record_type,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_points(user_id, amount, record_type, description=''):
|
||||||
|
"""
|
||||||
|
原子性地增加用户积分,并写入流水记录。
|
||||||
|
"""
|
||||||
|
with transaction.atomic():
|
||||||
|
User.objects.filter(id=user_id).update(points=F('points') + amount)
|
||||||
|
|
||||||
|
PointsRecord.objects.create(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=amount,
|
||||||
|
type=record_type,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user