diff --git a/config/migrations/0010_club_miniapp_icon_pindao.py b/config/migrations/0010_club_miniapp_icon_pindao.py new file mode 100644 index 0000000..f65a39e --- /dev/null +++ b/config/migrations/0010_club_miniapp_icon_pindao.py @@ -0,0 +1,67 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0009_gonggao_copy_pages'), + ] + + operations = [ + migrations.CreateModel( + name='ClubMiniappIcon', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID')), + ('icon_key', models.CharField(db_index=True, max_length=64, verbose_name='图标键')), + ('label', models.CharField(blank=True, default='', max_length=100, verbose_name='展示名称')), + ('image_path', models.CharField(blank=True, default='', max_length=500, verbose_name='图片相对路径')), + ('description', models.CharField(blank=True, default='', max_length=300, verbose_name='说明')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '小程序图标', + 'verbose_name_plural': '小程序图标', + 'db_table': 'club_miniapp_icon', + 'ordering': ['icon_key'], + 'unique_together': {('club_id', 'icon_key')}, + }, + ), + migrations.CreateModel( + name='ClubPindao', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(default='xq', max_length=16, unique=True, verbose_name='俱乐部ID')), + ('channel_no', models.CharField(blank=True, default='', max_length=100, verbose_name='频道号')), + ('title', models.CharField(blank=True, default='频道', max_length=100, verbose_name='标题')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '俱乐部频道', + 'verbose_name_plural': '俱乐部频道', + 'db_table': 'club_pindao', + }, + ), + migrations.CreateModel( + name='ClubPindaoImage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID')), + ('image_path', models.CharField(max_length=500, verbose_name='图片相对路径')), + ('sort_order', models.PositiveIntegerField(default=0, verbose_name='排序')), + ('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('pindao', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='images', to='config.clubpindao')), + ], + options={ + 'verbose_name': '频道图片', + 'verbose_name_plural': '频道图片', + 'db_table': 'club_pindao_image', + 'ordering': ['sort_order', 'id'], + }, + ), + ] diff --git a/config/models.py b/config/models.py index d77d12c..c492119 100644 --- a/config/models.py +++ b/config/models.py @@ -459,3 +459,63 @@ class PopupImage(QModel): def __str__(self): return f'{self.popup_config.popup_id} - 图片{self.sort_order}' + + +class ClubMiniappIcon(QModel): + """小程序功能图标(按俱乐部,固定 icon_key + 存储桶相对路径)。""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') + icon_key = models.CharField(max_length=64, db_index=True, verbose_name='图标键') + label = models.CharField(max_length=100, blank=True, default='', verbose_name='展示名称') + image_path = models.CharField(max_length=500, blank=True, default='', verbose_name='图片相对路径') + description = models.CharField(max_length=300, blank=True, default='', verbose_name='说明') + is_active = models.BooleanField(default=True, verbose_name='是否启用') + CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'club_miniapp_icon' + verbose_name = '小程序图标' + verbose_name_plural = verbose_name + unique_together = [['club_id', 'icon_key']] + ordering = ['icon_key'] + + def __str__(self): + return f'{self.club_id}:{self.icon_key}' + + +class ClubPindao(QModel): + """俱乐部频道配置(每俱乐部一条)。""" + club_id = models.CharField(max_length=16, default='xq', unique=True, verbose_name='俱乐部ID') + channel_no = models.CharField(max_length=100, blank=True, default='', verbose_name='频道号') + title = models.CharField(max_length=100, blank=True, default='频道', verbose_name='标题') + is_active = models.BooleanField(default=True, verbose_name='是否启用') + CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'club_pindao' + verbose_name = '俱乐部频道' + verbose_name_plural = verbose_name + + def __str__(self): + return f'{self.club_id} 频道' + + +class ClubPindaoImage(QModel): + """频道展示图片(多张)。""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') + pindao = models.ForeignKey( + ClubPindao, on_delete=models.CASCADE, related_name='images', null=True, blank=True, + ) + image_path = models.CharField(max_length=500, verbose_name='图片相对路径') + sort_order = models.PositiveIntegerField(default=0, verbose_name='排序') + CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + + class Meta: + db_table = 'club_pindao_image' + verbose_name = '频道图片' + verbose_name_plural = verbose_name + ordering = ['sort_order', 'id'] + + def __str__(self): + return f'{self.club_id} 频道图{self.sort_order}' diff --git a/config/views.py b/config/views.py index 5887e84..da97a7e 100644 --- a/config/views.py +++ b/config/views.py @@ -67,6 +67,8 @@ class GetDynamicConfigView(APIView): try: from jituan.services.display_config import get_tupianpeizhi_url + from jituan.services.miniapp_assets import build_miniapp_assets_payload + cos_oss_url = getattr(settings, 'COS_DOMAIN', '') if cos_oss_url and not cos_oss_url.endswith('/'): cos_oss_url += '/' @@ -102,7 +104,8 @@ class GetDynamicConfigView(APIView): "kefu": { "link": kefu_link, "enterpriseId": kefu_enterprise_id - } + }, + "miniappAssets": build_miniapp_assets_payload(request), } } return Response(data) diff --git a/jituan/services/display_config.py b/jituan/services/display_config.py index bb2deb5..45de591 100644 --- a/jituan/services/display_config.py +++ b/jituan/services/display_config.py @@ -182,3 +182,6 @@ def copy_display_config(template_club_id, new_club_id): text=img.text, sort_order=img.sort_order, ) + + from jituan.services.miniapp_assets import copy_miniapp_assets + copy_miniapp_assets(template_club_id, new_club_id) diff --git a/jituan/services/miniapp_assets.py b/jituan/services/miniapp_assets.py new file mode 100644 index 0000000..ee2ffbd --- /dev/null +++ b/jituan/services/miniapp_assets.py @@ -0,0 +1,139 @@ +"""小程序频道与可配置图标(按俱乐部)。""" +from jituan.services.display_config import _display_club_id + +ICON_FOLDER = 'beijing/tubiao/chengxutubiao' + +# icon_key -> 后台展示名(文件名固定为 {icon_key}.png) +MINIAPP_ICON_META = [ + ('merchant_gold_banner', '商家金牌横幅'), + ('merchant_regular_dispatch', '常规派单'), + ('merchant_custom_dispatch', '自定义派单'), + ('merchant_pending_settle', '待清算'), + ('merchant_kefu_key', '客服密钥'), + ('merchant_kefu_list', '客服列表'), + ('merchant_recharge_deposit', '充值保证金'), + ('merchant_recharge_member', '充值会员'), + ('merchant_link_dispatch', '链接派单'), + ('merchant_punish', '处罚记录'), + ('merchant_rank', '商家排行'), + ('merchant_refresh', '商家刷新'), + ('fighter_recharge_member', '打手充值会员'), + ('fighter_recharge_deposit', '打手充值保证金'), + ('mine_pindao', '频道入口'), + ('icon_refresh', '通用刷新'), +] + +MINIAPP_ICON_KEYS = [k for k, _ in MINIAPP_ICON_META] +MINIAPP_ICON_LABELS = dict(MINIAPP_ICON_META) + +# 无 DB 记录时的默认相对路径(与种子脚本一致) +MINIAPP_ICON_DEFAULTS = { + key: f'{ICON_FOLDER}/{key}.png' for key in MINIAPP_ICON_KEYS +} +# 部分历史资源扩展名不同 +MINIAPP_ICON_DEFAULTS['icon_refresh'] = 'beijing/guanshiduan/icon-refresh.png' +MINIAPP_ICON_DEFAULTS['merchant_refresh'] = 'beijing/guanshiduan/icon-refresh.png' +MINIAPP_ICON_DEFAULTS['fighter_recharge_member'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-recharge.jpg' +MINIAPP_ICON_DEFAULTS['fighter_recharge_deposit'] = 'beijing/dashouduan/vip-card-bg.jpg' +MINIAPP_ICON_DEFAULTS['mine_pindao'] = 'beijing/tubiao/grzx_guanzhualong.jpg' + + +def default_icon_path(icon_key: str) -> str: + return MINIAPP_ICON_DEFAULTS.get(icon_key) or f'{ICON_FOLDER}/{icon_key}.png' + + +def get_miniapp_icons_dict(club_id: str) -> dict: + from config.models import ClubMiniappIcon + + rows = ClubMiniappIcon.query.filter(club_id=club_id, is_active=True) + db_map = {r.icon_key: (r.image_path or '').strip() for r in rows if r.icon_key} + result = {} + for key in MINIAPP_ICON_KEYS: + path = db_map.get(key) or default_icon_path(key) + if path: + result[key] = path + return result + + +def get_pindao_payload(club_id: str) -> dict: + from config.models import ClubPindao, ClubPindaoImage + + row = ClubPindao.query.filter(club_id=club_id, is_active=True).first() + if not row: + return {'channel_no': '', 'title': '频道', 'images': []} + images = [ + img.image_path + for img in ClubPindaoImage.query.filter(club_id=club_id).order_by('sort_order', 'id') + if img.image_path + ] + return { + 'channel_no': row.channel_no or '', + 'title': row.title or '频道', + 'images': images, + } + + +def build_miniapp_assets_payload(request) -> dict: + club_id = _display_club_id(request) + return { + 'icons': get_miniapp_icons_dict(club_id), + 'pindao': get_pindao_payload(club_id), + } + + +def seed_miniapp_icons_for_club(club_id: str, overwrite: bool = False) -> int: + from config.models import ClubMiniappIcon + + created = 0 + for key, label in MINIAPP_ICON_META: + path = default_icon_path(key) + row = ClubMiniappIcon.query.filter(club_id=club_id, icon_key=key).first() + if row: + if overwrite: + row.label = label + row.image_path = path + row.is_active = True + row.save(update_fields=['label', 'image_path', 'is_active', 'UpdateTime']) + continue + ClubMiniappIcon.query.create( + club_id=club_id, + icon_key=key, + label=label, + image_path=path, + is_active=True, + ) + created += 1 + return created + + +def copy_miniapp_assets(template_club_id: str, new_club_id: str): + from config.models import ClubMiniappIcon, ClubPindao, ClubPindaoImage + + for row in ClubMiniappIcon.query.filter(club_id=template_club_id): + if ClubMiniappIcon.query.filter(club_id=new_club_id, icon_key=row.icon_key).exists(): + continue + ClubMiniappIcon.query.create( + club_id=new_club_id, + icon_key=row.icon_key, + label=row.label, + image_path=row.image_path, + description=row.description, + is_active=row.is_active, + ) + + src_pindao = ClubPindao.query.filter(club_id=template_club_id).first() + if not src_pindao or ClubPindao.query.filter(club_id=new_club_id).exists(): + return + new_pindao = ClubPindao.query.create( + club_id=new_club_id, + channel_no=src_pindao.channel_no, + title=src_pindao.title, + is_active=src_pindao.is_active, + ) + for img in ClubPindaoImage.query.filter(club_id=template_club_id).order_by('sort_order', 'id'): + ClubPindaoImage.query.create( + club_id=new_club_id, + pindao=new_pindao, + image_path=img.image_path, + sort_order=img.sort_order, + ) diff --git a/jituan/urls.py b/jituan/urls.py index 40b9897..fe55b13 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -26,6 +26,11 @@ from jituan.views_display import ( ClubGonggaoView, ClubLunboListView, ClubLunboManageView, ClubTupianListView, ClubTupianManageView, ) +from jituan.views_miniapp_assets import ( + ClubMiniappIconListView, + ClubMiniappIconManageView, + ClubPindaoManageView, +) from jituan.views import ( ClubAdminAssignmentListView, ClubAuditLogListView, @@ -89,6 +94,9 @@ urlpatterns = [ path('houtai/identity-tag/bind', IdentityTagBindView.as_view(), name='jituan_identity_tag_bind'), path('houtai/tupian-list', ClubTupianListView.as_view(), name='jituan_tupian_list'), path('houtai/tupian-manage', ClubTupianManageView.as_view(), name='jituan_tupian_manage'), + path('houtai/miniapp-icon-list', ClubMiniappIconListView.as_view(), name='jituan_miniapp_icon_list'), + path('houtai/miniapp-icon-manage', ClubMiniappIconManageView.as_view(), name='jituan_miniapp_icon_manage'), + path('houtai/pindao-manage', ClubPindaoManageView.as_view(), name='jituan_pindao_manage'), path('houtai/hthqtcxx', PopupNoticeListAPIView.as_view(), name='jituan_popup_list'), path('houtai/htxgtcxx', PopupNoticeModifyAPIView.as_view(), name='jituan_popup_modify'), path('houtai/htglyhqcfsltj', FaKuanTongJiView.as_view(), name='jituan_penalty_stats'), diff --git a/jituan/views_miniapp_assets.py b/jituan/views_miniapp_assets.py new file mode 100644 index 0000000..ee96929 --- /dev/null +++ b/jituan/views_miniapp_assets.py @@ -0,0 +1,231 @@ +"""小程序图标与频道 — 后台管理 API。""" +import os + +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from backend.utils import verify_kefu_permission +from config.models import ClubMiniappIcon, ClubPindao, ClubPindaoImage +from jituan.services.club_context import resolve_effective_club_id +from jituan.services.display_config import forbid_display_write_in_all_scope, list_response_meta +from jituan.services.miniapp_assets import ( + ICON_FOLDER, + MINIAPP_ICON_LABELS, + MINIAPP_ICON_META, + default_icon_path, + get_miniapp_icons_dict, + get_pindao_payload, +) +from products.views import delete_from_oss, upload_to_oss, validate_image + +POPUP_PERM = '8080a' +PINDAO_FOLDER = 'beijing/pindao' + + +class ClubMiniappIconListView(APIView): + """POST /jituan/houtai/miniapp-icon-list""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理小程序图标'}) + + club_id = resolve_effective_club_id(request, request.user) + icons_db = { + r.icon_key: r for r in ClubMiniappIcon.query.filter(club_id=club_id) + } + items = [] + for key, label in MINIAPP_ICON_META: + row = icons_db.get(key) + items.append({ + 'icon_key': key, + 'label': label, + 'image_path': (row.image_path if row else '') or default_icon_path(key), + 'description': (row.description if row else '') or '', + 'is_active': row.is_active if row else True, + 'id': row.id if row else None, + }) + return Response({ + 'code': 0, + 'data': { + 'icons': items, + 'folder': ICON_FOLDER, + **list_response_meta(request), + }, + }) + + +class ClubMiniappIconManageView(APIView): + """POST /jituan/houtai/miniapp-icon-manage action=upload|save_meta""" + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理小程序图标'}) + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + action = (request.data.get('action') or 'upload').strip() + club_id = resolve_effective_club_id(request, request.user) + icon_key = (request.data.get('icon_key') or '').strip() + if icon_key not in MINIAPP_ICON_LABELS: + return Response({'code': 400, 'msg': 'icon_key 无效'}) + + if action == 'save_meta': + description = (request.data.get('description') or '').strip() + row, _ = ClubMiniappIcon.query.get_or_create( + club_id=club_id, + icon_key=icon_key, + defaults={ + 'label': MINIAPP_ICON_LABELS[icon_key], + 'image_path': default_icon_path(icon_key), + }, + ) + row.description = description + row.label = MINIAPP_ICON_LABELS[icon_key] + row.save(update_fields=['description', 'label', 'UpdateTime']) + return Response({'code': 0, 'msg': '保存成功'}) + + file_obj = request.FILES.get('file') + if not file_obj: + return Response({'code': 400, 'msg': '请上传图片'}) + ok, msg = validate_image(file_obj) + if not ok: + return Response({'code': 400, 'msg': 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'{ICON_FOLDER}/{icon_key}{ext}' + + row = ClubMiniappIcon.query.filter(club_id=club_id, icon_key=icon_key).first() + if row and row.image_path and row.image_path != oss_path: + delete_from_oss(row.image_path) + + if not upload_to_oss(file_obj, oss_path): + return Response({'code': 500, 'msg': '图片上传失败'}) + + if row: + row.image_path = oss_path + row.label = MINIAPP_ICON_LABELS[icon_key] + row.is_active = True + row.save(update_fields=['image_path', 'label', 'is_active', 'UpdateTime']) + else: + ClubMiniappIcon.query.create( + club_id=club_id, + icon_key=icon_key, + label=MINIAPP_ICON_LABELS[icon_key], + image_path=oss_path, + is_active=True, + ) + return Response({'code': 0, 'msg': '上传成功', 'data': {'image_path': oss_path}}) + + +class ClubPindaoManageView(APIView): + """POST /jituan/houtai/pindao-manage""" + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理频道'}) + + action = (request.data.get('action') or 'get').strip() + club_id = resolve_effective_club_id(request, request.user) + + if action == 'get': + pindao = get_pindao_payload(club_id) + images = [ + { + 'id': img.id, + 'image_path': img.image_path, + 'sort_order': img.sort_order, + } + for img in ClubPindaoImage.query.filter(club_id=club_id).order_by('sort_order', 'id') + ] + return Response({ + 'code': 0, + 'data': { + 'pindao': pindao, + 'images': images, + 'folder': PINDAO_FOLDER, + **list_response_meta(request), + }, + }) + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + if action == 'save': + channel_no = (request.data.get('channel_no') or '').strip() + title = (request.data.get('title') or '频道').strip() or '频道' + row, _ = ClubPindao.query.get_or_create(club_id=club_id, defaults={'title': title}) + row.channel_no = channel_no + row.title = title + row.is_active = True + row.save(update_fields=['channel_no', 'title', 'is_active', 'UpdateTime']) + return Response({'code': 0, 'msg': '保存成功'}) + + if action == 'add_image': + file_obj = request.FILES.get('file') + if not file_obj: + return Response({'code': 400, 'msg': '请上传图片'}) + ok, msg = validate_image(file_obj) + if not ok: + return Response({'code': 400, 'msg': msg}) + ext = os.path.splitext(file_obj.name)[1].lower() or '.jpg' + import random + import string + rnd = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) + oss_path = f'{PINDAO_FOLDER}/{club_id}_{rnd}{ext}' + if not upload_to_oss(file_obj, oss_path): + return Response({'code': 500, 'msg': '图片上传失败'}) + pindao_row, _ = ClubPindao.query.get_or_create(club_id=club_id, defaults={'title': '频道'}) + max_sort = ClubPindaoImage.query.filter(club_id=club_id).order_by('-sort_order').first() + sort_order = (max_sort.sort_order + 1) if max_sort else 0 + img = ClubPindaoImage.query.create( + club_id=club_id, + pindao=pindao_row, + image_path=oss_path, + sort_order=sort_order, + ) + return Response({'code': 0, 'msg': '上传成功', 'data': {'id': img.id, 'image_path': oss_path}}) + + if action == 'delete_image': + img_id = request.data.get('id') + if not img_id: + return Response({'code': 400, 'msg': '缺少图片 id'}) + img = ClubPindaoImage.query.filter(club_id=club_id, id=img_id).first() + if not img: + return Response({'code': 404, 'msg': '图片不存在'}) + if img.image_path: + delete_from_oss(img.image_path) + img.delete() + return Response({'code': 0, 'msg': '删除成功'}) + + return Response({'code': 400, 'msg': 'action 无效'}) diff --git a/scripts/seed_miniapp_icons.py b/scripts/seed_miniapp_icons.py new file mode 100644 index 0000000..20819ba --- /dev/null +++ b/scripts/seed_miniapp_icons.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +"""预置各俱乐部小程序图标配置(仅写 DB 路径,不上传文件)。""" +import os +import sys +import django + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, BASE_DIR) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'a_long_dianjing.settings') +django.setup() + +from jituan.models import Club +from jituan.services.miniapp_assets import seed_miniapp_icons_for_club + + +def main(): + clubs = Club.query.filter(status=1).values_list('club_id', flat=True) + club_ids = list(clubs) or ['xq', 'xzj'] + total = 0 + for cid in club_ids: + n = seed_miniapp_icons_for_club(cid, overwrite=False) + total += n + print(f'俱乐部 {cid}: 新增 {n} 条图标配置') + print(f'完成,共新增 {total} 条') + + +if __name__ == '__main__': + main()