"""小程序系统配置 & 页面资源 — 后台管理接口(客服终端 + 管理员)""" import logging from django.db import transaction from rest_framework import permissions from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from houtai.utils import verify_kefu_permission from peizhi.models import XcxSysPeizhi, XcxPageZiyuan from utils.oss_utils import upload_to_oss, validate_image from utils.page_ziyuan_service import DEFAULT_ZIYUAN, build_page_assets_for_api, clear_page_ziyuan_cache, get_ziyuan_miaoshu from utils.xcx_sys_config import clear_xcx_sys_peizhi_cache, wx_cfg from yonghu.models import AdminProfile logger = logging.getLogger(__name__) # 与弹窗公告等小程序配置页共用权限码 REQUIRED_PERMISSION = '8080a' def _frontend_account(request): raw = ( request.data.get('username') or request.data.get('phone') or request.data.get('zhanghao') or '' ) return str(raw).strip() def _authorize_miniapp_config(request): """ 客服终端:verify_kefu_permission + 8080a 管理员账号:user_type=admin 且 phone 匹配(兼容旧后台) 失败一律 HTTP 200 + code,避免前端 401 误踢登录 """ account = _frontend_account(request) if not account: return False, Response({'code': 1, 'msg': '账号不能为空', 'data': None}) user = request.user if user.user_type == 'admin' and user.phone == account: try: user.admin_profile return True, None except AdminProfile.DoesNotExist: pass kefu, perms_or_resp = verify_kefu_permission(request, account) if kefu is None: if isinstance(perms_or_resp, Response): body = perms_or_resp.data if hasattr(perms_or_resp, 'data') else {} code = body.get('code', 403) msg = body.get('msg', '身份验证失败') return False, Response({'code': code, 'msg': msg, 'data': None}) return False, Response({'code': 403, 'msg': '身份验证失败', 'data': None}) if REQUIRED_PERMISSION not in perms_or_resp: return False, Response({ 'code': 403, 'msg': '您没有权限访问小程序配置(需权限 8080a)', 'data': None, }) return True, None def _mask_secret(val): if not val: return '' if len(val) <= 8: return '****' return val[:4] + '****' + val[-4:] class XcxSysPeizhiQueryView(APIView): """POST /peizhi/xcxsyshq — 获取小程序系统配置""" permission_classes = [permissions.IsAuthenticated] parser_classes = [JSONParser] def post(self, request): ok, err = _authorize_miniapp_config(request) if not ok: return err row = XcxSysPeizhi.objects.first() data = { 'weixin_appid': row.weixin_appid if row else wx_cfg.WEIXIN_APPID, 'weixin_secret': _mask_secret(row.weixin_secret if row else wx_cfg.WEIXIN_SECRET), 'weixin_official_appid': row.weixin_official_appid if row else wx_cfg.WEIXIN_OFFICIAL_APPID, 'weixin_official_secret': _mask_secret( row.weixin_official_secret if row else wx_cfg.WEIXIN_OFFICIAL_SECRET ), 'weixin_official_token': row.weixin_official_token if row else wx_cfg.WEIXIN_OFFICIAL_TOKEN, 'weixin_official_encoding_aes_key': _mask_secret( row.weixin_official_encoding_aes_key if row else wx_cfg.WEIXIN_OFFICIAL_ENCODING_AES_KEY ), 'weixin_template_id': row.weixin_template_id if row else wx_cfg.WEIXIN_TEMPLATE_ID, 'weixin_template_max_per_minute': ( row.weixin_template_max_per_minute if row else wx_cfg.WEIXIN_TEMPLATE_MAX_PER_MINUTE ), 'weixin_template_batch_size': ( row.weixin_template_batch_size if row else wx_cfg.WEIXIN_TEMPLATE_BATCH_SIZE ), 'weixin_broadcast_enabled': ( row.weixin_broadcast_enabled if row else wx_cfg.WEIXIN_BROADCAST_ENABLED ), 'weixin_broadcast_workers': row.weixin_broadcast_workers if row else wx_cfg.WEIXIN_BROADCAST_WORKERS, 'weixin_mchid': row.weixin_mchid if row else wx_cfg.WEIXIN_MCHID, 'weixin_shanghu_miyao': _mask_secret(row.weixin_shanghu_miyao if row else wx_cfg.WEIXIN_SHANGHUMIYAO), 'weixin_notify_url': row.weixin_notify_url if row else wx_cfg.WEIXIN_NOTIFY_URL, 'weixin_cert_path': row.weixin_cert_path if row else wx_cfg.WEIXIN_CERT_PATH, 'weixin_key_path': row.weixin_key_path if row else wx_cfg.WEIXIN_KEY_PATH, 'notify_base_url': row.notify_base_url if row else wx_cfg.NOTIFY_BASE_URL, 'shangpin_shenhe_mode': row.shangpin_shenhe_mode if row else wx_cfg.SHANGPIN_SHENHE_MODE, 'shangjia_paifa_jiage_min': str( row.shangjia_paifa_jiage_min if row else wx_cfg.SHANGJIA_PAIFA_JIAGE_MIN ), 'shangjia_paifa_jiage_max': str( row.shangjia_paifa_jiage_max if row else wx_cfg.SHANGJIA_PAIFA_JIAGE_MAX ), 'from_database': bool(row), } return Response({'code': 0, 'msg': 'success', 'data': data}) class XcxSysPeizhiUpdateView(APIView): """POST /peizhi/xcxsysgx — 更新小程序系统配置""" permission_classes = [permissions.IsAuthenticated] parser_classes = [JSONParser] UPDATABLE = frozenset({ 'weixin_appid', 'weixin_secret', 'weixin_official_appid', 'weixin_official_secret', 'weixin_official_token', 'weixin_official_encoding_aes_key', 'weixin_template_id', 'weixin_template_max_per_minute', 'weixin_template_batch_size', 'weixin_broadcast_enabled', 'weixin_broadcast_workers', 'weixin_mchid', 'weixin_shanghu_miyao', 'weixin_notify_url', 'weixin_cert_path', 'weixin_key_path', 'notify_base_url', 'shangpin_shenhe_mode', 'shangjia_paifa_jiage_min', 'shangjia_paifa_jiage_max', }) def post(self, request): ok, err = _authorize_miniapp_config(request) if not ok: return err payload = request.data.get('config') or request.data with transaction.atomic(): row, _ = XcxSysPeizhi.objects.get_or_create(id=1) for key in self.UPDATABLE: if key in payload and payload[key] is not None: val = payload[key] if key.endswith('_secret') or key.endswith('_miyao') or key.endswith('_aes_key'): if val == '' or '****' in str(val): continue if key == 'weixin_broadcast_enabled': row.weixin_broadcast_enabled = bool(val) elif key in ('weixin_template_max_per_minute', 'weixin_template_batch_size', 'weixin_broadcast_workers'): row.__setattr__(key, int(val)) elif key == 'shangpin_shenhe_mode': mode = int(val) if mode not in (1, 2): continue row.shangpin_shenhe_mode = mode elif key in ('shangjia_paifa_jiage_min', 'shangjia_paifa_jiage_max'): from decimal import Decimal, InvalidOperation try: amount = Decimal(str(val)) except (InvalidOperation, ValueError, TypeError): continue if amount <= 0: continue row.__setattr__(key, amount) else: row.__setattr__(key, str(val).strip()) row.save() clear_xcx_sys_peizhi_cache() row = XcxSysPeizhi.objects.first() if row and row.shangjia_paifa_jiage_min > row.shangjia_paifa_jiage_max: row.shangjia_paifa_jiage_min, row.shangjia_paifa_jiage_max = ( row.shangjia_paifa_jiage_max, row.shangjia_paifa_jiage_min, ) row.save(update_fields=['shangjia_paifa_jiage_min', 'shangjia_paifa_jiage_max']) clear_xcx_sys_peizhi_cache() return Response({'code': 0, 'msg': '配置已更新', 'data': None}) class XcxPageZiyuanQueryView(APIView): """POST /peizhi/xcxyzycx — 查询页面资源列表""" permission_classes = [permissions.IsAuthenticated] parser_classes = [JSONParser] def post(self, request): ok, err = _authorize_miniapp_config(request) if not ok: return err zu = (request.data.get('ziyuan_zu') or '').strip() merged = build_page_assets_for_api() if zu: groups = {zu: merged.get(zu, {})} else: groups = merged items = [] for gzu, keys in groups.items(): for key, path in keys.items(): items.append({ 'ziyuan_zu': gzu, 'ziyuan_key': key, 'ziyuan_path': path, 'miaoshu': get_ziyuan_miaoshu(gzu, key), }) return Response({'code': 0, 'msg': 'success', 'data': {'list': items, 'groups': groups}}) class XcxPageZiyuanUpdateView(APIView): """POST /peizhi/xcxyzgx — 更新页面资源路径""" permission_classes = [permissions.IsAuthenticated] parser_classes = [JSONParser] def post(self, request): ok, err = _authorize_miniapp_config(request) if not ok: return err items = request.data.get('items') or [] if not isinstance(items, list) or not items: return Response({'code': 1, 'msg': 'items 不能为空', 'data': None}) with transaction.atomic(): for item in items: zu = (item.get('ziyuan_zu') or '').strip() key = (item.get('ziyuan_key') or '').strip() path = (item.get('ziyuan_path') or '').strip().lstrip('/') if not zu or not key or not path: continue XcxPageZiyuan.objects.update_or_create( ziyuan_zu=zu, ziyuan_key=key, defaults={'ziyuan_path': path, 'miaoshu': item.get('miaoshu') or ''}, ) clear_page_ziyuan_cache() return Response({'code': 0, 'msg': '资源已更新', 'data': None}) class XcxPageZiyuanUploadView(APIView): """POST /peizhi/xcxyzsc — 上传页面资源图片""" permission_classes = [permissions.IsAuthenticated] parser_classes = (MultiPartParser, FormParser) def post(self, request): ok, err = _authorize_miniapp_config(request) if not ok: return err zu = (request.data.get('ziyuan_zu') or '').strip() key = (request.data.get('ziyuan_key') or '').strip() image_file = request.FILES.get('image') if not zu or not key: return Response({'code': 1, 'msg': 'ziyuan_zu/ziyuan_key 必填', 'data': None}) if not image_file: return Response({'code': 2, 'msg': '请选择图片', 'data': None}) is_valid, error_msg = validate_image(image_file) if not is_valid: return Response({'code': 3, 'msg': error_msg, 'data': None}) default_key = f'{zu}.{key}' default_path = DEFAULT_ZIYUAN.get(default_key, f'beijing/{zu}/{key}.png') oss_path = default_path image_file.seek(0) full_url = upload_to_oss(image_file, oss_path) if not full_url: return Response({'code': 4, 'msg': '上传失败', 'data': None}) with transaction.atomic(): XcxPageZiyuan.objects.update_or_create( ziyuan_zu=zu, ziyuan_key=key, defaults={'ziyuan_path': oss_path}, ) clear_page_ziyuan_cache() return Response({'code': 0, 'msg': '上传成功', 'data': {'ziyuan_path': oss_path}})