Compare commits
No commits in common. "80e1a783ba3d81d32d3544e0da8b18f2480492e9" and "c252ad0c7874771bfe53149340a29911e93ac5ef" have entirely different histories.
80e1a783ba
...
c252ad0c78
@ -1,68 +0,0 @@
|
|||||||
"""
|
|
||||||
管理端批次导出视图
|
|
||||||
"""
|
|
||||||
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})
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
"""
|
|
||||||
设备模块管理端视图
|
|
||||||
|
|
||||||
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,18 +17,10 @@ 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,25 +106,6 @@ 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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
"""
|
|
||||||
用户积分服务
|
|
||||||
|
|
||||||
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