From 214b52baddde8874fc74c9b4edcef49f2e8e4fb8 Mon Sep 17 00:00:00 2001 From: XingQue Date: Thu, 9 Jul 2026 22:58:56 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=A4=8D=E5=88=B6=E5=BA=97=E9=93=BA=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持按用户ID复制商品类型、专区与商品,OSS图片独立拷贝,并提供覆盖/补充两种模式。 Co-authored-by: Cursor --- backend/services/__init__.py | 0 backend/services/shop_copy_service.py | 259 ++++++++++++++++++++++++++ backend/urls.py | 3 +- backend/view.py | 2 + backend/views/shops.py | 43 +++++ utils/oss_utils.py | 82 ++++++++ 6 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 backend/services/__init__.py create mode 100644 backend/services/shop_copy_service.py diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/shop_copy_service.py b/backend/services/shop_copy_service.py new file mode 100644 index 0000000..7be2085 --- /dev/null +++ b/backend/services/shop_copy_service.py @@ -0,0 +1,259 @@ +""" +店铺商品目录复制服务:复制店铺商品类型、专区、商品(含 OSS 图片独立拷贝)。 +""" +import logging +import time +import uuid + +from django.db import transaction + +from products.models import Shangpin, ShangpinZhuanqu +from shop.models import Dianpu, ShangpinLeixingDianpu +from utils.oss_utils import copy_in_oss, delete_from_oss, _url_to_oss_key + +logger = logging.getLogger('houtai') + +COPY_MODE_REPLACE = 'replace' +COPY_MODE_APPEND = 'append' + + +class ShopCopyError(Exception): + def __init__(self, message, code=400): + super().__init__(message) + self.message = message + self.code = code + + +def resolve_dianpu_by_user_uid(user_uid): + """通过小程序用户 ID(YonghuPingzheng.yonghu,全局唯一)定位店铺""" + uid = str(user_uid or '').strip() + if not uid: + raise ShopCopyError('用户ID不能为空') + try: + return Dianpu.objects.select_related('pingzheng').get(pingzheng__yonghu=uid) + except Dianpu.DoesNotExist: + raise ShopCopyError(f'未找到用户ID {uid} 对应的店铺', 404) + + +def _gen_dest_key(target_dianpu_id, category, src_url): + src_key = _url_to_oss_key(src_url) + ext = 'png' + if src_key and '.' in src_key: + ext = src_key.rsplit('.', 1)[-1].lower() or 'png' + ts = int(time.time() * 1000) + rand = uuid.uuid4().hex[:8] + return f"dianpu/shangpin/{category}/{target_dianpu_id}_{ts}_{rand}.{ext}" + + +def _copy_image_url(src_url, target_dianpu_id, category): + """复制 OSS 图片到目标店铺独立路径,返回相对 URL""" + if not src_url: + return None + dest_key = _gen_dest_key(target_dianpu_id, category, src_url) + if not copy_in_oss(src_url, dest_key): + raise ShopCopyError(f'图片复制失败: {src_url}') + return dest_key + + +def _clear_target_catalog_db_only(target_dianpu): + """全部覆盖模式:仅删除目标店铺商品目录 DB 记录,返回待清理的 OSS URL""" + dianpu_id = target_dianpu.id + oss_to_delete = [] + + products = list(Shangpin.query.filter(dianpu_id=dianpu_id)) + for p in products: + if p.tupian_url: + oss_to_delete.append(p.tupian_url) + if p.guize_tupian: + oss_to_delete.append(p.guize_tupian) + if products: + Shangpin.query.filter(dianpu_id=dianpu_id).delete() + + ShangpinZhuanqu.query.filter(dianpu_id=dianpu_id).delete() + + types = list(ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id)) + for t in types: + if t.tupian_url: + oss_to_delete.append(t.tupian_url) + if types: + ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id).delete() + + return oss_to_delete + + +def _delete_oss_urls(urls): + for url in urls: + if url: + delete_from_oss(url) + + +def _pre_copy_source_images(source_dianpu, target_dianpu): + """ + 预复制源店铺所有图片到目标店铺 OSS 路径。 + 返回结构化的预复制数据,供事务内写库使用。 + """ + src_id = source_dianpu.id + tgt_id = target_dianpu.id + + source_types = list( + ShangpinLeixingDianpu.query.filter(dianpu_id=src_id).order_by('paixu', 'id') + ) + type_payloads = [] + for st in source_types: + type_payloads.append({ + 'source': st, + 'tupian_url': _copy_image_url(st.tupian_url, tgt_id, 'shangpinleixing'), + }) + + source_zones = list( + ShangpinZhuanqu.query.filter(dianpu_id=src_id).order_by('paixu', 'id') + ) + zone_payloads = [] + for sz in source_zones: + zone_payloads.append({'source': sz}) + + source_products = list( + Shangpin.query.filter(dianpu_id=src_id).order_by('paixu', 'id') + ) + product_payloads = [] + for sp in source_products: + product_payloads.append({ + 'source': sp, + 'tupian_url': _copy_image_url(sp.tupian_url, tgt_id, 'shangpin'), + 'guize_tupian': _copy_image_url(sp.guize_tupian, tgt_id, 'shangpinguize') if sp.guize_tupian else None, + }) + + return type_payloads, zone_payloads, product_payloads + + +def _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads): + """在目标店铺插入复制后的类型、专区、商品,维护 ID 映射""" + type_id_map = {} + zone_id_map = {} + + for item in type_payloads: + st = item['source'] + new_type = ShangpinLeixingDianpu.query.create( + dianpu_id=target_dianpu.id, + gonggong_leixing_id=st.gonggong_leixing_id, + jieshao=st.jieshao, + tupian_url=item['tupian_url'], + paixu=st.paixu, + shangjia_zhuangtai=st.shangjia_zhuangtai, + fengjin_zhuangtai=st.fengjin_zhuangtai, + shenhe_zhuangtai=st.shenhe_zhuangtai, + ) + type_id_map[st.id] = new_type.id + + for item in zone_payloads: + sz = item['source'] + new_dianpu_leixing_id = type_id_map.get(sz.dianpu_leixing_id) + if not new_dianpu_leixing_id: + raise ShopCopyError(f'专区「{sz.mingzi}」关联的商品类型在复制映射中缺失') + new_zone = ShangpinZhuanqu.query.create( + dianpu_id=target_dianpu.id, + mingzi=sz.mingzi, + dianpu_leixing_id=new_dianpu_leixing_id, + leixing_id=sz.leixing_id, + paixu=sz.paixu, + shangjia_zhuangtai=sz.shangjia_zhuangtai, + fengjin_zhuangtai=sz.fengjin_zhuangtai, + shenhezhuangtai=sz.shenhezhuangtai, + ) + zone_id_map[sz.id] = new_zone.id + + copied_products = 0 + for item in product_payloads: + sp = item['source'] + new_dianpu_leixing_id = type_id_map.get(sp.dianpu_leixing_id) + new_zhuanqu_id = zone_id_map.get(sp.zhuanqu_id) if sp.zhuanqu_id else None + if sp.dianpu_leixing_id and not new_dianpu_leixing_id: + raise ShopCopyError(f'商品「{sp.biaoqian}」关联的商品类型在复制映射中缺失') + if sp.zhuanqu_id and not new_zhuanqu_id: + raise ShopCopyError(f'商品「{sp.biaoqian}」关联的专区在复制映射中缺失') + + Shangpin.query.create( + biaoqian=sp.biaoqian, + jiage=sp.jiage, + kucun=sp.kucun, + leixing_id=sp.leixing_id, + zhuanqu_id=new_zhuanqu_id, + zhenshi_xiaoliang=0, + duiwai_xiaoliang=0, + jieshao=sp.jieshao, + xiadan_xuzhi=sp.xiadan_xuzhi, + guize_tupian=item['guize_tupian'], + yaoqiuleixing=sp.yaoqiuleixing, + huiyuan_id=sp.huiyuan_id, + yongjin=sp.yongjin, + kaioi_ewai_dashou_fencheng=sp.kaioi_ewai_dashou_fencheng, + ewai_dashou_fencheng=sp.ewai_dashou_fencheng, + tupian_url=item['tupian_url'], + paixu=sp.paixu, + shenhezhuangtai=sp.shenhezhuangtai, + dianpu_id=target_dianpu.id, + dianpu_leixing_id=new_dianpu_leixing_id, + shangjia_zhuangtai=sp.shangjia_zhuangtai, + fengjin_zhuangtai=sp.fengjin_zhuangtai, + shenhe_zhuangtai=sp.shenhe_zhuangtai, + ) + copied_products += 1 + + return { + 'types': len(type_payloads), + 'zones': len(zone_payloads), + 'products': copied_products, + } + + +def copy_shop_catalog(source_user_uid, target_user_uid, copy_mode=COPY_MODE_APPEND): + """ + 复制店铺商品目录。 + :param source_user_uid: 源店铺小程序用户 ID(YonghuPingzheng.yonghu,唯一) + :param target_user_uid: 目标店铺小程序用户 ID + :param copy_mode: 'replace' 全部覆盖 | 'append' 补充追加 + """ + if copy_mode not in (COPY_MODE_REPLACE, COPY_MODE_APPEND): + raise ShopCopyError('copy_mode 无效,仅支持 replace 或 append') + + source_dianpu = resolve_dianpu_by_user_uid(source_user_uid) + target_dianpu = resolve_dianpu_by_user_uid(target_user_uid) + + if source_dianpu.id == target_dianpu.id: + raise ShopCopyError('源店铺与目标店铺不能相同') + + # 1. 预复制 OSS 图片(DB 变更前完成,失败则目标店不受影响) + type_payloads, zone_payloads, product_payloads = _pre_copy_source_images( + source_dianpu, target_dianpu + ) + + oss_cleanup = [] + # 2. 数据库事务:锁定目标店 → 可选清空 → 写入 + with transaction.atomic(): + Dianpu.objects.select_for_update().get(id=target_dianpu.id) + + if copy_mode == COPY_MODE_REPLACE: + oss_cleanup = _clear_target_catalog_db_only(target_dianpu) + + stats = _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads) + + if oss_cleanup: + _delete_oss_urls(oss_cleanup) + + logger.info( + '店铺商品复制完成: source=%s(%s) -> target=%s(%s) mode=%s stats=%s', + source_dianpu.id, source_user_uid, + target_dianpu.id, target_user_uid, + copy_mode, stats, + ) + + return { + 'source_dianpu_id': source_dianpu.id, + 'source_dianpu_name': source_dianpu.dianpu_mingcheng, + 'target_dianpu_id': target_dianpu.id, + 'target_dianpu_name': target_dianpu.dianpu_mingcheng, + 'copy_mode': copy_mode, + 'copied_types': stats['types'], + 'copied_zones': stats['zones'], + 'copied_products': stats['products'], + } diff --git a/backend/urls.py b/backend/urls.py index 4274d97..c178a91 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -10,7 +10,7 @@ from .view import (GetRolePermissionView, UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView, ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView, ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView, ShopProductModifyView, - ShopProductTypeMappingView, ProductListView, ProductModifyView, KefuChatPermissionsView, + ShopProductTypeMappingView, ProductListView, ProductModifyView, ShopCopyCatalogView, KefuChatPermissionsView, FineApplyView, PunishDashouView, FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView, HqbkxxView,BkxgView,CaiwuView,CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView, @@ -82,6 +82,7 @@ urlpatterns = [ path('hqhtdpsplx', ProductListView.as_view(), name='后台获取店铺商品数据'), path('htxgdpspsj', ProductModifyView.as_view(), name='后台修改店铺商品数据'), + path('htfzdpspsj', ShopCopyCatalogView.as_view(), name='后台复制店铺商品目录'), path('kfhqltqx', KefuChatPermissionsView.as_view(), name='后台获取客服聊天配置'), diff --git a/backend/view.py b/backend/view.py index e37fd21..0923bd1 100644 --- a/backend/view.py +++ b/backend/view.py @@ -63,6 +63,7 @@ from .views.system import ( from .views.shops import ( ShopListView, ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView, ShopProductTypeMappingView, ShopProductModifyView, ProductListView, ProductModifyView, + ShopCopyCatalogView, ) from .views.penalties import ( FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanPingTaiShenHeView, @@ -117,6 +118,7 @@ __all__ = [ # shops 'ShopListView', 'ShopModifyView', 'ShopPublicTypeAndAuditView', 'ShopListForProductView', 'ShopProductTypeMappingView', 'ShopProductModifyView', 'ProductListView', 'ProductModifyView', + 'ShopCopyCatalogView', # penalties 'FaKuanTongJiView', 'FaKuanLieBiaoView', 'FaKuanChuLiView', 'FaKuanPingTaiShenHeView', 'FaKuanChuangJianView', 'FineApplyView', 'PunishDashouView', diff --git a/backend/views/shops.py b/backend/views/shops.py index a3803f0..942968a 100644 --- a/backend/views/shops.py +++ b/backend/views/shops.py @@ -931,3 +931,46 @@ class ProductModifyView(APIView): product.save(update_fields=update_fields) return Response({'code': 0, 'msg': '商品修改成功'}) + +class ShopCopyCatalogView(APIView): + """ + 复制店铺商品目录(类型 + 专区 + 商品,含 OSS 图片独立拷贝)。 + 请求路径: /houtai/htfzdpspsj + 权限: 店铺管理权限 999a~999e 任一 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('username') + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) + + required_perms = {'999a', '999b', '999c', '999d', '999e'} + if not required_perms.intersection(set(permissions)): + return Response({'code': 403, 'msg': '您没有权限执行店铺商品复制操作'}) + + source_user_uid = str(request.data.get('source_user_uid', '')).strip() + target_user_uid = str(request.data.get('target_user_uid', '')).strip() + copy_mode = str(request.data.get('copy_mode', 'append')).strip().lower() + + if not source_user_uid or not target_user_uid: + return Response({'code': 400, 'msg': '请填写源店铺与目标店铺的用户ID'}) + + from backend.services.shop_copy_service import copy_shop_catalog, ShopCopyError + + try: + result = copy_shop_catalog(source_user_uid, target_user_uid, copy_mode) + return Response({ + 'code': 0, + 'msg': ( + f'复制成功:商品类型 {result["copied_types"]} 个,' + f'专区 {result["copied_zones"]} 个,商品 {result["copied_products"]} 个' + ), + 'data': result, + }) + except ShopCopyError as e: + return Response({'code': e.code, 'msg': e.message}) + except Exception: + logger.exception('复制店铺商品失败') + return Response({'code': 500, 'msg': '复制失败,请稍后重试'}) diff --git a/utils/oss_utils.py b/utils/oss_utils.py index 95cbfb7..c2db037 100644 --- a/utils/oss_utils.py +++ b/utils/oss_utils.py @@ -184,6 +184,88 @@ def _upload_to_aliyun_oss(file_obj, file_path): return None +def _url_to_oss_key(file_url): + """从完整 URL 或相对路径提取 OSS 对象 Key""" + if not file_url: + return None + file_url = str(file_url).strip() + if not file_url: + return None + + oss_type = getattr(settings, 'OSS_TYPE', 'tencent') + if oss_type == 'tencent': + domain = getattr(settings, 'COS_DOMAIN', '') + elif oss_type == 'aliyun': + domain = getattr(settings, 'ALIYUN_OSS_DOMAIN', '') + else: + return None + + if file_url.startswith('http'): + if domain and domain not in file_url: + return None + prefix = f"{domain.rstrip('/')}/" + if file_url.startswith(prefix): + return file_url[len(prefix):] + return file_url.split('://', 1)[-1].split('/', 1)[-1] + + return file_url.lstrip('/') + + +def copy_in_oss(src_key_or_url, dest_key): + """ + 在 OSS 内复制对象(服务端复制,不经过本地) + src_key_or_url: 源对象 Key 或完整 URL + dest_key: 目标对象 Key(相对路径) + 返回: 成功 True,失败 False + """ + try: + src_key = _url_to_oss_key(src_key_or_url) + if not src_key or not dest_key: + return False + + dest_key = dest_key.lstrip('/') + oss_type = getattr(settings, 'OSS_TYPE', 'tencent') + + if oss_type == 'tencent': + return _copy_in_tencent_cos(src_key, dest_key) + elif oss_type == 'aliyun': + return _copy_in_aliyun_oss(src_key, dest_key) + return False + except Exception as e: + print(f"OSS复制失败: {e}") + return False + + +def _copy_in_tencent_cos(src_key, dest_key): + client = get_oss_client() + bucket = getattr(settings, 'COS_BUCKET', '') + region = getattr(settings, 'COS_REGION', 'ap-shanghai') + try: + client.copy_object( + Bucket=bucket, + Key=dest_key, + CopySource={ + 'Bucket': bucket, + 'Region': region, + 'Key': src_key, + }, + ) + return True + except Exception as e: + print(f"腾讯云COS复制失败: {e}") + return False + + +def _copy_in_aliyun_oss(src_key, dest_key): + bucket = get_oss_client() + try: + result = bucket.copy_object(src_key, dest_key) + return result.status == 200 + except Exception as e: + print(f"阿里云OSS复制失败: {e}") + return False + + def delete_from_oss(file_url): """ 从OSS删除文件 From a92352f2b0c2f6c24c0027bbed7e071f3a5e4f36 Mon Sep 17 00:00:00 2001 From: XingQue Date: Thu, 9 Jul 2026 23:06:34 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E5=BA=97=E9=93=BA=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=A0=A1=E9=AA=8C=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用 has_perm_code 替代 set(permissions) 交集判断,避免 _AllPermissions 被转成 set 后丢失超管放行能力。 Co-authored-by: Cursor --- backend/views/shops.py | 75 ++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/backend/views/shops.py b/backend/views/shops.py index 942968a..c69d4d0 100644 --- a/backend/views/shops.py +++ b/backend/views/shops.py @@ -50,6 +50,7 @@ from products.utils import update_shangpin_daily_stat from orders.notice_tasks import dingdan_guangbo from ..utils import ( verify_kefu_permission, + has_perm_code, PERMISSION_TO_SHENFEN, SHENFEN_PROFILE_MAP, has_fadan_view_permission, @@ -113,6 +114,7 @@ from ..serializers import PopupPageSerializer # 全局常量、日志对象 logger = logging.getLogger('houtai') SHOP_PRODUCT_PERMS = ['1199ab', '1199abc', '1199abd'] +SHOP_MANAGE_PERMS = ('999a', '999b', '999c', '999d', '999e') class ShopListView(APIView): permission_classes = [IsAuthenticated] @@ -125,8 +127,7 @@ class ShopListView(APIView): return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) # 2. 检查是否拥有所需权限(999a~999e 任意一个) - required_perms = {'999a', '999b', '999c', '999d', '999e'} - if not required_perms.intersection(set(permissions)): + if not has_perm_code(permissions, *SHOP_MANAGE_PERMS): return Response({'code': 403, 'msg': '您没有权限访问店铺管理功能'}) # 3. 解析请求参数 @@ -220,21 +221,19 @@ class ShopModifyView(APIView): if kefu is None: return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) - # 转换权限为集合方便判断 - user_perms = set(permissions) action = request.data.get('action', '') # 2. 根据 action 分派任务 if action == 'update_dianpu': - return self._update_dianpu(request, user_perms) + return self._update_dianpu(request, permissions) elif action == 'add_dianpu': - return self._add_dianpu(request, user_perms) + return self._add_dianpu(request, permissions) elif action == 'update_default': - return self._update_default(request, user_perms) + return self._update_default(request, permissions) else: return Response({'code': 400, 'msg': '未知操作类型'}) - def _update_dianpu(self, request, user_perms): + def _update_dianpu(self, request, permissions): """修改店铺信息,精确权限控制""" dianpu_id = request.data.get('dianpu_id') if not dianpu_id: @@ -246,7 +245,7 @@ class ShopModifyView(APIView): # 基础信息修改 (999e) if any(k in request.data for k in ['dianpu_mingcheng', 'lianxi_dianhua', 'weixinhao']): - if '999e' not in user_perms: + if not has_perm_code(permissions, '999e'): return Response({'code': 403, 'msg': '无权限修改店铺基本信息(999e)'}) if 'dianpu_mingcheng' in request.data: dianpu.dianpu_mingcheng = request.data['dianpu_mingcheng'] @@ -257,7 +256,7 @@ class ShopModifyView(APIView): # 店铺状态 (999a) if 'zhuangtai' in request.data: - if '999a' not in user_perms: + if not has_perm_code(permissions, '999a'): return Response({'code': 403, 'msg': '无权限修改店铺状态(999a)'}) new_status = int(request.data['zhuangtai']) if new_status not in (0, 1): @@ -266,13 +265,13 @@ class ShopModifyView(APIView): # 可提现余额 (999b) if 'ketixian_yue' in request.data: - if '999b' not in user_perms: + if not has_perm_code(permissions, '999b'): return Response({'code': 403, 'msg': '无权限修改可提现余额(999b)'}) dianpu.ketixian_yue = request.data['ketixian_yue'] # 提现限额相关 (999c) if 'kaiqi_meiri_xiane' in request.data or 'meiri_tixian_xiane' in request.data: - if '999c' not in user_perms: + if not has_perm_code(permissions, '999c'): return Response({'code': 403, 'msg': '无权限修改提现限额配置(999c)'}) if 'kaiqi_meiri_xiane' in request.data: dianpu.kaiqi_meiri_xiane = request.data['kaiqi_meiri_xiane'] @@ -281,7 +280,7 @@ class ShopModifyView(APIView): # 抽成相关 (999d) if 'kaiqi_shouyi_choucheng' in request.data or 'shouyi_choucheng_feilv' in request.data: - if '999d' not in user_perms: + if not has_perm_code(permissions, '999d'): return Response({'code': 403, 'msg': '无权限修改抽成配置(999d)'}) if 'kaiqi_shouyi_choucheng' in request.data: dianpu.kaiqi_shouyi_choucheng = request.data['kaiqi_shouyi_choucheng'] @@ -297,9 +296,9 @@ class ShopModifyView(APIView): logger.exception("修改店铺失败") return Response({'code': 500, 'msg': '修改失败,请稍后重试'}) - def _add_dianpu(self, request, user_perms): + def _add_dianpu(self, request, permissions): """添加店铺,需要权限 999b""" - if '999b' not in user_perms: + if not has_perm_code(permissions, '999b'): return Response({'code': 403, 'msg': '无权限添加店铺(999b)'}) yonghu_id = request.data.get('yonghu_id') @@ -346,16 +345,16 @@ class ShopModifyView(APIView): logger.exception("创建店铺失败") return Response({'code': 500, 'msg': '创建失败,请稍后重试'}) - def _update_default(self, request, user_perms): + def _update_default(self, request, permissions): """修改默认配置,按修改内容校验权限""" feilv = request.data.get('moren_choucheng_feilv') xiane = request.data.get('moren_tixian_xiane') modify_commission = feilv is not None modify_withdraw = xiane is not None - if modify_commission and '999d' not in user_perms: + if modify_commission and not has_perm_code(permissions, '999d'): return Response({'code': 403, 'msg': '无权限修改默认抽成费率(999d)'}) - if modify_withdraw and '999c' not in user_perms: + if modify_withdraw and not has_perm_code(permissions, '999c'): return Response({'code': 403, 'msg': '无权限修改默认可提现限额(999c)'}) try: @@ -394,7 +393,7 @@ class ShopPublicTypeAndAuditView(APIView): return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) # 拥有 1199ab/1199abc/1199abd 任一个即可访问 - if not set(permissions).intersection(SHOP_PRODUCT_PERMS): + if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS): return Response({'code': 403, 'msg': '无权访问店铺商品管理功能'}) # ---------- 查询公共商品类型(仅正常审核状态的) ---------- @@ -444,7 +443,7 @@ class ShopListForProductView(APIView): kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return Response({'code': 403, 'msg': '身份验证失败'}) - if not set(permissions).intersection(SHOP_PRODUCT_PERMS): + if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS): return Response({'code': 403, 'msg': '无权访问店铺列表'}) # ---------- 安全解析分页参数 ---------- @@ -520,7 +519,7 @@ class ShopProductTypeMappingView(APIView): kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return Response({'code': 403, 'msg': '身份验证失败'}) - if not set(permissions).intersection(SHOP_PRODUCT_PERMS): + if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS): return Response({'code': 403, 'msg': '无权访问商品类型映射'}) # ---------- 安全解析参数 ---------- @@ -611,7 +610,7 @@ class ShopProductModifyView(APIView): if kefu is None: return Response({'code': 403, 'msg': '身份验证失败'}) - user_perms = set(permissions) + user_perms = permissions action = request.data.get('action') if action == 'update_audit_mode': @@ -623,7 +622,7 @@ class ShopProductModifyView(APIView): @transaction.atomic # ✅ 事务修复点 def _update_audit_mode(self, request, user_perms): - if '1199ab' not in user_perms: + if not has_perm_code(permissions, '1199ab'): return Response({'code': 403, 'msg': '无权限修改审核模式'}) kaiqi = request.data.get('kaiqi_shenhe') @@ -657,25 +656,25 @@ class ShopProductModifyView(APIView): # 权限判断 if fengjin is not None: if fengjin: - if '1199abc' not in user_perms: + if not has_perm_code(user_perms, '1199abc'): return Response({'code': 403, 'msg': '无权限封禁'}) else: - if '1199abd' not in user_perms: + if not has_perm_code(user_perms, '1199abd'): return Response({'code': 403, 'msg': '无权限解封'}) mapping.fengjin_zhuangtai = bool(fengjin) if shangjia is not None: if not shangjia: - if '1199abc' not in user_perms: + if not has_perm_code(user_perms, '1199abc'): return Response({'code': 403, 'msg': '无权限下架'}) else: - if '1199abd' not in user_perms: + if not has_perm_code(user_perms, '1199abd'): return Response({'code': 403, 'msg': '无权限上架'}) mapping.shangjia_zhuangtai = bool(shangjia) if shenhe is not None: if shenhe: - if '1199abd' not in user_perms: + if not has_perm_code(user_perms, '1199abd'): return Response({'code': 403, 'msg': '无权限审核通过'}) mapping.shenhe_zhuangtai = bool(shenhe) @@ -706,7 +705,7 @@ class ProductListView(APIView): if kefu is None: return Response({'code': 403, 'msg': '身份验证失败'}) - if not set(permissions).intersection(PRODUCT_PERMS): + if not has_perm_code(permissions, *PRODUCT_PERMS): return Response({'code': 403, 'msg': '无商品管理权限'}) # ----- 安全解析分页与筛选参数 ----- @@ -844,16 +843,15 @@ class ProductModifyView(APIView): if kefu is None: return Response({'code': 403, 'msg': '身份验证失败'}) - user_perms = set(permissions) action = request.data.get('action', '') if action != 'update_product': return Response({'code': 400, 'msg': '未知操作'}) - return self._update_product(request, user_perms) + return self._update_product(request, permissions) @transaction.atomic - def _update_product(self, request, user_perms): + def _update_product(self, request, permissions): """执行商品修改,严格校验权限""" product_id = request.data.get('product_id') if not product_id: @@ -877,18 +875,18 @@ class ProductModifyView(APIView): # ---------- 权限分类检查 ---------- # 1. 封禁/下架操作 (消极操作) -> 1199abc if any(status_changes.get(k) is not None and status_changes[k] == False for k in ['shangjia', 'shenhe']): - if '1199abc' not in user_perms: + if not has_perm_code(permissions, '1199abc'): return Response({'code': 403, 'msg': '无权限执行下架/封禁/驳回审核操作(1199abc)'}) if status_changes.get('fengjin') is not None and status_changes['fengjin'] == True: - if '1199abc' not in user_perms: + if not has_perm_code(permissions, '1199abc'): return Response({'code': 403, 'msg': '无权限执行封禁操作(1199abc)'}) # 2. 解封/上架/过审 (积极操作) -> 1199abd if any(status_changes.get(k) is not None and status_changes[k] == True for k in ['shangjia', 'shenhe']): - if '1199abd' not in user_perms: + if not has_perm_code(permissions, '1199abd'): return Response({'code': 403, 'msg': '无权限执行上架/解封/审核通过操作(1199abd)'}) if status_changes.get('fengjin') is not None and status_changes['fengjin'] == False: - if '1199abd' not in user_perms: + if not has_perm_code(permissions, '1199abd'): return Response({'code': 403, 'msg': '无权限执行解封操作(1199abd)'}) # 3. 修改其他数据(非状态字段)-> 1199abe @@ -896,7 +894,7 @@ class ProductModifyView(APIView): other_fields = [k for k in request.data.keys() if k not in ['action', 'product_id', 'username', 'shangjia_zhuangtai', 'fengjin_zhuangtai', 'shenhe_zhuangtai']] - if other_fields and '1199abe' not in user_perms: + if other_fields and not has_perm_code(permissions, '1199abe'): return Response({'code': 403, 'msg': '无权限修改商品基本信息(1199abe)'}) # ---------- 应用修改 ---------- @@ -946,8 +944,7 @@ class ShopCopyCatalogView(APIView): if kefu is None: return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) - required_perms = {'999a', '999b', '999c', '999d', '999e'} - if not required_perms.intersection(set(permissions)): + if not has_perm_code(permissions, *SHOP_MANAGE_PERMS): return Response({'code': 403, 'msg': '您没有权限执行店铺商品复制操作'}) source_user_uid = str(request.data.get('source_user_uid', '')).strip() From 90e187b65cc66c5ca024878e9284213f4d922e5e Mon Sep 17 00:00:00 2001 From: XingQue Date: Thu, 9 Jul 2026 23:09:37 +0800 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20=E8=A1=A5=E9=BD=90=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E7=AE=A1=E7=90=86=E6=9D=83=E9=99=90=E7=A0=81=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E4=B8=8ESQL=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增999a~999e与1199ab~1199abe权限定义,并提供可重复执行的查询/插入SQL。 Co-authored-by: Cursor --- deploy/seed_shop_permissions.sql | 111 ++++++++++++++++++ .../commands/seed_gvsdsdk_permissions.py | 11 ++ 2 files changed, 122 insertions(+) create mode 100644 deploy/seed_shop_permissions.sql diff --git a/deploy/seed_shop_permissions.sql b/deploy/seed_shop_permissions.sql new file mode 100644 index 0000000..2c27600 --- /dev/null +++ b/deploy/seed_shop_permissions.sql @@ -0,0 +1,111 @@ +-- ============================================================ +-- 店铺相关权限:查询 + 缺失则插入 +-- 主权限表:Permission(gvsdsdk RBAC,角色管理页读取此表) +-- 兼容旧表:permission(legacy,部分账号仍走 user_role.account_id=手机号) +-- ============================================================ + +-- ---------- 1. 查询 gvsdsdk 主权限表 ---------- +SELECT PermCode, PermName, PermDesc, PermCategory, PermStatus +FROM `Permission` +WHERE PermCode IN ( + '999a', '999b', '999c', '999d', '999e', + '1199ab', '1199abc', '1199abd', '1199abe' +) +ORDER BY PermCode; + +-- ---------- 2. 查询 legacy 权限表(如仍在使用) ---------- +SELECT perm_id, perm_code, perm_name, description +FROM `permission` +WHERE perm_code IN ( + '999a', '999b', '999c', '999d', '999e', + '1199ab', '1199abc', '1199abd', '1199abe' +) +ORDER BY perm_code; + +-- ---------- 3. 缺失则写入 Permission(主表,推荐) ---------- +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺状态管理', '999a', '店铺管理', 1, '修改店铺封禁/正常状态', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999a'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺余额与创建', '999b', '店铺管理', 1, '修改可提现余额、添加新店铺', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999b'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺提现限额', '999c', '店铺管理', 1, '修改每日提现限额及开关', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999c'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺抽成配置', '999d', '店铺管理', 1, '修改店铺订单收益抽成及默认抽成费率', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999d'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺基本信息', '999e', '店铺管理', 1, '修改店铺名称、电话、微信号', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999e'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品审核模式', '1199ab', '店铺管理', 1, '查看/修改店铺商品全局审核开关', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199ab'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品封禁下架', '1199abc', '店铺管理', 1, '封禁、下架、驳回店铺商品/类型审核', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abc'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品上架解封', '1199abd', '店铺管理', 1, '上架、解封、审核通过店铺商品/类型', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abd'); + +INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`) +SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品信息修改', '1199abe', '店铺管理', 1, '修改店铺商品标题、价格、库存等基本信息', NOW() +FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t +WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abe'); + +-- ---------- 4. 可选:legacy permission 表(若你们仍在用旧角色表) ---------- +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '999a', '店铺状态管理', '修改店铺封禁/正常状态', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999a'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '999b', '店铺余额与创建', '修改可提现余额、添加新店铺', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999b'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '999c', '店铺提现限额', '修改每日提现限额及开关', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999c'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '999d', '店铺抽成配置', '修改店铺订单收益抽成及默认抽成费率', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999d'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '999e', '店铺基本信息', '修改店铺名称、电话、微信号', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999e'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '1199ab', '店铺商品审核模式', '查看/修改店铺商品全局审核开关', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199ab'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '1199abc', '店铺商品封禁下架', '封禁、下架、驳回店铺商品/类型审核', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abc'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '1199abd', '店铺商品上架解封', '上架、解封、审核通过店铺商品/类型', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abd'); + +INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`) +SELECT '1199abe', '店铺商品信息修改', '修改店铺商品标题、价格、库存等基本信息', NOW(), NOW() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abe'); + +-- ---------- 5. 插入后再查一遍确认 ---------- +SELECT PermCode, PermName, PermStatus FROM `Permission` +WHERE PermCode IN ('999a','999b','999c','999d','999e','1199ab','1199abc','1199abd','1199abe') +ORDER BY PermCode; diff --git a/jituan/management/commands/seed_gvsdsdk_permissions.py b/jituan/management/commands/seed_gvsdsdk_permissions.py index 0f7e7f5..b2d1a26 100644 --- a/jituan/management/commands/seed_gvsdsdk_permissions.py +++ b/jituan/management/commands/seed_gvsdsdk_permissions.py @@ -32,6 +32,17 @@ EXTRA_PERMISSIONS = [ '管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)', '用户管理', ), + # ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ---------- + ('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'), + ('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'), + ('999c', '店铺提现限额', '修改每日提现限额及开关', '店铺管理'), + ('999d', '店铺抽成配置', '修改店铺订单收益抽成及默认抽成费率', '店铺管理'), + ('999e', '店铺基本信息', '修改店铺名称、电话、微信号', '店铺管理'), + # ---------- 店铺商品管理(1199 系列:商品/类型/审核) ---------- + ('1199ab', '店铺商品审核模式', '查看/修改店铺商品全局审核开关', '店铺管理'), + ('1199abc', '店铺商品封禁下架', '封禁、下架、驳回店铺商品/类型审核', '店铺管理'), + ('1199abd', '店铺商品上架解封', '上架、解封、审核通过店铺商品/类型', '店铺管理'), + ('1199abe', '店铺商品信息修改', '修改店铺商品标题、价格、库存等基本信息', '店铺管理'), ] From df49631401178ee71555540008041688d1d05415 Mon Sep 17 00:00:00 2001 From: XingQue Date: Thu, 9 Jul 2026 23:51:00 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E5=BA=97=E9=93=BA=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81=E7=94=9F=E6=88=90=E4=B8=8E=E4=B8=8B=E8=BD=BD=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 stable access_token 并重试;规范化 OSS Key;下载接口返回 JSON 错误便于前端识别。 Co-authored-by: Cursor --- shop/views/shop_base_views.py | 129 +++++++++++++++------------------- 1 file changed, 56 insertions(+), 73 deletions(-) diff --git a/shop/views/shop_base_views.py b/shop/views/shop_base_views.py index be7c677..8d458f3 100644 --- a/shop/views/shop_base_views.py +++ b/shop/views/shop_base_views.py @@ -21,7 +21,6 @@ from django.conf import settings from django.db import models, transaction, IntegrityError from django.db.models import F, Sum, Count, Q, Prefetch from gvsdsdk.fluent import db, func, FQ -from django.core.cache import cache from django.core.paginator import Paginator, EmptyPage from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone @@ -33,7 +32,8 @@ from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework_simplejwt.tokens import RefreshToken -from utils.oss_utils import upload_to_oss, delete_from_oss, get_oss_client +from utils.oss_utils import upload_to_oss, delete_from_oss, get_oss_client, _url_to_oss_key +from utils.weixin_token import get_weixin_mini_access_token, is_weixin_token_invalid from utils.money import yuan_to_fen from ..utils import verify_shop_permission @@ -482,31 +482,30 @@ class QrcodeDownloadView(APIView): # 2. 获取店铺的二维码相对路径 erweima_relative_url = dianpu.erweima_url if not erweima_relative_url: - return HttpResponse('店铺暂无二维码', status=404) + return Response({'code': 404, 'msg': '店铺暂无二维码'}, status=404) - # 3. 拼接 OSS 完整访问地址(使用 settings 中的域名) - oss_domain = getattr(settings, 'COS_DOMAIN', '') - if not oss_domain: - logger.error("COS_DOMAIN 未配置") - return HttpResponse('OSS域名未配置', status=500) + oss_key = _url_to_oss_key(erweima_relative_url) or str(erweima_relative_url).lstrip('/') - image_url = oss_domain.rstrip('/') + '/' + erweima_relative_url.lstrip('/') - - # 4. 从腾讯云 COS 获取图片内容 + # 3. 从腾讯云 COS 获取图片内容 try: client = get_oss_client() bucket = getattr(settings, 'COS_BUCKET', '') - response = client.get_object(Bucket=bucket, Key=erweima_relative_url) - image_data = response['Body'].get_raw_stream().read() - except Exception as e: - logger.exception("从COS获取二维码失败") - return HttpResponse('下载失败,请稍后重试', status=502) + cos_resp = client.get_object(Bucket=bucket, Key=oss_key) + image_data = cos_resp['Body'].get_raw_stream().read() + except Exception: + logger.exception("从COS获取二维码失败 key=%s", oss_key) + return Response({'code': 502, 'msg': '下载失败,请稍后重试'}, status=502) - # 5. 构造下载响应,强制浏览器保存为文件 - filename = f"店铺二维码_{dianpu.dianpu_mingcheng}.png" + if not image_data: + return Response({'code': 404, 'msg': '二维码文件不存在'}, status=404) + + # 4. 返回图片(inline 供页面展示,attachment 供下载) + filename = f"shop_qrcode_{dianpu.id}.png" + disposition_type = 'inline' if request.GET.get('disposition') == 'inline' else 'attachment' response = HttpResponse(image_data, content_type='image/png') - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response['Content-Disposition'] = f'{disposition_type}; filename="{filename}"' response['Content-Length'] = str(len(image_data)) + response['Cache-Control'] = 'private, max-age=300' return response @@ -649,42 +648,56 @@ class ShopQrcodeView(APIView): if error_response: return error_response - # 获取缓存的微信 access_token - access_token = self._get_wx_access_token() + # 获取微信 access_token(stable_token + 缓存) + access_token = get_weixin_mini_access_token() if not access_token: return Response( - {'code': 500, 'msg': '获取微信 access_token 失败'}, + {'code': 500, 'msg': '获取微信 access_token 失败,请检查小程序配置'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - # 构造小程序码请求参数 - # 注意:scene 最大32个字符,我们使用店铺ID + # 构造小程序码请求参数(scene 传店铺ID,首页解析 scene 进入店铺) scene = str(dianpu.id) - page_path = "pages/index/index" # 前端小程序首页路径 - wx_url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}' + page_path = getattr(settings, 'SHOP_QRCODE_PAGE', 'pages/index/index') post_data = { 'scene': scene, 'page': page_path, 'width': 430, 'auto_color': False, 'line_color': {'r': 0, 'g': 0, 'b': 0}, + 'check_path': False, } - # 调用微信接口生成小程序码 - try: - wx_resp = requests.post(wx_url, json=post_data, timeout=10) - except Exception as e: - logger.error(f"请求微信生成二维码接口异常: {e}") - return Response( - {'code': 500, 'msg': '调用微信服务失败'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + def _request_wx_qrcode(token): + wx_url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={token}' + return requests.post(wx_url, json=post_data, timeout=15) - # 判断返回是否为图片 - if wx_resp.status_code != 200 or 'image' not in wx_resp.headers.get('content-type', ''): - error_info = wx_resp.json() if wx_resp.headers.get('content-type') == 'application/json' else {} - errmsg = error_info.get('errmsg', '未知错误') - logger.error(f"微信生成二维码失败: {errmsg}") + wx_resp = _request_wx_qrcode(access_token) + + # token 失效时强制刷新重试一次 + if wx_resp.headers.get('content-type', '').startswith('application/json'): + try: + err = wx_resp.json() + if is_weixin_token_invalid(err.get('errcode'), err.get('errmsg', '')): + access_token = get_weixin_mini_access_token(force_refresh=True) + if access_token: + wx_resp = _request_wx_qrcode(access_token) + except Exception: + pass + + # 判断返回是否为图片(微信错误时可能返回 JSON) + content_type = wx_resp.headers.get('content-type', '') + if wx_resp.status_code != 200 or 'image' not in content_type: + errmsg = '未知错误' + try: + error_info = wx_resp.json() + errmsg = error_info.get('errmsg', errmsg) + logger.error( + '微信生成店铺二维码失败 dianpu=%s errcode=%s errmsg=%s', + dianpu.id, error_info.get('errcode'), errmsg, + ) + except Exception: + logger.error('微信生成店铺二维码失败 dianpu=%s status=%s', dianpu.id, wx_resp.status_code) return Response( {'code': 500, 'msg': f'生成二维码失败: {errmsg}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR @@ -713,10 +726,10 @@ class ShopQrcodeView(APIView): status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - # 提取相对路径(根据您的 upload_to_oss 返回值格式调整) - cos_domain = getattr(settings, 'COS_DOMAIN', '') + # 提取相对路径(与商品图片上传逻辑一致) + cos_domain = getattr(settings, 'COS_DOMAIN', '').rstrip('/') if cos_domain and full_url.startswith(cos_domain): - relative_path = full_url[len(cos_domain):].lstrip('/') + relative_path = full_url.replace(f'{cos_domain}/', '', 1) else: relative_path = oss_path @@ -739,33 +752,3 @@ class ShopQrcodeView(APIView): 'data': {'erweima_url': relative_path} }) - def _get_wx_access_token(self): - """ - 获取微信小程序 access_token,带缓存 - 缓存键: wx_mini_access_token - """ - cache_key = 'wx_mini_access_token' - token = cache.get(cache_key) - if token: - return token - - appid = getattr(settings, 'WEIXIN_APPID', '') - secret = getattr(settings, 'WEIXIN_SECRET', '') - if not appid or not secret: - logger.error("微信小程序 AppID 或 Secret 未配置") - return None - - url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}' - try: - resp = requests.get(url, timeout=5) - data = resp.json() - token = data.get('access_token') - expires_in = data.get('expires_in', 7200) - if token: - # 提前200秒过期,防止边界情况 - cache.set(cache_key, token, expires_in - 200) - return token - except Exception as e: - logger.error(f"获取微信 access_token 异常: {e}") - return None - From 65dae4f3abf10a322db35f599df7eab480686f09 Mon Sep 17 00:00:00 2001 From: XingQue Date: Fri, 10 Jul 2026 00:04:21 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E5=BA=97=E9=93=BA=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81=E4=B8=8B=E8=BD=BD=E6=8B=92=E7=BB=9D=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=A4=A7=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 超过2MB的OSS对象视为路径错误,提示重新生成而非原样返回。 Co-authored-by: Cursor --- shop/views/shop_base_views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shop/views/shop_base_views.py b/shop/views/shop_base_views.py index 8d458f3..ad3acd8 100644 --- a/shop/views/shop_base_views.py +++ b/shop/views/shop_base_views.py @@ -499,6 +499,14 @@ class QrcodeDownloadView(APIView): if not image_data: return Response({'code': 404, 'msg': '二维码文件不存在'}, status=404) + # 店铺二维码正常仅数十 KB,超过 2MB 视为 OSS 路径异常 + if len(image_data) > 2 * 1024 * 1024: + logger.error( + '店铺二维码文件体积异常 dianpu=%s key=%s size=%s', + dianpu.id, oss_key, len(image_data), + ) + return Response({'code': 500, 'msg': '二维码文件异常,请重新生成'}, status=500) + # 4. 返回图片(inline 供页面展示,attachment 供下载) filename = f"shop_qrcode_{dianpu.id}.png" disposition_type = 'inline' if request.GET.get('disposition') == 'inline' else 'attachment' From 390202b9e45d65b3e1c62e35e2d08e5458813a28 Mon Sep 17 00:00:00 2001 From: XingQue Date: Fri, 10 Jul 2026 00:12:34 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E8=B5=84=E6=96=99=E9=A1=B5=E4=BB=85?= =?UTF-8?q?=E6=94=B9=E5=A4=B4=E5=83=8F=E4=B8=8D=E5=86=8D=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E4=B8=BA=E6=89=8B=E6=9C=BA=E5=8F=B7=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手机号认证流需同时带头像与 shoujihao_code 且无昵称;普通编辑只上传头像可正常保存。 Co-authored-by: Cursor --- users/views/user_info.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/users/views/user_info.py b/users/views/user_info.py index 6ebfa35..f66f736 100644 --- a/users/views/user_info.py +++ b/users/views/user_info.py @@ -221,8 +221,8 @@ class UserInfoUpdateView(APIView): else: code = str(code or '').strip() - # 认证页仅上传头像+手机号,不带昵称 - is_phone_auth_flow = bool(av) and not nick + # 手机号强制认证页:同时带头像 + shoujihao_code,且不带 nicheng(资料编辑页会带昵称) + is_phone_auth_flow = bool(av) and bool(code) and not nick phone_error = None if is_phone_auth_flow and not code: From 2a99e2ea103f8dc8fd47814accfb611afd3c3f7e Mon Sep 17 00:00:00 2001 From: XingQue Date: Fri, 10 Jul 2026 01:45:50 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=BC=9A=E5=91=98=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E5=85=85=E5=80=BC=E7=A7=AF=E5=88=86=E5=8A=A0=E6=BB=A110?= =?UTF-8?q?=E5=88=86=EF=BC=8C=E5=90=8E=E7=BB=AD=E5=85=85=E5=80=BC=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8A=A0=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 体验会员原仅加5分且正式会员会重复加分;改为首次充值会员(任意类型)补满至10分,此后任意会员充值不再增加积分。 Co-authored-by: Cursor --- jituan/services/member_recharge.py | 34 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/jituan/services/member_recharge.py b/jituan/services/member_recharge.py index e244251..18f657b 100644 --- a/jituan/services/member_recharge.py +++ b/jituan/services/member_recharge.py @@ -6,7 +6,7 @@ - formal_cishu=1:duoci_fenhong 或 club 默认正式分成。 - formal_cishu>=2:仅 duoci_fenhong;无配置则不分。 - 体验单:固定 trial_guanshifc / trial_zuzhangfc;终身仅可购 1 次。 -- 积分:体验 +5,正式 +5(体验后)或直接至 10(未买过体验),封顶 10。 +- 积分:首次充值会员(体验/正式均可)加满至 10;此后任意会员充值不再加分。 - 履约(回调/轮询/余额)均二次校验体验资格;分红失败回滚事务。 财务:每笔成功履约必须 czjilu.zhuangtai=3 + 收支入账(record_wechat_income_once)。 @@ -50,8 +50,6 @@ MEMBER_FAILED_STATUS = 8 MEMBER_CANCELLED_STATUS = 2 ENTITLEMENT_REVOKED_MARKER = '权益已撤销' MEMBER_JIFEN_CAP = 10 -MEMBER_JIFEN_TRIAL_GRANT = 5 -MEMBER_JIFEN_FORMAL_GRANT = 5 class MemberRechargeError(Exception): @@ -145,6 +143,19 @@ def has_formal_member_purchase(yonghuid, huiyuan_id, club_id, exclude_dingdan_id return False +def has_any_prior_paid_member_recharge(yonghuid, exclude_dingdan_id=None): + """是否已有成功支付的会员充值单(任意会员类型;不含后台赠送 jine=0)。""" + qs = Czjilu.query.filter( + yonghuid=yonghuid, + leixing=MEMBER_LEIXING, + zhuangtai=MEMBER_PAID_STATUS, + jine__gt=0, + ) + if exclude_dingdan_id: + qs = qs.exclude(dingdan_id=exclude_dingdan_id) + return qs.exists() + + def _sync_trial_usage_flags(yonghuid, huiyuan_id, club_id): """根据 czjilu 历史回填 huiyuangoumai.has_used_trial。""" goumai = Huiyuangoumai.query.filter( @@ -581,7 +592,7 @@ def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=No def _apply_member_purchase_jifen(yonghuid, is_trial, huiyuan_id, club_id, current_dingdan_id=None): """ - 会员购积分:体验 +5,正式 +5(体验后)或直接到 10(未买过体验),封顶 10。 + 会员购积分:首次充值会员(体验/正式/任意类型)加满至 10;此后不再加分。 """ try: dashou = UserDashou.query.get(user__UserUID=yonghuid) @@ -593,17 +604,14 @@ def _apply_member_purchase_jifen(yonghuid, is_trial, huiyuan_id, club_id, curren if current >= MEMBER_JIFEN_CAP: return current - if is_trial: - delta = min(MEMBER_JIFEN_TRIAL_GRANT, MEMBER_JIFEN_CAP - current) - else: - had_trial = has_paid_trial_purchase( - yonghuid, huiyuan_id, club_id, exclude_dingdan_id=current_dingdan_id, + if has_any_prior_paid_member_recharge(yonghuid, exclude_dingdan_id=current_dingdan_id): + logger.info( + '会员购积分跳过(非首次充值) yonghu=%s is_trial=%s huiyuan=%s jifen=%s', + yonghuid, is_trial, huiyuan_id, current, ) - if had_trial: - delta = min(MEMBER_JIFEN_FORMAL_GRANT, MEMBER_JIFEN_CAP - current) - else: - delta = MEMBER_JIFEN_CAP - current + return current + delta = MEMBER_JIFEN_CAP - current if delta <= 0: return current