P1 性能优化(避免循环内 N+1 / N 次 DB 查询): - shop_order_views._batch_images: 移除 ThreadPoolExecutor 掩盖的 N 次查询,改单次批量 + Python 侧分组 - product_query.DashouHuiyuanList: 5N 查询 -> 3 次批量预取 + 本地闭包判断 - roles.GetRolePermissionView / 用户列表: 循环内 RolePermission/Permission/UserRole/Role -> 批量 __in 预取 - guanli / zuzhang 杜次分红 4.2/4.3: 循环内 update_or_create + 单条 delete -> bulk_create + bulk_update + 批量 delete - admin_config QQ 群配置: 循环内 exists/first/save/create -> 批量预取 + bulk_create + bulk_update - jituan 服务层多处合并统计查询、批量预取 map - rank 多处循环 N+1 改批量预取 - backend 多处循环内 count/create 改批量 - config/orders/merchant_ops 多处循环 N+1 改批量预取 其他改动: - users/views/kefu.py 拆分为 kefu_base/kefu_dashou/kefu_orders/kefu_punishment/kefu_withdraw 5 个文件 - 删除遗留脚本 check_prod_uid.py / create_rbac_tables.sql
467 lines
16 KiB
Python
467 lines
16 KiB
Python
"""打手抢单前考试:按俱乐部独立题库与配置。"""
|
||
import logging
|
||
import os
|
||
import random
|
||
import uuid
|
||
|
||
from django.utils import timezone
|
||
|
||
from jituan.services.admin_context import build_admin_club_context
|
||
|
||
from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_ALL
|
||
from jituan.models import (
|
||
Club,
|
||
ClubDashouExamConfig,
|
||
ClubDashouExamPass,
|
||
ClubDashouExamQuestion,
|
||
ClubDashouExamQuestionImage,
|
||
)
|
||
from jituan.services.club_context import (
|
||
club_id_for_write,
|
||
resolve_club_id_from_request,
|
||
resolve_club_scope,
|
||
resolve_effective_club_id,
|
||
)
|
||
from products.models import Huiyuangoumai
|
||
from utils.oss_utils import delete_from_oss, upload_to_oss, validate_image
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SCOPE_MSG = '请切换到具体小程序/俱乐部后再操作'
|
||
EXAM_MANAGE_PERMS = frozenset({'dsks666', '8080a'})
|
||
OPTION_FIELDS = [f'option_{i}' for i in range(1, 7)]
|
||
CORRECT_FIELDS = [f'correct_{i}' for i in range(1, 7)]
|
||
|
||
|
||
def has_exam_manage_perm(permissions):
|
||
return any(code in permissions for code in EXAM_MANAGE_PERMS)
|
||
|
||
|
||
def resolve_exam_target_club_id(request, user, data=None):
|
||
"""
|
||
后台考试管理目标俱乐部。
|
||
优先 request 中的 target_club_id,须在用户可见俱乐部列表内。
|
||
集团身份可在页面内切换俱乐部,不依赖顶栏视图。
|
||
"""
|
||
payload = data if data is not None else (getattr(request, 'data', None) or {})
|
||
target = (payload.get('target_club_id') or payload.get('club_id') or '').strip()
|
||
ctx = build_admin_club_context(user)
|
||
visible_ids = {c['club_id'] for c in ctx.get('clubs', [])}
|
||
|
||
if target:
|
||
if target not in visible_ids:
|
||
return None, '无权管理该俱乐部的考试'
|
||
return target, None
|
||
|
||
if ctx.get('scope') == DATA_SCOPE_ALL:
|
||
default = (ctx.get('club_id') or CLUB_ID_DEFAULT).strip()
|
||
if default in visible_ids:
|
||
return default, None
|
||
if visible_ids:
|
||
return sorted(visible_ids)[0], None
|
||
return None, '请选择俱乐部'
|
||
|
||
club_id = resolve_club_id_from_request(request)
|
||
if club_id not in visible_ids:
|
||
return None, '无权管理该俱乐部的考试'
|
||
return club_id, None
|
||
|
||
|
||
def exam_admin_clubs_payload(user):
|
||
ctx = build_admin_club_context(user)
|
||
return {
|
||
'clubs': ctx.get('clubs', []),
|
||
'can_switch_club': bool(ctx.get('can_switch_club')),
|
||
'is_group_admin': bool(ctx.get('is_group_admin')),
|
||
}
|
||
|
||
|
||
def _scope_blocked(request):
|
||
return resolve_club_scope(request) == DATA_SCOPE_ALL
|
||
|
||
|
||
def get_or_create_config(club_id):
|
||
row, _ = ClubDashouExamConfig.query.get_or_create(
|
||
club_id=club_id,
|
||
defaults={
|
||
'is_enabled': False,
|
||
'draw_count': 10,
|
||
'pass_count': 10,
|
||
'require_member': True,
|
||
},
|
||
)
|
||
return row
|
||
|
||
|
||
def config_to_dict(row):
|
||
return {
|
||
'club_id': row.club_id,
|
||
'is_enabled': bool(row.is_enabled),
|
||
'draw_count': int(row.draw_count or 10),
|
||
'pass_count': int(row.pass_count or 10),
|
||
'require_member': bool(row.require_member),
|
||
'max_wrong': max(0, int(row.draw_count or 0) - int(row.pass_count or 0)),
|
||
}
|
||
|
||
|
||
def user_has_active_member(yonghuid, club_id=None):
|
||
"""是否有任意有效会员(与 /yonghu/dashouxinxi 的 clumber 逻辑一致)。"""
|
||
for r in Huiyuangoumai.query.filter(yonghu_id=yonghuid):
|
||
if r.huiyuan_zhuangtai == 1 and not r.jiance_shifou_daoqi():
|
||
return True
|
||
return False
|
||
|
||
|
||
def user_exam_passed(club_id, yonghuid):
|
||
return ClubDashouExamPass.query.filter(club_id=club_id, yonghuid=yonghuid).exists()
|
||
|
||
|
||
def validate_question_payload(data, for_update=False):
|
||
stem = (data.get('stem') or '').strip()
|
||
if not stem:
|
||
return '题干不能为空'
|
||
qtype = int(data.get('question_type') or 1)
|
||
if qtype not in (1, 2):
|
||
return '题目类型无效'
|
||
options = []
|
||
correct_slots = []
|
||
for i in range(1, 7):
|
||
text = (data.get(f'option_{i}') or '').strip()
|
||
is_corr = bool(data.get(f'correct_{i}'))
|
||
if text:
|
||
options.append(i)
|
||
if is_corr:
|
||
correct_slots.append(i)
|
||
if len(options) < 2:
|
||
return '至少需要两个有效选项'
|
||
if not correct_slots:
|
||
return '至少标记一个正确答案'
|
||
if qtype == 1 and len(correct_slots) != 1:
|
||
return '单选题只能有一个正确答案'
|
||
return None
|
||
|
||
|
||
def question_to_admin_dict(q, images=None):
|
||
item = {
|
||
'id': q.id,
|
||
'stem': q.stem,
|
||
'question_type': q.question_type,
|
||
'explanation': q.explanation or '',
|
||
'is_enabled': bool(q.is_enabled),
|
||
'sort_order': q.sort_order,
|
||
'images': images or [],
|
||
}
|
||
for i in range(1, 7):
|
||
item[f'option_{i}'] = getattr(q, f'option_{i}', '') or ''
|
||
item[f'correct_{i}'] = bool(getattr(q, f'correct_{i}', False))
|
||
return item
|
||
|
||
|
||
def build_question_bundle_item(q, image_urls):
|
||
options = []
|
||
correct_slots = []
|
||
for i in range(1, 7):
|
||
text = (getattr(q, f'option_{i}', '') or '').strip()
|
||
if not text:
|
||
continue
|
||
options.append({'slot': i, 'text': text})
|
||
if getattr(q, f'correct_{i}', False):
|
||
correct_slots.append(i)
|
||
return {
|
||
'id': q.id,
|
||
'stem': q.stem,
|
||
'question_type': q.question_type,
|
||
'images': image_urls,
|
||
'options': options,
|
||
'correct_slots': correct_slots,
|
||
'explanation': q.explanation or '',
|
||
}
|
||
|
||
|
||
def get_exam_status(request, user):
|
||
club_id = resolve_effective_club_id(request, user)
|
||
cfg = get_or_create_config(club_id)
|
||
yonghuid = user.UserUID
|
||
has_member = user_has_active_member(yonghuid, club_id)
|
||
passed = user_exam_passed(club_id, yonghuid)
|
||
enabled = bool(cfg.is_enabled)
|
||
pool_count = ClubDashouExamQuestion.query.filter(club_id=club_id, is_enabled=True).count()
|
||
return {
|
||
'club_id': club_id,
|
||
'exam_enabled': enabled,
|
||
'exam_passed': passed,
|
||
'has_member': has_member,
|
||
'can_take_exam': has_member if cfg.require_member else True,
|
||
'exam_required': enabled and not passed,
|
||
'pool_count': pool_count,
|
||
'config': config_to_dict(cfg),
|
||
}
|
||
|
||
|
||
def build_exam_bundle(request, user):
|
||
club_id = resolve_effective_club_id(request, user)
|
||
cfg = get_or_create_config(club_id)
|
||
if not cfg.is_enabled:
|
||
return None, '本俱乐部未开启考试'
|
||
yonghuid = user.UserUID
|
||
has_member = user_has_active_member(yonghuid, club_id)
|
||
if cfg.require_member and not has_member:
|
||
return None, '须先开通会员才能参加考试'
|
||
|
||
qs = ClubDashouExamQuestion.query.filter(club_id=club_id, is_enabled=True)
|
||
all_ids = list(qs.values_list('id', flat=True))
|
||
draw = min(int(cfg.draw_count or 10), len(all_ids))
|
||
if draw < 1:
|
||
total = ClubDashouExamQuestion.query.filter(club_id=club_id).count()
|
||
if total > 0 and not all_ids:
|
||
return None, '题库有题目但未上架,请在后台打开题目的「上架」开关'
|
||
return None, '题库暂无已上架题目,请至少录入一道题并上架(每题至少两个选项且标记正确答案)'
|
||
|
||
selected_ids = random.sample(all_ids, draw)
|
||
# 批量预取题目和图片,避免循环内 N+1
|
||
_questions_map = {
|
||
q.id: q for q in ClubDashouExamQuestion.query.filter(id__in=selected_ids)
|
||
}
|
||
_imgs_map = {}
|
||
for img in ClubDashouExamQuestionImage.query.filter(
|
||
club_id=club_id, question_id__in=selected_ids,
|
||
).order_by('question_id', 'sort_order', 'id'):
|
||
_imgs_map.setdefault(img.question_id, []).append(img.image_url)
|
||
|
||
questions = []
|
||
for qid in selected_ids:
|
||
q = _questions_map.get(qid)
|
||
if q is None:
|
||
continue
|
||
imgs = _imgs_map.get(qid, [])
|
||
questions.append(build_question_bundle_item(q, imgs))
|
||
|
||
return {
|
||
'club_id': club_id,
|
||
'config': config_to_dict(cfg),
|
||
'has_member': has_member,
|
||
'already_passed': user_exam_passed(club_id, yonghuid),
|
||
'questions': questions,
|
||
}, None
|
||
|
||
|
||
def mark_exam_passed(request, user):
|
||
club_id = resolve_effective_club_id(request, user)
|
||
cfg = get_or_create_config(club_id)
|
||
if not cfg.is_enabled:
|
||
return None, '本俱乐部未开启考试'
|
||
yonghuid = user.UserUID
|
||
if cfg.require_member and not user_has_active_member(yonghuid, club_id):
|
||
return None, '须先开通会员'
|
||
ClubDashouExamPass.query.update_or_create(
|
||
club_id=club_id,
|
||
yonghuid=yonghuid,
|
||
defaults={'passed_at': timezone.now()},
|
||
)
|
||
return {'club_id': club_id, 'passed': True}, None
|
||
|
||
|
||
def build_group_exam_overview(request):
|
||
"""集团汇总视图:各俱乐部考试配置一览。"""
|
||
if not _scope_blocked(request):
|
||
return None, '请在集团汇总视图查看各俱乐部概览'
|
||
rows = []
|
||
for club in Club.query.filter(status=1).order_by('club_id'):
|
||
cid = club.club_id
|
||
cfg = get_or_create_config(cid)
|
||
pool = ClubDashouExamQuestion.query.filter(club_id=cid).count()
|
||
enabled_pool = ClubDashouExamQuestion.query.filter(club_id=cid, is_enabled=True).count()
|
||
passed_count = ClubDashouExamPass.query.filter(club_id=cid).count()
|
||
rows.append({
|
||
'club_id': cid,
|
||
'club_name': club.name or cid,
|
||
'is_enabled': bool(cfg.is_enabled),
|
||
'draw_count': int(cfg.draw_count or 10),
|
||
'pass_count': int(cfg.pass_count or 10),
|
||
'require_member': bool(cfg.require_member),
|
||
'pool_count': pool,
|
||
'enabled_pool_count': enabled_pool,
|
||
'passed_user_count': passed_count,
|
||
})
|
||
return {'list': rows}, None
|
||
|
||
|
||
def build_admin_config_payload(request, user, data=None):
|
||
clubs_info = exam_admin_clubs_payload(user)
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return {**clubs_info, 'scope_error': err}
|
||
cfg = get_or_create_config(club_id)
|
||
# 合并 2 次独立 count → 1 次 aggregate with 条件 Count
|
||
from django.db.models import Count, Q
|
||
_pool_agg = ClubDashouExamQuestion.query.filter(club_id=club_id).aggregate(
|
||
pool=Count('id'),
|
||
enabled_pool=Count('id', filter=Q(is_enabled=True)),
|
||
)
|
||
pool = _pool_agg['pool'] or 0
|
||
enabled_pool = _pool_agg['enabled_pool'] or 0
|
||
return {
|
||
**clubs_info,
|
||
'club_id': club_id,
|
||
'config': config_to_dict(cfg),
|
||
'pool_count': pool,
|
||
'enabled_pool_count': enabled_pool,
|
||
}
|
||
|
||
|
||
def save_admin_config(request, user, data):
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return None, err
|
||
draw = int(data.get('draw_count') or 10)
|
||
pass_cnt = int(data.get('pass_count') or draw)
|
||
if draw < 1:
|
||
return None, '抽题数量至少为1'
|
||
if pass_cnt < 1 or pass_cnt > draw:
|
||
return None, '及格题数须在 1 到抽题数量之间'
|
||
cfg = get_or_create_config(club_id)
|
||
cfg.is_enabled = bool(data.get('is_enabled'))
|
||
cfg.draw_count = draw
|
||
cfg.pass_count = pass_cnt
|
||
cfg.require_member = bool(data.get('require_member', True))
|
||
cfg.save(update_fields=['is_enabled', 'draw_count', 'pass_count', 'require_member', 'UpdateTime'])
|
||
return config_to_dict(cfg), None
|
||
|
||
|
||
def list_admin_questions(request, user, data=None):
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return {**exam_admin_clubs_payload(user), 'list': [], 'scope_error': err}
|
||
rows = ClubDashouExamQuestion.query.filter(club_id=club_id).order_by('-sort_order', 'id')
|
||
rows_list = list(rows)
|
||
_q_ids = [q.id for q in rows_list]
|
||
# 批量预取所有题目图片,避免循环内 N+1
|
||
_imgs_map = {}
|
||
for img in ClubDashouExamQuestionImage.query.filter(
|
||
club_id=club_id, question_id__in=_q_ids,
|
||
).order_by('question_id', 'sort_order', 'id'):
|
||
_imgs_map.setdefault(img.question_id, []).append(
|
||
{'id': img.id, 'image_url': img.image_url, 'sort_order': img.sort_order}
|
||
)
|
||
|
||
out = []
|
||
for q in rows_list:
|
||
imgs = _imgs_map.get(q.id, [])
|
||
out.append(question_to_admin_dict(q, imgs))
|
||
return {**exam_admin_clubs_payload(user), 'club_id': club_id, 'list': out}
|
||
|
||
|
||
def create_question(request, user, data):
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return None, err
|
||
err = validate_question_payload(data)
|
||
if err:
|
||
return None, err
|
||
q = ClubDashouExamQuestion.query.create(
|
||
club_id=club_id,
|
||
stem=data.get('stem', '').strip(),
|
||
question_type=int(data.get('question_type') or 1),
|
||
explanation=(data.get('explanation') or '').strip(),
|
||
is_enabled=bool(data.get('is_enabled', True)),
|
||
sort_order=int(data.get('sort_order') or 0),
|
||
**{f'option_{i}': (data.get(f'option_{i}') or '').strip() for i in range(1, 7)},
|
||
**{f'correct_{i}': bool(data.get(f'correct_{i}')) for i in range(1, 7)},
|
||
)
|
||
return {'id': q.id}, None
|
||
|
||
|
||
def update_question(request, user, question_id, data):
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return None, err
|
||
try:
|
||
q = ClubDashouExamQuestion.query.get(id=question_id, club_id=club_id)
|
||
except ClubDashouExamQuestion.DoesNotExist:
|
||
return None, '题目不存在'
|
||
err = validate_question_payload(data, for_update=True)
|
||
if err:
|
||
return None, err
|
||
q.stem = data.get('stem', q.stem).strip()
|
||
q.question_type = int(data.get('question_type') or q.question_type)
|
||
q.explanation = (data.get('explanation') or '').strip()
|
||
if 'is_enabled' in data:
|
||
q.is_enabled = bool(data.get('is_enabled'))
|
||
if 'sort_order' in data:
|
||
q.sort_order = int(data.get('sort_order') or 0)
|
||
for i in range(1, 7):
|
||
if f'option_{i}' in data:
|
||
q.__setattr__(f'option_{i}', (data.get(f'option_{i}') or '').strip())
|
||
if f'correct_{i}' in data:
|
||
q.__setattr__(f'correct_{i}', bool(data.get(f'correct_{i}')))
|
||
q.save()
|
||
return {'id': q.id}, None
|
||
|
||
|
||
def delete_question(request, user, question_id, data=None):
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return None, err
|
||
try:
|
||
q = ClubDashouExamQuestion.query.get(id=question_id, club_id=club_id)
|
||
except ClubDashouExamQuestion.DoesNotExist:
|
||
return None, '题目不存在'
|
||
for img in ClubDashouExamQuestionImage.query.filter(club_id=club_id, question_id=q.id):
|
||
if img.image_url:
|
||
delete_from_oss(img.image_url)
|
||
ClubDashouExamQuestionImage.query.filter(club_id=club_id, question_id=q.id).delete()
|
||
q.delete()
|
||
return {'id': question_id}, None
|
||
|
||
|
||
def upload_question_image(request, user, question_id, file_obj, data=None):
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return None, err
|
||
if not ClubDashouExamQuestion.query.filter(id=question_id, club_id=club_id).exists():
|
||
return None, '题目不存在'
|
||
ok, msg = validate_image(file_obj)
|
||
if not ok:
|
||
return None, msg
|
||
ext = os.path.splitext(file_obj.name)[1].lower() or '.png'
|
||
if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
|
||
ext = '.png'
|
||
oss_path = f'club/{club_id}/exam/{question_id}_{uuid.uuid4().hex}{ext}'
|
||
if not upload_to_oss(file_obj, oss_path):
|
||
return None, '图片上传失败'
|
||
row = ClubDashouExamQuestionImage.query.create(
|
||
club_id=club_id,
|
||
question_id=question_id,
|
||
image_url=oss_path,
|
||
sort_order=int(request.data.get('sort_order') or 0),
|
||
)
|
||
return {'id': row.id, 'image_url': oss_path}, None
|
||
|
||
|
||
def delete_question_image(request, user, image_id, data=None):
|
||
club_id, err = resolve_exam_target_club_id(request, user, data)
|
||
if err:
|
||
return None, err
|
||
try:
|
||
img = ClubDashouExamQuestionImage.query.get(id=image_id, club_id=club_id)
|
||
except ClubDashouExamQuestionImage.DoesNotExist:
|
||
return None, '图片不存在'
|
||
if img.image_url:
|
||
delete_from_oss(img.image_url)
|
||
img.delete()
|
||
return {'id': image_id}, None
|
||
|
||
|
||
def copy_exam_config(template_club_id, new_club_id):
|
||
tpl = ClubDashouExamConfig.query.filter(club_id=template_club_id).first()
|
||
if tpl:
|
||
ClubDashouExamConfig.query.update_or_create(
|
||
club_id=new_club_id,
|
||
defaults={
|
||
'is_enabled': tpl.is_enabled,
|
||
'draw_count': tpl.draw_count,
|
||
'pass_count': tpl.pass_count,
|
||
'require_member': tpl.require_member,
|
||
},
|
||
)
|