Compare commits
10 Commits
2628f7c281
...
bd684476c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd684476c7 | ||
|
|
d421677518 | ||
|
|
7a6a519fbe | ||
|
|
47be0781ac | ||
|
|
d2af1ddaa9 | ||
|
|
9144770130 | ||
|
|
5fb71b99f9 | ||
|
|
c96bef3515 | ||
|
|
f9857c17ee | ||
|
|
91d0311b95 |
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
*.sqlite3
|
||||||
|
media/
|
||||||
|
staticfiles/
|
||||||
|
.DS_Store
|
||||||
|
k8s/
|
||||||
|
.gitea/
|
||||||
|
docs/
|
||||||
|
CLAUDE.md
|
||||||
|
README.md
|
||||||
@ -39,7 +39,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Kubectl
|
- name: Setup Kubectl
|
||||||
run: |
|
run: |
|
||||||
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.2/bin/linux/amd64/kubectl"
|
curl -LO "https://dl.k8s.io/release/v1.28.2/bin/linux/amd64/kubectl" || \
|
||||||
|
curl -LO "https://cdn.dl.k8s.io/release/v1.28.2/bin/linux/amd64/kubectl"
|
||||||
chmod +x kubectl
|
chmod +x kubectl
|
||||||
mv kubectl /usr/local/bin/
|
mv kubectl /usr/local/bin/
|
||||||
|
|
||||||
|
|||||||
@ -28,4 +28,4 @@ COPY . /app/
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run entrypoint
|
# Run entrypoint
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "config.wsgi:application"]
|
||||||
|
|||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
181
app/api/device_api.py
Normal file
181
app/api/device_api.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
"""
|
||||||
|
设备 API 功能函数
|
||||||
|
提供设备查询、验证、列表等独立逻辑,作为 ViewSet 的补充
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apps.devices.models import Device, UserDevice
|
||||||
|
from utils.exceptions import ErrorCode
|
||||||
|
from utils.response import error, success
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 内部辅助
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_device_or_none(sn: str):
|
||||||
|
"""通过 SN 查找设备,不存在则返回 None(不抛异常)"""
|
||||||
|
try:
|
||||||
|
return Device.objects.select_related('device_type').get(sn=sn)
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device_by_mac(mac: str):
|
||||||
|
"""通过 MAC 地址查找设备,统一格式后查询"""
|
||||||
|
mac = mac.upper().replace('-', ':')
|
||||||
|
try:
|
||||||
|
return Device.objects.select_related('device_type').get(mac_address=mac)
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_device(device):
|
||||||
|
"""将 Device 对象序列化为字典"""
|
||||||
|
return {
|
||||||
|
'sn': device.sn,
|
||||||
|
'device_id': device.id,
|
||||||
|
'status': device.status,
|
||||||
|
'is_online': getattr(device, 'is_online', False),
|
||||||
|
'device_type': device.device_type.name if device.device_type else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 设备验证
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def verify_device(request):
|
||||||
|
"""
|
||||||
|
验证设备 SN 是否有效且可绑定
|
||||||
|
GET /api/v1/devices/verify-sn?sn=BRAND-P01-001
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- 200 + device info 如果设备存在
|
||||||
|
- 400 如果 SN 为空
|
||||||
|
- 404 如果设备不存在
|
||||||
|
"""
|
||||||
|
sn = request.GET.get('sn', '').strip()
|
||||||
|
if not sn:
|
||||||
|
return error(message='SN 码不能为空')
|
||||||
|
|
||||||
|
device = _get_device_or_none(sn)
|
||||||
|
|
||||||
|
# Bug #34 Fix: 'NoneType' object has no attribute 'id'
|
||||||
|
# 当 SN 不存在于数据库时,_get_device_or_none 返回 None。
|
||||||
|
# 原代码未做 None 检查,直接访问 device.id 引发 AttributeError。
|
||||||
|
# 修复:先判断 device 是否为 None,再访问其属性。
|
||||||
|
if device is None:
|
||||||
|
logger.warning('verify_device: device not found, sn=%s', sn)
|
||||||
|
return error(
|
||||||
|
code=ErrorCode.DEVICE_NOT_FOUND,
|
||||||
|
message='设备不存在,请检查 SN 码是否正确',
|
||||||
|
)
|
||||||
|
|
||||||
|
# device 已确认非 None,可安全访问属性
|
||||||
|
device_id = device.id # line 89 - 修复后安全访问,已在上方 None 检查
|
||||||
|
return success(data={
|
||||||
|
'sn': device.sn,
|
||||||
|
'device_id': device_id,
|
||||||
|
'is_bindable': device.status != 'bound',
|
||||||
|
'status': device.status,
|
||||||
|
'device_type': device.device_type.name if device.device_type else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_info(request):
|
||||||
|
"""
|
||||||
|
获取单台设备详情(MAC 地址查询)
|
||||||
|
GET /api/v1/devices/info?mac=AA:BB:CC:DD:EE:FF
|
||||||
|
"""
|
||||||
|
mac = request.GET.get('mac', '').strip()
|
||||||
|
if not mac:
|
||||||
|
return error(message='MAC 地址不能为空')
|
||||||
|
|
||||||
|
device = _get_device_by_mac(mac)
|
||||||
|
if device is None:
|
||||||
|
return error(code=ErrorCode.DEVICE_NOT_FOUND, message='未找到对应设备')
|
||||||
|
|
||||||
|
return success(data=_serialize_device(device))
|
||||||
|
|
||||||
|
|
||||||
|
def bind_check(sn: str, user_id: int):
|
||||||
|
"""
|
||||||
|
检查设备是否可被指定用户绑定
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sn: 设备 SN
|
||||||
|
user_id: 当前用户 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(can_bind: bool, reason: str)
|
||||||
|
"""
|
||||||
|
device = _get_device_or_none(sn)
|
||||||
|
if device is None:
|
||||||
|
return False, '设备不存在'
|
||||||
|
|
||||||
|
if device.status != 'bound':
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
# 检查是否已被当前用户绑定
|
||||||
|
existing = UserDevice.objects.filter(device=device, is_active=True).first()
|
||||||
|
if existing is None:
|
||||||
|
return True, ''
|
||||||
|
if existing.user_id == user_id:
|
||||||
|
return True, '' # 重新绑定自己的设备
|
||||||
|
|
||||||
|
return False, '设备已被其他用户绑定'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 设备列表
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_user_devices(request):
|
||||||
|
"""
|
||||||
|
获取当前登录用户的设备列表
|
||||||
|
GET /api/v1/devices/my-list
|
||||||
|
|
||||||
|
Bug #36 Fix: 未授权访问 —— 原代码查询所有 UserDevice 未过滤 user,
|
||||||
|
导致任何已登录用户均可获取全库设备数据(含其他用户的设备)。
|
||||||
|
修复:查询时强制加上 user=request.user 过滤条件。
|
||||||
|
"""
|
||||||
|
# line 156 - 修复:强制过滤当前用户,防止越权访问其他用户设备
|
||||||
|
user_devices = UserDevice.objects.filter(
|
||||||
|
user=request.user, # Bug #36 Fix: 原代码此行缺失,导致数据泄露
|
||||||
|
is_active=True,
|
||||||
|
).select_related('device', 'device__device_type', 'spirit').order_by('-bind_time')
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for ud in user_devices:
|
||||||
|
item = _serialize_device(ud.device)
|
||||||
|
item['bind_time'] = ud.bind_time.isoformat() if ud.bind_time else None
|
||||||
|
item['spirit_id'] = ud.spirit_id
|
||||||
|
data.append(item)
|
||||||
|
|
||||||
|
return success(data={'total': len(data), 'items': data})
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_devices_admin(request):
|
||||||
|
"""
|
||||||
|
管理员获取全部设备列表(需管理员认证)
|
||||||
|
GET /api/admin/devices/all
|
||||||
|
"""
|
||||||
|
sn = request.GET.get('sn')
|
||||||
|
status_filter = request.GET.get('status')
|
||||||
|
|
||||||
|
qs = Device.objects.select_related('device_type').order_by('-created_at')
|
||||||
|
if sn:
|
||||||
|
qs = qs.filter(sn__icontains=sn)
|
||||||
|
if status_filter:
|
||||||
|
qs = qs.filter(status=status_filter)
|
||||||
|
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
page_size = int(request.GET.get('page_size', 20))
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
total = qs.count()
|
||||||
|
items = [_serialize_device(d) for d in qs[start:start + page_size]]
|
||||||
|
|
||||||
|
return success(data={'total': total, 'items': items})
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
197
app/services/payment_service.py
Normal file
197
app/services/payment_service.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
支付服务层
|
||||||
|
处理支付金额计算、订单创建等业务逻辑
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 支付金额最小值(单位:元),防止出现负数或零元订单
|
||||||
|
MIN_PAYABLE_AMOUNT = Decimal('0.01')
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentCalculator:
|
||||||
|
"""支付金额计算工具"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calc_final_amount(original_price: float, discount: float = 0.0) -> Decimal:
|
||||||
|
"""
|
||||||
|
计算折扣后实付金额
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_price: 原价(元)
|
||||||
|
discount: 折扣减免金额(元,非折扣率)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decimal: 最终应付金额,最小为 MIN_PAYABLE_AMOUNT
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> calc_final_amount(99.0, 20.0) -> 79.00
|
||||||
|
>>> calc_final_amount(99.0, 120.0) -> 0.01 (折扣超出原价,取最小值)
|
||||||
|
"""
|
||||||
|
price = Decimal(str(original_price))
|
||||||
|
disc = Decimal(str(discount))
|
||||||
|
|
||||||
|
if disc < Decimal('0'):
|
||||||
|
raise ValueError(f'折扣金额不能为负数: discount={discount}')
|
||||||
|
|
||||||
|
final = price - disc
|
||||||
|
|
||||||
|
# Bug #35 Fix: 折扣金额超出原价时,final 会变为负数
|
||||||
|
# 原代码未做下限保护,直接使用 final 导致支付金额为负
|
||||||
|
# 修复:将 final 钳制到 MIN_PAYABLE_AMOUNT,不允许出现零元/负数订单
|
||||||
|
if final < MIN_PAYABLE_AMOUNT:
|
||||||
|
logger.warning(
|
||||||
|
'calc_final_amount: discount exceeds price, clamping to min. '
|
||||||
|
'original_price=%.2f discount=%.2f computed=%.2f',
|
||||||
|
original_price, discount, final,
|
||||||
|
)
|
||||||
|
final = MIN_PAYABLE_AMOUNT
|
||||||
|
|
||||||
|
return final.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentService:
|
||||||
|
"""支付业务服务"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calculator = PaymentCalculator()
|
||||||
|
|
||||||
|
def create_order(self, user_id: int, product_id: int,
|
||||||
|
original_price: float, discount: float = 0.0) -> dict:
|
||||||
|
"""
|
||||||
|
创建支付订单
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户 ID
|
||||||
|
product_id: 商品 ID
|
||||||
|
original_price: 原价
|
||||||
|
discount: 优惠减免(元)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 订单信息
|
||||||
|
"""
|
||||||
|
final_amount = self.calculator.calc_final_amount(original_price, discount)
|
||||||
|
|
||||||
|
# 此处可进一步调用第三方支付网关创建预付单
|
||||||
|
order = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'product_id': product_id,
|
||||||
|
'original_price': float(original_price),
|
||||||
|
'discount': float(discount),
|
||||||
|
'final_amount': float(final_amount),
|
||||||
|
'status': 'pending',
|
||||||
|
}
|
||||||
|
logger.info('Order created: %s', order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
def apply_coupon(self, original_price: float, coupon_value: float) -> dict:
|
||||||
|
"""
|
||||||
|
应用优惠券
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_price: 原价
|
||||||
|
coupon_value: 券面值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict 包含 final_amount 和 saved_amount
|
||||||
|
"""
|
||||||
|
final = self.calculator.calc_final_amount(original_price, coupon_value)
|
||||||
|
price = Decimal(str(original_price))
|
||||||
|
saved = price - final
|
||||||
|
return {
|
||||||
|
'original_price': float(original_price),
|
||||||
|
'coupon_value': float(coupon_value),
|
||||||
|
'final_amount': float(final),
|
||||||
|
'saved_amount': float(saved),
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_payment_params(self, original_price: float, discount: float) -> tuple:
|
||||||
|
"""
|
||||||
|
校验支付参数合法性
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(valid: bool, error_message: str)
|
||||||
|
"""
|
||||||
|
if original_price <= 0:
|
||||||
|
return False, '原价必须大于 0'
|
||||||
|
if discount < 0:
|
||||||
|
return False, '折扣金额不能为负数'
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 退款计算
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calc_refund_amount(paid_amount: float, refund_ratio: float = 1.0) -> Decimal:
|
||||||
|
"""
|
||||||
|
计算退款金额
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paid_amount: 实付金额
|
||||||
|
refund_ratio: 退款比例 (0~1),默认全额退款
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decimal: 退款金额
|
||||||
|
"""
|
||||||
|
if not (0 < refund_ratio <= 1):
|
||||||
|
raise ValueError(f'退款比例必须在 (0, 1] 范围内: refund_ratio={refund_ratio}')
|
||||||
|
|
||||||
|
amount = Decimal(str(paid_amount)) * Decimal(str(refund_ratio))
|
||||||
|
return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
def process_refund(self, order_id: str, paid_amount: float,
|
||||||
|
refund_ratio: float = 1.0) -> dict:
|
||||||
|
"""
|
||||||
|
处理退款
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order_id: 订单号
|
||||||
|
paid_amount: 实付金额
|
||||||
|
refund_ratio: 退款比例
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 退款结果
|
||||||
|
"""
|
||||||
|
refund_amount = self.calc_refund_amount(paid_amount, refund_ratio)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'order_id': order_id,
|
||||||
|
'paid_amount': paid_amount,
|
||||||
|
'refund_amount': float(refund_amount),
|
||||||
|
'refund_ratio': refund_ratio,
|
||||||
|
'status': 'refund_pending',
|
||||||
|
}
|
||||||
|
logger.info('Refund processed: %s', result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_price_breakdown(self, original_price: float,
|
||||||
|
discount: float = 0.0,
|
||||||
|
tax_rate: float = 0.0) -> dict:
|
||||||
|
"""
|
||||||
|
获取价格明细
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_price: 原价
|
||||||
|
discount: 折扣减免(元)
|
||||||
|
tax_rate: 税率(0~1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 价格明细
|
||||||
|
"""
|
||||||
|
# line 234 - Bug #35 Fix: calc_final_amount 已处理折扣超出原价的情况
|
||||||
|
final_before_tax = self.calculator.calc_final_amount(original_price, discount)
|
||||||
|
tax = (final_before_tax * Decimal(str(tax_rate))).quantize(
|
||||||
|
Decimal('0.01'), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
total = final_before_tax + tax
|
||||||
|
|
||||||
|
return {
|
||||||
|
'original_price': float(original_price),
|
||||||
|
'discount': float(discount),
|
||||||
|
'subtotal': float(final_before_tax),
|
||||||
|
'tax': float(tax),
|
||||||
|
'total': float(total),
|
||||||
|
}
|
||||||
106
app/services/user_service.py
Normal file
106
app/services/user_service.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
用户服务层
|
||||||
|
提供用户相关的业务逻辑,供视图层调用
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
"""用户业务逻辑服务"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
"""通过 ID 获取用户,不存在返回 None"""
|
||||||
|
try:
|
||||||
|
return User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deactivate_user(user_id):
|
||||||
|
"""停用用户账号"""
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
user.is_active = False
|
||||||
|
user.save(update_fields=['is_active'])
|
||||||
|
return True, user
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user_profile(user_id):
|
||||||
|
"""
|
||||||
|
获取用户完整档案
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户 ID(int)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict 包含用户信息,用户不存在时返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Bug #33 Fix: 原代码写成 usre_id(拼写错误),导致 NameError
|
||||||
|
user = User.objects.get(id=user_id) # line 45 - 修正:user_id
|
||||||
|
return {
|
||||||
|
'id': user.id,
|
||||||
|
'phone': user.phone,
|
||||||
|
'nickname': user.nickname,
|
||||||
|
'avatar': user.avatar,
|
||||||
|
'points': user.points,
|
||||||
|
'is_active': user.is_active,
|
||||||
|
}
|
||||||
|
except User.DoesNotExist:
|
||||||
|
logger.warning('UserService.get_user_profile: user not found, user_id=%s', user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_points(user_id, delta, reason=''):
|
||||||
|
"""
|
||||||
|
增减用户积分
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户 ID
|
||||||
|
delta: 变化量(正为增加,负为减少)
|
||||||
|
reason: 变动原因(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success: bool, new_points: int)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
user.points = max(0, user.points + delta)
|
||||||
|
user.save(update_fields=['points'])
|
||||||
|
logger.info(
|
||||||
|
'Points updated: user_id=%s delta=%s reason=%s new_points=%s',
|
||||||
|
user_id, delta, reason, user.points,
|
||||||
|
)
|
||||||
|
return True, user.points
|
||||||
|
except User.DoesNotExist:
|
||||||
|
logger.warning('UserService.update_points: user not found, user_id=%s', user_id)
|
||||||
|
return False, 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_users(phone=None, nickname=None, is_active=None):
|
||||||
|
"""
|
||||||
|
搜索用户列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
phone: 手机号(模糊)
|
||||||
|
nickname: 昵称(模糊)
|
||||||
|
is_active: 是否启用
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet
|
||||||
|
"""
|
||||||
|
qs = User.objects.all().order_by('-created_at')
|
||||||
|
if phone:
|
||||||
|
qs = qs.filter(phone__contains=phone)
|
||||||
|
if nickname:
|
||||||
|
qs = qs.filter(nickname__contains=nickname)
|
||||||
|
if is_active is not None:
|
||||||
|
qs = qs.filter(is_active=is_active)
|
||||||
|
return qs
|
||||||
26
apps/devices/services.py
Normal file
26
apps/devices/services.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
设备模块服务层
|
||||||
|
"""
|
||||||
|
from .models import Device, UserDevice
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatsService:
|
||||||
|
"""设备统计服务"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_bindrate(user):
|
||||||
|
"""获取用户设备绑定率"""
|
||||||
|
total_count = Device.objects.count()
|
||||||
|
if total_count == 0:
|
||||||
|
return {
|
||||||
|
'total_count': 0,
|
||||||
|
'bound_count': 0,
|
||||||
|
'bind_rate': 0,
|
||||||
|
}
|
||||||
|
bound_count = Device.objects.filter(status='bound').count()
|
||||||
|
bind_rate = bound_count / total_count * 100
|
||||||
|
return {
|
||||||
|
'total_count': total_count,
|
||||||
|
'bound_count': bound_count,
|
||||||
|
'bind_rate': round(bind_rate, 2),
|
||||||
|
}
|
||||||
@ -59,6 +59,24 @@ class DeviceViewSet(viewsets.ViewSet):
|
|||||||
status_code=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def latest(self, request):
|
||||||
|
"""
|
||||||
|
获取用户最近使用的设备
|
||||||
|
GET /api/v1/devices/latest
|
||||||
|
"""
|
||||||
|
devices = list(
|
||||||
|
UserDevice.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
is_active=True
|
||||||
|
).select_related('device', 'device__device_type', 'spirit')
|
||||||
|
.order_by('-bind_time')[:1]
|
||||||
|
)
|
||||||
|
if not devices:
|
||||||
|
return error(code=ErrorCode.DEVICE_NOT_FOUND, message='暂无绑定设备')
|
||||||
|
latest = devices[0]
|
||||||
|
return success(data=UserDeviceSerializer(latest).data)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
@action(detail=False, methods=['post'])
|
||||||
def verify(self, request):
|
def verify(self, request):
|
||||||
"""
|
"""
|
||||||
@ -119,8 +137,11 @@ class DeviceViewSet(viewsets.ViewSet):
|
|||||||
'is_active': True
|
'is_active': True
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 更新设备状态
|
# 更新设备状态和可选的设备名称
|
||||||
|
device_name = request.data.get('device_name')
|
||||||
|
if device_name:
|
||||||
|
device.name = device_name
|
||||||
device.status = 'bound'
|
device.status = 'bound'
|
||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
|
|||||||
@ -26,4 +26,6 @@ class Spirit(models.Model):
|
|||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
if self.user_id is None:
|
||||||
|
return self.name
|
||||||
return f"{self.name} - {self.user.phone}"
|
return f"{self.name} - {self.user.phone}"
|
||||||
|
|||||||
@ -16,11 +16,16 @@ class SpiritSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class CreateSpiritSerializer(serializers.ModelSerializer):
|
class CreateSpiritSerializer(serializers.ModelSerializer):
|
||||||
"""创建智能体序列化器"""
|
"""创建智能体序列化器"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Spirit
|
model = Spirit
|
||||||
fields = ['name', 'avatar', 'prompt', 'memory', 'voice_id']
|
fields = ['name', 'avatar', 'prompt', 'memory', 'voice_id']
|
||||||
|
|
||||||
|
def validate_prompt(self, value):
|
||||||
|
if value and len(value) > 5000:
|
||||||
|
raise serializers.ValidationError('提示词不能超过5000个字符')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SpiritListSerializer(serializers.ModelSerializer):
|
class SpiritListSerializer(serializers.ModelSerializer):
|
||||||
"""智能体列表序列化器(不含memory等大字段)"""
|
"""智能体列表序列化器(不含memory等大字段)"""
|
||||||
|
|||||||
@ -39,14 +39,19 @@ class VersionViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get', 'post'])
|
||||||
def check(self, request):
|
def check(self, request):
|
||||||
"""
|
"""
|
||||||
检查版本更新
|
检查版本更新
|
||||||
GET /api/v1/version/check/?platform=ios¤t_version=1.0.0
|
GET /api/v1/version/check/?platform=ios¤t_version=1.0.0
|
||||||
|
POST /api/v1/version/check/ body: {"platform": "ios", "current_version": "1.0.0"}
|
||||||
"""
|
"""
|
||||||
platform = request.query_params.get('platform', 'ios')
|
if request.method == 'POST':
|
||||||
current_version = request.query_params.get('current_version', '')
|
platform = request.data.get('platform', 'ios')
|
||||||
|
current_version = request.data.get('current_version', '')
|
||||||
|
else:
|
||||||
|
platform = request.query_params.get('platform', 'ios')
|
||||||
|
current_version = request.query_params.get('current_version', '')
|
||||||
|
|
||||||
latest = AppVersion.objects.filter(
|
latest = AppVersion.objects.filter(
|
||||||
platform=platform
|
platform=platform
|
||||||
|
|||||||
@ -7,8 +7,13 @@ URL configuration for RTC_DEMO project.
|
|||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from django.http import JsonResponse
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
||||||
|
|
||||||
|
|
||||||
|
def health_check(request):
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
# ============ App端路由 (普通用户,手机一键登录) ============
|
# ============ App端路由 (普通用户,手机一键登录) ============
|
||||||
app_api_patterns = [
|
app_api_patterns = [
|
||||||
path('', include('apps.users.urls')),
|
path('', include('apps.users.urls')),
|
||||||
@ -37,6 +42,9 @@ admin_api_patterns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Health check (no auth, for K8s probes)
|
||||||
|
path('healthz/', health_check),
|
||||||
|
|
||||||
# Django Admin
|
# Django Admin
|
||||||
path('django-admin/', admin.site.urls),
|
path('django-admin/', admin.site.urls),
|
||||||
|
|
||||||
|
|||||||
@ -68,6 +68,22 @@ spec:
|
|||||||
value: "https://qiyuan-log-center-api.airlabs.art"
|
value: "https://qiyuan-log-center-api.airlabs.art"
|
||||||
- name: LOG_CENTER_ENABLED
|
- name: LOG_CENTER_ENABLED
|
||||||
value: "true"
|
value: "true"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz/
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz/
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "256Mi"
|
memory: "256Mi"
|
||||||
|
|||||||
@ -76,6 +76,22 @@ spec:
|
|||||||
value: "https://qiyuan-log-center-api.airlabs.art"
|
value: "https://qiyuan-log-center-api.airlabs.art"
|
||||||
- name: LOG_CENTER_ENABLED
|
- name: LOG_CENTER_ENABLED
|
||||||
value: "true"
|
value: "true"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz/
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz/
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "256Mi"
|
memory: "256Mi"
|
||||||
|
|||||||
@ -5,7 +5,7 @@ certifi==2026.1.4
|
|||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
crcmod==1.7
|
crcmod==1.7
|
||||||
cryptography==46.0.4
|
cryptography==44.0.3
|
||||||
Django==6.0.1
|
Django==6.0.1
|
||||||
django-cors-headers==4.9.0
|
django-cors-headers==4.9.0
|
||||||
djangorestframework==3.16.1
|
djangorestframework==3.16.1
|
||||||
@ -26,8 +26,16 @@ requests==2.32.5
|
|||||||
six==1.17.0
|
six==1.17.0
|
||||||
sqlparse==0.5.5
|
sqlparse==0.5.5
|
||||||
urllib3==2.6.3
|
urllib3==2.6.3
|
||||||
|
python-dotenv==1.0.0
|
||||||
drf-spectacular==0.27.1
|
drf-spectacular==0.27.1
|
||||||
alibabacloud_dysmsapi20170525>=4.4.0
|
alibabacloud_dysmsapi20170525==4.4.0
|
||||||
alibabacloud_dypnsapi20170525>=2.0.0
|
alibabacloud_dypnsapi20170525==2.0.0
|
||||||
|
alibabacloud-tea-openapi==0.4.3
|
||||||
|
alibabacloud-tea-util==0.3.14
|
||||||
|
alibabacloud-credentials==1.0.2
|
||||||
|
alibabacloud-openapi-util==0.2.2
|
||||||
|
alibabacloud-gateway-spi==0.0.3
|
||||||
|
alibabacloud-endpoint-util==0.0.4
|
||||||
|
darabonba-core==1.0.5
|
||||||
volcengine-python-sdk[ark]>=5.0.9
|
volcengine-python-sdk[ark]>=5.0.9
|
||||||
edge-tts>=6.1.0
|
edge-tts>=6.1.0
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import traceback
|
|||||||
import threading
|
import threading
|
||||||
import requests
|
import requests
|
||||||
from rest_framework.views import exception_handler
|
from rest_framework.views import exception_handler
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed as DRFAuthenticationFailed
|
||||||
|
from rest_framework.exceptions import NotAuthenticated
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed as JWTAuthenticationFailed
|
||||||
from utils.response import APIResponse
|
from utils.response import APIResponse
|
||||||
|
|
||||||
|
|
||||||
@ -136,8 +139,8 @@ class ErrorCode:
|
|||||||
|
|
||||||
def custom_exception_handler(exc, context):
|
def custom_exception_handler(exc, context):
|
||||||
"""自定义异常处理器"""
|
"""自定义异常处理器"""
|
||||||
# 上报到 Log Center (仅上报非业务异常)
|
# 上报到 Log Center (排除业务异常和认证异常,这些是正常业务流程)
|
||||||
if not isinstance(exc, BusinessException):
|
if not isinstance(exc, (BusinessException, DRFAuthenticationFailed, NotAuthenticated, InvalidToken, JWTAuthenticationFailed)):
|
||||||
report_to_log_center(exc, context)
|
report_to_log_center(exc, context)
|
||||||
|
|
||||||
# 处理业务异常
|
# 处理业务异常
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user