Compare commits
214 Commits
2a99e2ea10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8ced78ca | ||
|
|
3ba3536b05 | ||
|
|
62f121f438 | ||
|
|
1cc3f07480 | ||
|
|
b5c719e7c1 | ||
|
|
09f05605ff | ||
|
|
9744e800eb | ||
|
|
c8a3d0274d | ||
|
|
bb74140150 | ||
|
|
6f4e458871 | ||
|
|
9df450b278 | ||
|
|
7cda8ccbe1 | ||
|
|
3a9b275d0a | ||
|
|
148a35f04b | ||
|
|
531639731a | ||
|
|
ae8a2bb993 | ||
|
|
e2bfed75fa | ||
|
|
fd2163f96a | ||
|
|
e6b1d29b11 | ||
|
|
af513738a5 | ||
|
|
c3a0143eff | ||
|
|
52e714b9b2 | ||
|
|
559e903a0c | ||
|
|
ad760a9b42 | ||
|
|
fa746448cc | ||
|
|
589a653a94 | ||
|
|
da62ea8be9 | ||
|
|
07ae350d55 | ||
|
|
2f684a0203 | ||
|
|
2ab97b0579 | ||
|
|
5f3a5f9e08 | ||
|
|
b84bdc5a76 | ||
|
|
e8de4b2cf9 | ||
|
|
47f69dc8ab | ||
|
|
b324e3ee0f | ||
|
|
bd1284825c | ||
|
|
7dc1dd6f04 | ||
|
|
2b6d91fc4a | ||
|
|
df89c82731 | ||
|
|
9346123e8c | ||
|
|
36e1bf902a | ||
|
|
8e7481e25d | ||
|
|
0ebe5b4872 | ||
|
|
568dcb720e | ||
|
|
e91bf6c982 | ||
|
|
4414b74119 | ||
|
|
bc20fd85e0 | ||
|
|
5ded53bca8 | ||
|
|
5cfb4a1b2b | ||
|
|
2ff5df09bc | ||
|
|
1c67f98866 | ||
|
|
75fb69e6da | ||
|
|
955b76cbea | ||
|
|
17b38d07a9 | ||
|
|
9b9203587c | ||
|
|
8e71cd4243 | ||
|
|
b5506f5361 | ||
|
|
3c02b9477d | ||
|
|
e3f9654002 | ||
|
|
5e38a3d49a | ||
|
|
3fe24cbcce | ||
|
|
871144b53b | ||
|
|
7d366e8075 | ||
|
|
c5ceacf3d3 | ||
|
|
caef4bf56c | ||
|
|
96f1a80395 | ||
|
|
69e1ca0e1a | ||
|
|
fcb2f5364e | ||
|
|
69b16848c4 | ||
|
|
bf4fd468e8 | ||
|
|
5b0db2c8fd | ||
|
|
40c489de39 | ||
|
|
1ae4796e1a | ||
|
|
842cfbe5e1 | ||
|
|
f59ae2416f | ||
|
|
454f5abe90 | ||
|
|
193a82930e | ||
|
|
7002da6093 | ||
|
|
e9e5ee85e2 | ||
|
|
1b733d21cc | ||
|
|
9ac0a3a5ad | ||
|
|
0e102fbcba | ||
|
|
97040c552e | ||
|
|
332fde50a3 | ||
|
|
4799282579 | ||
|
|
890e465e40 | ||
|
|
fa6e679cbe | ||
|
|
6c96eb3bcb | ||
|
|
a505a1ab2d | ||
|
|
3194a47f52 | ||
|
|
6a1895e566 | ||
|
|
d40a27a5ca | ||
|
|
79c86ec373 | ||
|
|
49e07c2673 | ||
|
|
231e5162b0 | ||
|
|
1234cff035 | ||
|
|
7cb41bf4d7 | ||
|
|
6443336bd9 | ||
|
|
98b4526efa | ||
|
|
c870e7e6e9 | ||
|
|
30c2dff040 | ||
|
|
280d8742c9 | ||
|
|
332c42c85d | ||
|
|
ccd0149897 | ||
|
|
0f7e5c503c | ||
|
|
3c017e2ad4 | ||
|
|
fb5a1707db | ||
|
|
ff3d1e2ed3 | ||
|
|
78850a5be8 | ||
|
|
da4514eeda | ||
|
|
59336424e3 | ||
|
|
47939042e6 | ||
|
|
12d01614e9 | ||
|
|
d771add6e8 | ||
|
|
2a2ed25f0c | ||
|
|
e8e98e2970 | ||
|
|
4b5130c824 | ||
|
|
dcb191ac3a | ||
|
|
d2d8e37080 | ||
|
|
0d9139d5cc | ||
|
|
34344fa5b2 | ||
|
|
b41f2b3095 | ||
|
|
de88e55378 | ||
|
|
cddd231dc2 | ||
|
|
46d64fbf92 | ||
|
|
6cb3dff949 | ||
|
|
c25ad40630 | ||
|
|
3313da5ce2 | ||
|
|
f2a9c1abc5 | ||
|
|
44f13e3310 | ||
|
|
d7f40409d6 | ||
|
|
97e9093aa5 | ||
|
|
c37d66745b | ||
|
|
65db2772d8 | ||
|
|
4826a3ca23 | ||
|
|
bc29118a6e | ||
|
|
e722c7016d | ||
|
|
93580a6d63 | ||
|
|
9d49c96e64 | ||
|
|
a483236d45 | ||
|
|
ce86b488d9 | ||
|
|
dc4c0ad091 | ||
|
|
43ad221c67 | ||
|
|
6a107e873d | ||
|
|
dacd55bb1e | ||
|
|
b19430213a | ||
|
|
e03d8728ae | ||
|
|
5762f00d40 | ||
|
|
f36db77f6b | ||
|
|
ab3b9f21b5 | ||
|
|
ef49fe3855 | ||
|
|
24cb0b88f7 | ||
|
|
8e82a8457d | ||
|
|
98b58f8058 | ||
|
|
f1db185d03 | ||
|
|
6515568ec0 | ||
|
|
ed627fa6f7 | ||
|
|
435422e716 | ||
|
|
60d8b52693 | ||
|
|
d5b8ff5b8a | ||
|
|
7c6e14db01 | ||
|
|
27862fa22b | ||
|
|
a432a0b9c4 | ||
|
|
eadf9e2503 | ||
|
|
1ab59efddb | ||
|
|
48d2e13037 | ||
|
|
de74865278 | ||
|
|
e7b59feacd | ||
|
|
b6044cd44a | ||
|
|
cb89720260 | ||
|
|
a1a49261a5 | ||
|
|
52009a54a9 | ||
|
|
60fdf8c427 | ||
|
|
118260a710 | ||
|
|
dac6193d5b | ||
|
|
477129de46 | ||
|
|
9c029288ba | ||
|
|
e6e8e718d3 | ||
|
|
ce8bda52d3 | ||
|
|
80cca803f8 | ||
|
|
5556bbdb61 | ||
|
|
fda4ffb628 | ||
|
|
b02b2dffed | ||
|
|
dc6ddee0d7 | ||
|
|
6689b7af00 | ||
|
|
73f0ce36f2 | ||
|
|
48eca8e5d0 | ||
|
|
6ac32674a5 | ||
|
|
b5021ee07d | ||
|
|
2e419b5b6e | ||
|
|
a28dafa6b7 | ||
|
|
ca67dfa863 | ||
|
|
65a40b5f09 | ||
|
|
d6237ccc7b | ||
|
|
8bc0afb7f4 | ||
|
|
921941a36d | ||
|
|
e1a3c07cef | ||
|
|
49ad57bbb2 | ||
|
|
017d176790 | ||
|
|
5d3e4e32a2 | ||
|
|
f601541eb0 | ||
|
|
df416b8284 | ||
|
|
3660fa4cc7 | ||
|
|
e0fa112da6 | ||
|
|
42963a9ace | ||
| 182f18da91 | |||
|
|
4bda89d7c3 | ||
|
|
5e758a8a46 | ||
| dcdcb7559e | |||
| 5eebbfbff1 | |||
|
|
4d1677a660 | ||
|
|
a3cf7803d8 | ||
| e123510185 | |||
| 4652f3394a |
@@ -1,120 +1,61 @@
|
||||
"""
|
||||
阿龙电竞 - Celery 定时任务主配置
|
||||
|
||||
业务约定(2026-07):Beat 只跑两条
|
||||
1) 资金冻结到期解冻
|
||||
2) 可控订单自动结算
|
||||
其余榜单/清零类任务保留代码但不进 beat_schedule,不会定时执行。
|
||||
"""
|
||||
import os
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
# 在 Django Shell 中执行
|
||||
|
||||
|
||||
# 设置Django默认设置模块
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'a_long_dianjing.settings')
|
||||
|
||||
# 创建Celery应用实例
|
||||
app = Celery('a_long_dianjing')
|
||||
# 从Django settings中加载Celery配置(CELERY_前缀)
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
# 自动发现所有已注册app中的tasks.py文件
|
||||
#app.autodiscover_tasks()
|
||||
# 自动发现任务(不含 orders.tasks:订单超时自动结算已永久关闭)
|
||||
app.autodiscover_tasks(['users.tasks', 'users.ranking_tasks', 'config.tasks'])
|
||||
|
||||
# 配置周期性任务(Celery Beat Schedule)
|
||||
# 仅自动发现仍可能被调用的任务模块;旧超时结算 orders.tasks 永不加载
|
||||
app.autodiscover_tasks(['jituan.tasks', 'users.tasks', 'users.ranking_tasks', 'config.tasks'])
|
||||
|
||||
# ========== Beat:只保留这两个 ==========
|
||||
app.conf.beat_schedule = {
|
||||
# 月榜数据转移(每月最后一天23:50执行)
|
||||
'yuebang_zhuanyi': {
|
||||
'task': 'users.ranking_tasks.zhuanyi_yuebang',
|
||||
'schedule': crontab(hour=23, minute=50, day_of_month='28-31'), # ✅ 修改这里
|
||||
'options': {'queue': 'periodic_tasks', 'priority': 7},
|
||||
},
|
||||
|
||||
# 日榜数据转移(每天23:55执行,在清零前)
|
||||
'ribang_zhuanyi': {
|
||||
'task': 'users.ranking_tasks.zhuanyi_ribang',
|
||||
#'schedule': crontab(minute='*/1'), # 每分钟执行一次,测试用
|
||||
'schedule': crontab(hour=23, minute=55), # 每天23:55
|
||||
'options': {'queue': 'periodic_tasks', 'priority': 7}, # 优先级高于清零任务
|
||||
},
|
||||
|
||||
# 清理旧历史数据(每月1号凌晨1点执行)
|
||||
'qingli_jiulishuju': {
|
||||
'task': 'users.ranking_tasks.qingli_jiulishuju',
|
||||
'schedule': crontab(hour=1, minute=0, day_of_month=1), # 每月1号凌晨1点
|
||||
'options': {'queue': 'periodic_tasks', 'priority': 4},
|
||||
},
|
||||
|
||||
# 补偿检查任务(订单自动结算已禁用)
|
||||
# 'check_order_expire_task': {
|
||||
# 'task': 'orders.tasks.check_order_expire_task',
|
||||
# 'schedule': crontab(minute='*/5000'),
|
||||
# 'options': {'queue': 'order_tasks', 'priority': 3},
|
||||
# },
|
||||
|
||||
# 1. 每日凌晨0点执行 - 清零任务
|
||||
'daily_reset_task': {
|
||||
'task': 'users.tasks.daily_reset_task',
|
||||
'schedule': crontab(hour=0, minute=0), # 每天0点
|
||||
# 资金冻结到期解冻(俱乐部未开冻结 / 无到期单 → 空跑)
|
||||
'scan_fund_freeze_due': {
|
||||
'task': 'jituan.tasks.scan_fund_freeze_due',
|
||||
'schedule': crontab(minute='*/5'),
|
||||
'options': {
|
||||
'queue': 'periodic_tasks',
|
||||
'priority': 5
|
||||
'priority': 4,
|
||||
},
|
||||
'args': (),
|
||||
'kwargs': {}
|
||||
},
|
||||
|
||||
# 2. 每月1日凌晨0点执行 - 月度清零任务
|
||||
'monthly_reset_task': {
|
||||
'task': 'users.tasks.monthly_reset_task',
|
||||
'schedule': crontab(hour=0, minute=0, day_of_month=1), # 每月1日0点
|
||||
# 可控自动结算(俱乐部后台开关关 → 空跑;只处理 AutoSettleEligible 新单)
|
||||
'scan_order_auto_settle_due': {
|
||||
'task': 'jituan.tasks.scan_order_auto_settle_due',
|
||||
'schedule': crontab(minute='*/5'),
|
||||
'options': {
|
||||
'queue': 'periodic_tasks',
|
||||
'priority': 5
|
||||
'priority': 4,
|
||||
},
|
||||
'args': (),
|
||||
'kwargs': {}
|
||||
},
|
||||
|
||||
# 4. 每天凌晨0点5分清理收支记录
|
||||
'daily_sz_reset_task': {
|
||||
'task': 'config.tasks.daily_sz_reset_task',
|
||||
'schedule': crontab(hour=0, minute=5), # 每天0点5分
|
||||
'options': {
|
||||
'queue': 'periodic_tasks',
|
||||
'priority': 4
|
||||
},
|
||||
'args': (),
|
||||
'kwargs': {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 时区设置
|
||||
app.conf.timezone = 'Asia/Shanghai'
|
||||
app.conf.enable_utc = True
|
||||
|
||||
# 队列路由配置(orders.tasks 已移除:禁止 order_tasks 队列再调度超时结算)
|
||||
app.conf.task_routes = {
|
||||
# 'orders.tasks.*': {'queue': 'order_tasks'},
|
||||
'jituan.tasks.*': {'queue': 'periodic_tasks'},
|
||||
'users.tasks.*': {'queue': 'periodic_tasks'},
|
||||
'config.tasks.*': {'queue': 'periodic_tasks'},
|
||||
}
|
||||
|
||||
# 任务序列化
|
||||
app.conf.accept_content = ['json']
|
||||
app.conf.task_serializer = 'json'
|
||||
app.conf.result_serializer = 'json'
|
||||
|
||||
# 任务超时设置
|
||||
app.conf.task_time_limit = 300 # 任务最大执行时间300秒
|
||||
app.conf.task_soft_time_limit = 240 # 软超时240秒
|
||||
|
||||
# Worker并发设置
|
||||
app.conf.task_time_limit = 300
|
||||
app.conf.task_soft_time_limit = 240
|
||||
app.conf.worker_concurrency = 4
|
||||
app.conf.worker_prefetch_multiplier = 1
|
||||
|
||||
# 任务确认设置
|
||||
app.conf.task_acks_late = True
|
||||
app.conf.worker_disable_rate_limits = True
|
||||
|
||||
# 结果过期时间
|
||||
app.conf.result_expires = 3600 # 任务结果保留1小时
|
||||
app.conf.result_expires = 3600
|
||||
|
||||
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
||||
'rank',
|
||||
'merchant_ops',
|
||||
'jituan',
|
||||
'gongdan',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -290,6 +291,12 @@ SHANGJIA_CZ_MIN_AMOUNT = 1
|
||||
SHANGJIA_CZ_MAX_AMOUNT = 10000
|
||||
FAKUAN_PAY_DESCRIPTION = '罚款缴纳'
|
||||
FAKUAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/yonghu/fk_huitiao'
|
||||
ZHUANPAN_PAY_DESCRIPTION = '转盘购票'
|
||||
ZHUANPAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/shangpin/zhuanpanhuitiao'
|
||||
|
||||
# 支付宝 Czjilu 充值统一回调(会员/押金/积分/商家/考核/罚款)
|
||||
# 与订单 alipay-notify 分离;密钥仍用 app_secrets.ALIPAY_PROD,此处只写死回调地址
|
||||
ALIPAY_CZJILU_NOTIFY_URL = 'https://www.abas.asia/hqhd/shangpin/alipay-czjilu-notify/'
|
||||
|
||||
# ==================== 订单ID前缀 ====================
|
||||
ORDER_PREFIX = {
|
||||
@@ -363,6 +370,9 @@ CELERY_TASK_QUEUES = {
|
||||
# ==================== 定时任务时间配置 ====================
|
||||
# 订单超时自动结算(Celery process_expired_order / check_order_expire_task)总开关:False=彻底关闭
|
||||
ORDER_AUTO_SETTLEMENT_ENABLED = False
|
||||
# 打手抢单大厅假单展示:False=仅返回真实订单(后台假单管理仍可用;改 True 可恢复假单池)
|
||||
# 假单功能总闸(紧急关闭用)。实际是否对打手生效由各俱乐部 config_json.fake_grab_order_enabled 控制。
|
||||
FAKE_GRAB_ORDER_POOL_ENABLED = True
|
||||
ORDER_EXPIRE_SECONDS = 48 * 60 * 60 # 48小时 = 172800秒(开关关闭时不生效)
|
||||
DAILY_TASK_HOUR = 0
|
||||
DAILY_TASK_MINUTE = 30
|
||||
@@ -375,11 +385,11 @@ ORDER_CHECK_INTERVAL = 5
|
||||
|
||||
# ==================== 微信支付 V3 ====================
|
||||
WECHAT_PAY_V3_CONFIG = {
|
||||
'MCHID': app_secrets.WECHAT_PAY_V3_MCHID,
|
||||
'APPID': app_secrets.WECHAT_PAY_V3_APPID,
|
||||
'API_V3_KEY': app_secrets.WECHAT_PAY_V3_API_V3_KEY,
|
||||
'MCHID': '1747846572',
|
||||
'APPID': 'wx0e4be86faac4a8d1',
|
||||
'API_V3_KEY': 'dujie4637rufjd98ijuyrgcvbvbn4789',
|
||||
'PRIVATE_KEY_PATH': app_secrets.WECHAT_PAY_V3_PRIVATE_KEY_PATH,
|
||||
'CERT_SERIAL_NO': app_secrets.WECHAT_PAY_V3_CERT_SERIAL_NO,
|
||||
'CERT_SERIAL_NO': '481784CF56E872D9D8122BAC64E6BC7390803582',
|
||||
'PLATFORM_CERT_DIR': app_secrets.WECHAT_PAY_V3_PLATFORM_CERT_DIR,
|
||||
'NOTIFY_URL': 'https://www.abas.asia/hqhd/yonghu/tixiancallback',
|
||||
'TRANSFER_SCENE_ID': '1005',
|
||||
@@ -390,3 +400,6 @@ WECHAT_PAY_V3_CONFIG = {
|
||||
ALIPAY_SANDBOX = app_secrets.ALIPAY_SANDBOX
|
||||
ALIPAY_PROD = app_secrets.ALIPAY_PROD
|
||||
ALIPAY_SANDBOX_CFG = app_secrets.ALIPAY_SANDBOX_CFG
|
||||
# 仅这些 club_id 禁止「发起」支付宝支付;回调/查单仍可用(提现收款不受影响)。
|
||||
# 小溪/小汐 = xx。恢复:改为 frozenset(),再重启 gunicorn。
|
||||
ALIPAY_DISABLED_CLUB_IDS = frozenset({'xx'})
|
||||
|
||||
@@ -14,6 +14,8 @@ urlpatterns = [
|
||||
|
||||
# 订单相关路由
|
||||
path('dingdan/', include('orders.urls')),
|
||||
# 组队
|
||||
path('zudui/', include('orders.urls_zudui')),
|
||||
|
||||
# 商品/陪玩服务相关路由
|
||||
path('shangpin/', include('products.urls')),
|
||||
@@ -25,6 +27,7 @@ urlpatterns = [
|
||||
path('shangdian/', include('shop.urls')),
|
||||
path('dengji/', include('rank.urls')),
|
||||
path('jituan/', include('jituan.urls')),
|
||||
path('gongdan/', include('gongdan.urls')),
|
||||
]
|
||||
|
||||
# 重要说明:
|
||||
|
||||
@@ -4,17 +4,19 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from .view import (GetRolePermissionView,
|
||||
ModifyRolePermissionView, AddRoleView, GetAdminRolesView, GetAdminUserListView, AddAdminUserView,
|
||||
ModifyAdminUserView, KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView, KefuGetShangjiaListView,
|
||||
KefuGetShangjiaDetailView, KefuUpdateShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
|
||||
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateMemberView, GetMemberListView,
|
||||
AddMemberView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, ChangeInviterView, UpdateGuanliView, GetWithdrawSettingsView,
|
||||
KefuGetShangjiaDetailView, KefuUpdateShangjiaView, KefuAddShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
|
||||
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateZhiKanShenheView, UpdateMemberView, GetMemberListView,
|
||||
AddMemberView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, ChangeInviterView, UpdateGuanliView, GuanshiChangeLeaderQuotaView, GuanshiChangeLeaderLogView, GetWithdrawSettingsView,
|
||||
UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView,
|
||||
ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView,
|
||||
ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView, ShopProductModifyView,
|
||||
ShopProductTypeMappingView, ProductListView, ProductModifyView, ShopCopyCatalogView, KefuChatPermissionsView,
|
||||
KefuImUserClubFilterView,
|
||||
FineApplyView, PunishDashouView,
|
||||
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView,
|
||||
HqbkxxView,BkxgView,CaiwuView,CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView,
|
||||
CwqtczhqView, KhpzhqView, ChzsgcView,KhgglView, ShgxgsjView, KhjlglView, KhjlczView, ZxkfghdsView)
|
||||
CwqtczhqView, KhpzhqView, ChzsgcView,KhgglView, ShgxgsjView, KhjlglView, KhjlczView, ZxkfghdsView,
|
||||
DashouGiftHuiyuanSaveView, DashouGiftHuiyuanRemoveView)
|
||||
from config.views import MiniappScriptHoutaiListView, MiniappScriptHoutaiModifyView
|
||||
from rank.reward_houtai_views import RankRewardHoutaiListView, RankRewardHoutaiSaveView, RankRewardHoutaiClaimsView
|
||||
|
||||
@@ -30,9 +32,12 @@ urlpatterns = [
|
||||
path('kefuhqdslb', KefuGetDashouListView.as_view(), name='客服获取打手数据'),
|
||||
path('kefuhqdsxq', KefuGetDashouDetailView.as_view(), name='客服获取打手具体信息'),
|
||||
path('kefuxgds', KefuUpdateDashouView.as_view(), name='客服修改打手数据'),
|
||||
path('dszsonghy', DashouGiftHuiyuanSaveView.as_view(), name='打手赠送会员'),
|
||||
path('dsycsonghy', DashouGiftHuiyuanRemoveView.as_view(), name='移除打手赠送会员'),
|
||||
path('hqsjgllb', KefuGetShangjiaListView.as_view(), name='后台获取商家列表'),
|
||||
path('hthqsjxq', KefuGetShangjiaDetailView.as_view(), name='后台获取商家详情'),
|
||||
path('xgsjxx', KefuUpdateShangjiaView.as_view(), name='后台修改商家数据'),
|
||||
path('tjsj', KefuAddShangjiaView.as_view(), name='后台添加商家'),
|
||||
|
||||
path('htsphqjcsx', GetProductBaseDataView.as_view(), name='后台获取商品类型专区'),
|
||||
path('hthqsplb', GetProductListView.as_view(), name='后台获取商品数据'),
|
||||
@@ -41,6 +46,7 @@ urlpatterns = [
|
||||
path('htscsp', DeleteProductView.as_view(), name='后台删除商品'),
|
||||
path('htsphqxq', GetProductDetailView.as_view(), name='后台获取商品详情'),
|
||||
path('bcspxg', UpdateProductView.as_view(), name='后台更新商品数据'),
|
||||
path('htxgzhikan', UpdateZhiKanShenheView.as_view(), name='后台更新只看审核开关'),
|
||||
|
||||
path('hthqhylb', GetMemberListView.as_view(), name='后台获取会员列表'),
|
||||
path('htxghyxx', UpdateMemberView.as_view(), name='后台更新会员信息'),
|
||||
@@ -50,6 +56,8 @@ urlpatterns = [
|
||||
path('hthqgsxq', GetGuanliDetailView.as_view(), name='后台获取管事详情信息'),
|
||||
path('htgssjzz', PromoteGuanshiToZuzhangView.as_view(), name='后台管事升级组长'),
|
||||
path('htghyqr', ChangeInviterView.as_view(), name='后台更换邀请人'),
|
||||
path('htgs_huan_zz_quota', GuanshiChangeLeaderQuotaView.as_view(), name='后台管事换组长次数'),
|
||||
path('htgs_huan_zz_logs', GuanshiChangeLeaderLogView.as_view(), name='后台管事换组长记录'),
|
||||
path('htxggsxx', UpdateGuanliView.as_view(), name='后台更新管事数据'),
|
||||
path('hqczrz', GetOperationLogListView.as_view(), name='后台操作日志列表'),
|
||||
path('hthqtxpz', GetWithdrawSettingsView.as_view(), name='后台获取提现配置'),
|
||||
@@ -85,6 +93,7 @@ urlpatterns = [
|
||||
path('htfzdpspsj', ShopCopyCatalogView.as_view(), name='后台复制店铺商品目录'),
|
||||
|
||||
path('kfhqltqx', KefuChatPermissionsView.as_view(), name='后台获取客服聊天配置'),
|
||||
path('kfimssclub', KefuImUserClubFilterView.as_view(), name='客服聊天按俱乐部过滤客户ID'),
|
||||
|
||||
|
||||
path('htglyhqcfsltj', FaKuanTongJiView.as_view(),name='后台罚单统计图片'), # 罚款统计
|
||||
|
||||
@@ -85,9 +85,39 @@ def has_perm_code(permissions, *codes):
|
||||
return any(c in perms for c in codes)
|
||||
|
||||
|
||||
def has_club_super_perm(permissions):
|
||||
"""本店超级管理 000001(含系统超管 _AllPermissions)。"""
|
||||
if isinstance(permissions, _AllPermissions):
|
||||
return True
|
||||
return '000001' in (permissions or [])
|
||||
|
||||
|
||||
# 本店超管可直接使用的核心业务码(不必再绑集团权限)
|
||||
CLUB_SUPER_IMPLIED_PERMS = frozenset({
|
||||
'002ab', # 平台订单
|
||||
'002ac', # 商家订单
|
||||
'003aa', # 跨平台订单(只读/本店范围仍由 club 闸门控制)
|
||||
'005bb', # 提现审核
|
||||
})
|
||||
|
||||
|
||||
def has_perm_code_or_club_super(permissions, *codes):
|
||||
"""指定业务码,或本店超管 000001(对核心业务码生效)。"""
|
||||
if has_perm_code(permissions, *codes):
|
||||
return True
|
||||
if has_club_super_perm(permissions) and any(c in CLUB_SUPER_IMPLIED_PERMS for c in codes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_merchant_order_permission(permissions):
|
||||
"""商家/链接订单处理权限:002ac 商家订单、002ab 平台订单、003aa 跨平台。"""
|
||||
return has_perm_code(permissions, '002ac', '002ab', '003aa')
|
||||
"""商家/链接订单处理权限:002ac 商家订单、002ab 平台订单、003aa 跨平台;本店超管 000001 亦可。"""
|
||||
return has_perm_code_or_club_super(permissions, '002ac', '002ab', '003aa')
|
||||
|
||||
|
||||
def has_platform_order_permission(permissions):
|
||||
"""平台订单:002ab 或本店超管 000001。"""
|
||||
return has_perm_code_or_club_super(permissions, '002ab')
|
||||
|
||||
|
||||
def _legacy_backend_role_permissions(phone):
|
||||
@@ -141,25 +171,46 @@ def verify_kefu_permission(request, username_from_frontend=None):
|
||||
|
||||
# ---------- 1. 必须是客服或管理员 ----------
|
||||
from jituan.constants import SUPER_ADMIN_PHONES
|
||||
from jituan.services.admin_context import is_kefu_backend_account
|
||||
is_super_phone = getattr(current_user, 'Phone', '') in SUPER_ADMIN_PHONES
|
||||
from jituan.services.admin_context import (
|
||||
is_kefu_backend_account,
|
||||
is_kefu_account_disabled,
|
||||
is_hardcoded_super_phone,
|
||||
is_system_super_admin,
|
||||
)
|
||||
is_super_phone = is_hardcoded_super_phone(current_user)
|
||||
is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser or is_super_phone
|
||||
|
||||
# 已禁用客服:非白名单即使带 IsSuperuser 也不能继续调接口
|
||||
if is_kefu_account_disabled(current_user):
|
||||
return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403)
|
||||
|
||||
if not is_kefu_backend_account(current_user):
|
||||
return None, Response({'code': 403, 'msg': '身份错误,非客服账号'}, status=403)
|
||||
|
||||
# ---------- 2. 客服扩展表存在且状态正常(管理员跳过) ----------
|
||||
# ---------- 2. 客服扩展表存在且状态正常(仅白名单超管可跳过) ----------
|
||||
kefu = None
|
||||
if not is_admin:
|
||||
if is_admin and is_super_phone:
|
||||
from types import SimpleNamespace
|
||||
try:
|
||||
kefu = current_user.KefuProfile
|
||||
except ObjectDoesNotExist:
|
||||
kefu = SimpleNamespace(user=current_user)
|
||||
elif is_admin:
|
||||
# 非白名单「超管」仍必须客服状态正常
|
||||
try:
|
||||
kefu = current_user.KefuProfile
|
||||
if kefu.zhuangtai != 1:
|
||||
return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403)
|
||||
except ObjectDoesNotExist:
|
||||
from types import SimpleNamespace
|
||||
kefu = SimpleNamespace(user=current_user)
|
||||
else:
|
||||
try:
|
||||
kefu = current_user.KefuProfile
|
||||
if kefu.zhuangtai != 1:
|
||||
return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403)
|
||||
except ObjectDoesNotExist:
|
||||
return None, Response({'code': 403, 'msg': '客服账号不存在'}, status=403)
|
||||
else:
|
||||
# 管理员无需客服扩展表,用占位对象以通过 kefu_obj is None 检查
|
||||
from types import SimpleNamespace
|
||||
kefu = SimpleNamespace(user=current_user)
|
||||
|
||||
# ---------- 3. 越权检测(只记录前端篡改username的情况) ----------
|
||||
if username_from_frontend and current_user.Phone != username_from_frontend:
|
||||
|
||||
@@ -35,11 +35,14 @@ from .views.roles import (
|
||||
from .views.kefu import (
|
||||
KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView,
|
||||
KefuGetShangjiaListView, KefuGetShangjiaDetailView, KefuUpdateShangjiaView,
|
||||
KefuChatPermissionsView,
|
||||
KefuAddShangjiaView,
|
||||
KefuChatPermissionsView, KefuImUserClubFilterView,
|
||||
DashouGiftHuiyuanSaveView, DashouGiftHuiyuanRemoveView,
|
||||
)
|
||||
from .views.products import (
|
||||
GetProductBaseDataView, GetProductListView, SaveProductOrderView,
|
||||
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView,
|
||||
UpdateZhiKanShenheView,
|
||||
)
|
||||
from .views.members import (
|
||||
GetMemberListView, UpdateMemberView, AddMemberView,
|
||||
@@ -48,7 +51,7 @@ from .views.members import (
|
||||
)
|
||||
from .views.guanli import (
|
||||
GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView,
|
||||
UpdateGuanliView,
|
||||
UpdateGuanliView, GuanshiChangeLeaderQuotaView, GuanshiChangeLeaderLogView,
|
||||
)
|
||||
from .views.withdraw_settings import (
|
||||
GetWithdrawSettingsView, UpdateWithdrawSettingsView,
|
||||
@@ -96,18 +99,21 @@ __all__ = [
|
||||
'GetAdminRolesView', 'GetAdminUserListView', 'ModifyAdminUserView', 'AddAdminUserView',
|
||||
# kefu
|
||||
'KefuGetDashouListView', 'KefuGetDashouDetailView', 'KefuUpdateDashouView',
|
||||
'DashouGiftHuiyuanSaveView', 'DashouGiftHuiyuanRemoveView',
|
||||
'KefuGetShangjiaListView', 'KefuGetShangjiaDetailView', 'KefuUpdateShangjiaView',
|
||||
'KefuChatPermissionsView',
|
||||
'KefuAddShangjiaView',
|
||||
'KefuChatPermissionsView', 'KefuImUserClubFilterView',
|
||||
# products
|
||||
'GetProductBaseDataView', 'GetProductListView', 'SaveProductOrderView',
|
||||
'AddProductView', 'DeleteProductView', 'GetProductDetailView', 'UpdateProductView',
|
||||
'UpdateZhiKanShenheView',
|
||||
# members
|
||||
'GetMemberListView', 'UpdateMemberView', 'AddMemberView',
|
||||
'ClubMemberCatalogView', 'ClubMemberEnableView', 'ClubMemberDisableView',
|
||||
'MemberCardImageUploadView', 'MemberRechargeRecordsView', 'MemberBundleCreateView',
|
||||
# guanli
|
||||
'GetGuanliListView', 'GetGuanliDetailView', 'PromoteGuanshiToZuzhangView',
|
||||
'UpdateGuanliView',
|
||||
'UpdateGuanliView', 'GuanshiChangeLeaderQuotaView', 'GuanshiChangeLeaderLogView',
|
||||
# withdraw_settings
|
||||
'GetWithdrawSettingsView', 'UpdateWithdrawSettingsView',
|
||||
# zuzhang
|
||||
|
||||
@@ -387,6 +387,19 @@ class GetGuanliDetailView(APIView):
|
||||
# 注意:首次分红定制只存储有定制记录的会员,没有定制的会员使用默认值(前端通过会员表展示)
|
||||
# 额外次数分红按次数返回
|
||||
|
||||
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
from users.services.change_leader import get_or_create_quota, quota_payload, list_change_logs
|
||||
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
quota_club = get_user_club_id(user)
|
||||
else:
|
||||
quota_club = resolve_club_id_from_request(request)
|
||||
quota = get_or_create_quota(quota_club, yonghuid)
|
||||
change_leader_quota = quota_payload(quota)
|
||||
change_leader_logs = list_change_logs(quota_club, yonghuid, page=1, page_size=20)
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
@@ -398,6 +411,8 @@ class GetGuanliDetailView(APIView):
|
||||
'extra_dividend_map': extra_map,
|
||||
'all_members': all_members,
|
||||
'is_zuzhang': is_zuzhang,
|
||||
'change_leader_quota': change_leader_quota,
|
||||
'change_leader_logs': change_leader_logs,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -636,8 +651,10 @@ class UpdateGuanliView(APIView):
|
||||
if new_yue > old_yue:
|
||||
if '4400c' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限增加余额(需要4400c)'})
|
||||
from jituan.services.group_finance_gate import deny_group_finance_exec
|
||||
deny = deny_group_finance_exec(request.user, request)
|
||||
from jituan.services.group_finance_gate import deny_balance_add_exec
|
||||
deny = deny_balance_add_exec(
|
||||
request, permissions=permissions, target_user=user,
|
||||
)
|
||||
if deny:
|
||||
return deny
|
||||
action_desc = f'增加{new_yue - old_yue}元'
|
||||
@@ -650,6 +667,8 @@ class UpdateGuanliView(APIView):
|
||||
if new_yue != old_yue:
|
||||
guanshi.yue = new_yue
|
||||
guanshi.save()
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
club_id = resolve_club_id_from_request(request) or ''
|
||||
write_xiugai_log(
|
||||
yonghuid=yonghuid,
|
||||
xiugaiid=operator,
|
||||
@@ -658,7 +677,7 @@ class UpdateGuanliView(APIView):
|
||||
guanshiyueq=old_yue,
|
||||
qitashuoming=(
|
||||
f'【后台】修改管事可提现余额:{old_yue}元 → {new_yue}元({action_desc}),'
|
||||
f'管事ID={yonghuid},操作人={operator}'
|
||||
f'管事ID={yonghuid},俱乐部={club_id},操作人={operator}'
|
||||
),
|
||||
)
|
||||
|
||||
@@ -851,7 +870,117 @@ class UpdateGuanliView(APIView):
|
||||
return Response({'code': 0, 'msg': '修改成功'})
|
||||
|
||||
|
||||
|
||||
def _admin_quota_club_id(request, user):
|
||||
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
return get_user_club_id(user)
|
||||
return resolve_club_id_from_request(request)
|
||||
|
||||
|
||||
class GuanshiChangeLeaderQuotaView(APIView):
|
||||
"""
|
||||
后台设置/追加管事换组长次数
|
||||
POST /houtai/htgs_huan_zz_quota
|
||||
参数:username, yonghuid,
|
||||
action=set|add,
|
||||
total_quota(set)或 add_count(add)
|
||||
权限:4400a
|
||||
"""
|
||||
permission_classes = []
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
username = request.data.get('username', '').strip()
|
||||
yonghuid = request.data.get('yonghuid', '').strip()
|
||||
action = (request.data.get('action') or 'add').strip().lower()
|
||||
|
||||
if not username:
|
||||
return Response({'code': 401, 'msg': '缺少username'})
|
||||
if not yonghuid:
|
||||
return Response({'code': 400, 'msg': '缺少用户ID'})
|
||||
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '4400a' not in permissions and '000001' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限修改换组长次数(需要4400a)'})
|
||||
|
||||
try:
|
||||
user = User.query.select_related('GuanshiProfile').get(UserUID=yonghuid)
|
||||
_ = user.GuanshiProfile
|
||||
except User.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '管事不存在'})
|
||||
except UserGuanshi.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '用户不是管事'})
|
||||
|
||||
denied = forbid_if_user_out_of_scope(request, user)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
from users.services.change_leader import set_total_quota, add_quota
|
||||
club_id = _admin_quota_club_id(request, user)
|
||||
if action == 'set':
|
||||
result = set_total_quota(club_id, yonghuid, request.data.get('total_quota'))
|
||||
else:
|
||||
result = add_quota(
|
||||
club_id,
|
||||
yonghuid,
|
||||
request.data.get('add_count') or request.data.get('add'),
|
||||
)
|
||||
if not result.get('ok'):
|
||||
return Response({'code': result.get('code', 400), 'msg': result.get('msg', '失败')})
|
||||
return Response({'code': 0, 'msg': result.get('msg', 'ok'), 'data': result.get('data')})
|
||||
|
||||
|
||||
class GuanshiChangeLeaderLogView(APIView):
|
||||
"""
|
||||
后台查询管事换组长记录
|
||||
POST /houtai/htgs_huan_zz_logs
|
||||
参数:username, yonghuid, page, page_size
|
||||
权限:4400a-f 任一
|
||||
"""
|
||||
permission_classes = []
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
username = request.data.get('username', '').strip()
|
||||
yonghuid = request.data.get('yonghuid', '').strip()
|
||||
|
||||
if not username:
|
||||
return Response({'code': 401, 'msg': '缺少username'})
|
||||
if not yonghuid:
|
||||
return Response({'code': 400, 'msg': '缺少用户ID'})
|
||||
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
required_perms = {'4400a', '4400b', '4400c', '4400d', '4400e', '4400f', '000001'}
|
||||
if not any(p in permissions for p in required_perms):
|
||||
return Response({'code': 403, 'msg': '您没有权限查看换组长记录'})
|
||||
|
||||
try:
|
||||
user = User.query.select_related('GuanshiProfile').get(UserUID=yonghuid)
|
||||
_ = user.GuanshiProfile
|
||||
except User.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '管事不存在'})
|
||||
except UserGuanshi.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '用户不是管事'})
|
||||
|
||||
denied = forbid_if_user_out_of_scope(request, user)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
from users.services.change_leader import list_change_logs
|
||||
club_id = _admin_quota_club_id(request, user)
|
||||
data = list_change_logs(
|
||||
club_id,
|
||||
yonghuid,
|
||||
page=request.data.get('page', 1),
|
||||
page_size=request.data.get('page_size', 20),
|
||||
)
|
||||
return Response({'code': 0, 'data': data})
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import threading
|
||||
import traceback
|
||||
import uuid
|
||||
import hashlib
|
||||
import re
|
||||
import xmltodict
|
||||
import time
|
||||
import logging
|
||||
@@ -52,6 +53,7 @@ from ..utils import (
|
||||
verify_kefu_permission,
|
||||
PERMISSION_TO_SHENFEN,
|
||||
SHENFEN_PROFILE_MAP,
|
||||
has_perm_code,
|
||||
has_fadan_view_permission,
|
||||
has_merchant_order_permission,
|
||||
check_fadan_permission,
|
||||
@@ -74,9 +76,11 @@ from backend.models import WithdrawalDailyStats
|
||||
## users
|
||||
from users.models import (
|
||||
UserGuanshi, UserBoss, UserZuzhang,
|
||||
UserKefu, UserDashou, Xiugaijilu, UserShangjia, UserShenheguan
|
||||
UserKefu, UserDashou, Xiugaijilu, UserShangjia, UserShenheguan,
|
||||
DashouZengsongHuiyuan,
|
||||
)
|
||||
from users.business_models import User
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
|
||||
## orders
|
||||
from orders.models import (
|
||||
@@ -121,90 +125,232 @@ class KefuGetDashouListView(APIView):
|
||||
"""
|
||||
客服获取打手列表接口(仅打手类型)
|
||||
需要拥有以下任一权限:001aa,001bb,001cc,001dd,001ee,001ff,001gg
|
||||
请求:POST /yonghu/kefuhqdslb
|
||||
请求:POST /jituan/houtai/kefuhqdslb
|
||||
参数:
|
||||
username: 客服手机号(用于权限校验)
|
||||
keyword: 搜索关键词(可选,搜索用户ID或昵称)
|
||||
page: 页码(从1开始)
|
||||
page_size: 每页数量
|
||||
返回:
|
||||
code: 0
|
||||
data: {
|
||||
list: [打手对象],
|
||||
has_more: bool,
|
||||
total: int, // 总打手数
|
||||
valid_dashou_count: int // 有效打手数(有有效会员)
|
||||
}
|
||||
keyword: 用户ID / 昵称 / 手机号(可选)
|
||||
zhanghaozhuangtai: 1正常 / 0封禁(可选)
|
||||
has_member: 1有有效会员 / 0无(可选)
|
||||
huiyuan_id: 限定有效会员含该类型(可选)
|
||||
yajin_op + yajin_value: gte/lte/eq + 数值(可选)
|
||||
zaixianzhuangtai / zhuangtai: 在线 / 空闲接单(可选)
|
||||
page / page_size: 分页
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
# 1. 获取参数(前端字段为 username)
|
||||
username = request.data.get('username', '').strip()
|
||||
keyword = request.data.get('keyword', '').strip()
|
||||
username = (request.data.get('username') or '').strip()
|
||||
keyword = (request.data.get('keyword') or '').strip()
|
||||
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
|
||||
yajin_op = (request.data.get('yajin_op') or '').strip().lower()
|
||||
yajin_value_raw = request.data.get('yajin_value', None)
|
||||
try:
|
||||
page = int(request.data.get('page', 1))
|
||||
page_size = int(request.data.get('page_size', 20))
|
||||
except ValueError:
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '分页参数错误'})
|
||||
|
||||
if not username:
|
||||
return Response({'code': 401, 'msg': '认证失败'})
|
||||
|
||||
# 2. 公共权限校验(验证客服身份,获取权限列表)
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions # 直接返回错误响应
|
||||
return permissions
|
||||
|
||||
# 3. 检查是否拥有打手管理相关权限(至少一个)
|
||||
required_perms = {'001aa', '001bb', '001cc', '001dd', '001ee', '001ff', '001gg'}
|
||||
if not any(p in permissions for p in required_perms):
|
||||
required_perms = ('001aa', '001bb', '001cc', '001dd', '001ee', '001ff', '001gg')
|
||||
if not has_perm_code(permissions, *required_perms):
|
||||
return Response({'code': 403, 'msg': '您没有权限查看打手列表'})
|
||||
|
||||
# 4. 构建打手查询集(仅打手类型)
|
||||
dashou_qs = filter_user_related_by_club(
|
||||
UserDashou.query.select_related('user').all(), request,
|
||||
)
|
||||
|
||||
# 关键词筛选(用户ID或昵称)
|
||||
if keyword:
|
||||
dashou_qs = dashou_qs.filter(
|
||||
Q(user__UserUID__icontains=keyword) |
|
||||
Q(nicheng__icontains=keyword)
|
||||
Q(nicheng__icontains=keyword) |
|
||||
Q(user__Phone__icontains=keyword)
|
||||
)
|
||||
|
||||
# 5. 统计打手总数
|
||||
total_dashou = dashou_qs.count()
|
||||
zhanghao_raw = request.data.get('zhanghaozhuangtai', None)
|
||||
if zhanghao_raw is not None and zhanghao_raw != '':
|
||||
try:
|
||||
zhanghao_val = int(zhanghao_raw)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '账号状态格式错误'})
|
||||
if zhanghao_val not in (0, 1):
|
||||
return Response({'code': 400, 'msg': '账号状态值无效'})
|
||||
dashou_qs = dashou_qs.filter(zhanghaozhuangtai=zhanghao_val)
|
||||
|
||||
zaixian_raw = request.data.get('zaixianzhuangtai', None)
|
||||
if zaixian_raw is not None and zaixian_raw != '':
|
||||
try:
|
||||
zaixian_val = int(zaixian_raw)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '在线状态格式错误'})
|
||||
if zaixian_val not in (0, 1):
|
||||
return Response({'code': 400, 'msg': '在线状态值无效'})
|
||||
dashou_qs = dashou_qs.filter(zaixianzhuangtai=zaixian_val)
|
||||
|
||||
zhuangtai_raw = request.data.get('zhuangtai', None)
|
||||
if zhuangtai_raw is not None and zhuangtai_raw != '':
|
||||
try:
|
||||
zhuangtai_val = int(zhuangtai_raw)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '接单状态格式错误'})
|
||||
if zhuangtai_val not in (0, 1):
|
||||
return Response({'code': 400, 'msg': '接单状态值无效'})
|
||||
dashou_qs = dashou_qs.filter(zhuangtai=zhuangtai_val)
|
||||
|
||||
# 6. 统计有效打手数(有会员且未过期)
|
||||
dashou_user_ids = dashou_qs.values_list('user__UserUID', flat=True)
|
||||
now = timezone.now()
|
||||
hy_qs = filter_queryset_by_club(Huiyuangoumai.query.all(), request)
|
||||
valid_dashou_count = hy_qs.filter(
|
||||
yonghu_id__in=dashou_user_ids,
|
||||
huiyuan_zhuangtai=1,
|
||||
# 有效会员:与打手详情 / clumber 一致——daoqi_time > now(未过期)。
|
||||
# 含购买表 Huiyuangoumai + 赠送表 DashouZengsongHuiyuan。
|
||||
# 必须用 Exists(OuterRef),禁止 FluentQuery.values() 丢进 __in:
|
||||
# values() 返回的是字典迭代器,UserUID__in=[{'yonghu_id':...}] 永远匹配不到。
|
||||
bought_any = Huiyuangoumai.objects.filter(
|
||||
yonghu_id=OuterRef('user__UserUID'),
|
||||
daoqi_time__gt=now,
|
||||
).values('yonghu_id').distinct().count()
|
||||
)
|
||||
gift_any = DashouZengsongHuiyuan.objects.filter(
|
||||
yonghu_id=OuterRef('user__UserUID'),
|
||||
daoqi_time__gt=now,
|
||||
)
|
||||
dashou_qs = dashou_qs.annotate(
|
||||
_any_hy=Exists(bought_any),
|
||||
_any_gift=Exists(gift_any),
|
||||
)
|
||||
|
||||
if huiyuan_id:
|
||||
bought_type = Huiyuangoumai.objects.filter(
|
||||
yonghu_id=OuterRef('user__UserUID'),
|
||||
daoqi_time__gt=now,
|
||||
huiyuan_id=huiyuan_id,
|
||||
)
|
||||
gift_type = DashouZengsongHuiyuan.objects.filter(
|
||||
yonghu_id=OuterRef('user__UserUID'),
|
||||
daoqi_time__gt=now,
|
||||
huiyuan_id=huiyuan_id,
|
||||
)
|
||||
dashou_qs = dashou_qs.annotate(
|
||||
_type_hy=Exists(bought_type),
|
||||
_type_gift=Exists(gift_type),
|
||||
)
|
||||
|
||||
has_member_raw = request.data.get('has_member', None)
|
||||
if has_member_raw is not None and has_member_raw != '':
|
||||
try:
|
||||
has_member_val = int(has_member_raw)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '会员筛选格式错误'})
|
||||
if has_member_val == 1:
|
||||
if huiyuan_id:
|
||||
dashou_qs = dashou_qs.filter(Q(_type_hy=True) | Q(_type_gift=True))
|
||||
else:
|
||||
dashou_qs = dashou_qs.filter(Q(_any_hy=True) | Q(_any_gift=True))
|
||||
elif has_member_val == 0:
|
||||
dashou_qs = dashou_qs.filter(_any_hy=False, _any_gift=False)
|
||||
else:
|
||||
return Response({'code': 400, 'msg': '会员筛选值无效'})
|
||||
elif huiyuan_id:
|
||||
dashou_qs = dashou_qs.filter(Q(_type_hy=True) | Q(_type_gift=True))
|
||||
|
||||
# 押金≥阈值人数:在应用押金比较筛之前统计(相对其它已选条件)
|
||||
yajin_gte_count = None
|
||||
yajin_value = None
|
||||
if yajin_value_raw is not None and yajin_value_raw != '':
|
||||
try:
|
||||
yajin_value = Decimal(str(yajin_value_raw))
|
||||
except Exception:
|
||||
return Response({'code': 400, 'msg': '押金数值格式错误'})
|
||||
yajin_gte_count = dashou_qs.filter(yajin__gte=yajin_value).count()
|
||||
op = yajin_op if yajin_op in ('gte', 'lte', 'eq') else 'gte'
|
||||
if op == 'gte':
|
||||
dashou_qs = dashou_qs.filter(yajin__gte=yajin_value)
|
||||
elif op == 'lte':
|
||||
dashou_qs = dashou_qs.filter(yajin__lte=yajin_value)
|
||||
else:
|
||||
dashou_qs = dashou_qs.filter(yajin=yajin_value)
|
||||
|
||||
total_dashou = dashou_qs.count()
|
||||
_status_agg = dashou_qs.aggregate(
|
||||
normal_count=Count('id', filter=Q(zhanghaozhuangtai=1)),
|
||||
banned_count=Count('id', filter=Q(zhanghaozhuangtai=0)),
|
||||
)
|
||||
valid_dashou_count = dashou_qs.filter(Q(_any_hy=True) | Q(_any_gift=True)).count()
|
||||
no_member_count = max(total_dashou - valid_dashou_count, 0)
|
||||
|
||||
# 7. 分页
|
||||
offset = (page - 1) * page_size
|
||||
dashou_list = dashou_qs.order_by('-CreateTime')[offset:offset + page_size]
|
||||
dashou_list = list(dashou_qs.order_by('-CreateTime')[offset:offset + page_size])
|
||||
page_uids = [d.user.UserUID for d in dashou_list if getattr(d, 'user', None)]
|
||||
|
||||
hy_by_uid = {}
|
||||
if page_uids:
|
||||
name_ids = set()
|
||||
bought_rows = list(Huiyuangoumai.objects.filter(
|
||||
yonghu_id__in=page_uids,
|
||||
daoqi_time__gt=now,
|
||||
).values('yonghu_id', 'huiyuan_id', 'jieshao'))
|
||||
gift_rows = list(DashouZengsongHuiyuan.objects.filter(
|
||||
yonghu_id__in=page_uids,
|
||||
daoqi_time__gt=now,
|
||||
).values('yonghu_id', 'huiyuan_id'))
|
||||
for row in bought_rows:
|
||||
name_ids.add(row['huiyuan_id'])
|
||||
for row in gift_rows:
|
||||
name_ids.add(row['huiyuan_id'])
|
||||
name_map = {
|
||||
h['huiyuan_id']: (h['jieshao'] or '')
|
||||
for h in Huiyuan.objects.filter(huiyuan_id__in=name_ids).values('huiyuan_id', 'jieshao')
|
||||
}
|
||||
seen_pair = set()
|
||||
for row in bought_rows:
|
||||
uid = row['yonghu_id']
|
||||
hid = row['huiyuan_id']
|
||||
key = (uid, hid)
|
||||
if key in seen_pair:
|
||||
continue
|
||||
seen_pair.add(key)
|
||||
name = (row.get('jieshao') or '').strip() or name_map.get(hid, '') or hid
|
||||
hy_by_uid.setdefault(uid, []).append({
|
||||
'huiyuan_id': hid,
|
||||
'name': name,
|
||||
})
|
||||
for row in gift_rows:
|
||||
uid = row['yonghu_id']
|
||||
hid = row['huiyuan_id']
|
||||
key = (uid, hid)
|
||||
if key in seen_pair:
|
||||
continue
|
||||
seen_pair.add(key)
|
||||
name = name_map.get(hid, '') or hid
|
||||
hy_by_uid.setdefault(uid, []).append({
|
||||
'huiyuan_id': hid,
|
||||
'name': name,
|
||||
'is_gift': True,
|
||||
})
|
||||
|
||||
# 8. 构建返回数据
|
||||
result = []
|
||||
for dashou in dashou_list:
|
||||
user = dashou.user
|
||||
uid = user.UserUID
|
||||
result.append({
|
||||
'yonghuid': user.UserUID,
|
||||
'yonghuid': uid,
|
||||
'avatar': user.Avatar or '',
|
||||
'phone': user.Phone or '',
|
||||
'club_id': getattr(user, 'ClubID', None) or '',
|
||||
'zaixianzhuangtai': dashou.zaixianzhuangtai,
|
||||
'zhanghaozhuangtai': dashou.zhanghaozhuangtai,
|
||||
'nicheng': dashou.nicheng or '',
|
||||
'zhuangtai': dashou.zhuangtai,
|
||||
'yajin': str(dashou.yajin if dashou.yajin is not None else '0'),
|
||||
'huiyuan_list': hy_by_uid.get(uid, []),
|
||||
})
|
||||
|
||||
has_more = (offset + len(dashou_list)) < total_dashou
|
||||
huiyuan_options = list(
|
||||
Huiyuan.query.all().order_by('huiyuan_id').values('huiyuan_id', 'jieshao')
|
||||
)
|
||||
|
||||
meta = list_response_meta(request)
|
||||
return Response({
|
||||
@@ -216,6 +362,11 @@ class KefuGetDashouListView(APIView):
|
||||
'has_more': has_more,
|
||||
'total': total_dashou,
|
||||
'valid_dashou_count': valid_dashou_count,
|
||||
'no_member_count': no_member_count,
|
||||
'normal_count': _status_agg['normal_count'] or 0,
|
||||
'banned_count': _status_agg['banned_count'] or 0,
|
||||
'yajin_gte_count': yajin_gte_count,
|
||||
'huiyuan_options': huiyuan_options,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -304,6 +455,7 @@ class KefuGetDashouDetailView(APIView):
|
||||
'data': {
|
||||
'user_info': user_info,
|
||||
'member_list': member_list,
|
||||
'gift_member_list': [],
|
||||
'all_members': all_members,
|
||||
'permissions': permissions,
|
||||
}
|
||||
@@ -316,6 +468,7 @@ class KefuGetDashouDetailView(APIView):
|
||||
'zaixianzhuangtai': dashou.zaixianzhuangtai,
|
||||
'zhanghaozhuangtai': dashou.zhanghaozhuangtai,
|
||||
'zhuangtai': dashou.zhuangtai,
|
||||
'fengjin_yuanyin': getattr(dashou, 'fengjin_yuanyin', '') or '',
|
||||
'nicheng': dashou.nicheng or '',
|
||||
'chenghao': dashou.chenghao or '',
|
||||
'dianhua': dashou.dianhua or '',
|
||||
@@ -329,6 +482,9 @@ class KefuGetDashouDetailView(APIView):
|
||||
'tuikuanliang': dashou.tuikuanliang,
|
||||
'jinrijiedan': dashou.jinrijiedan,
|
||||
'yue': float(dashou.yue) if dashou.yue else 0.00,
|
||||
'dongjie_yue': float(getattr(dashou, 'dongjie_yue', 0) or 0),
|
||||
'risk_fine_rate': float(getattr(dashou, 'risk_fine_rate', 0) or 0),
|
||||
'risk_complaint_rate': float(getattr(dashou, 'risk_complaint_rate', 0) or 0),
|
||||
'zonge': float(dashou.zonge) if dashou.zonge else 0.00,
|
||||
'jifen': dashou.jifen,
|
||||
'yajin': float(dashou.yajin) if dashou.yajin else 0.00,
|
||||
@@ -369,11 +525,15 @@ class KefuGetDashouDetailView(APIView):
|
||||
# 7. 查询所有会员类型(用于前端添加会员)
|
||||
all_members = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao'))
|
||||
|
||||
from jituan.services.gift_huiyuan import list_gift_huiyuan_for_admin
|
||||
gift_member_list = list_gift_huiyuan_for_admin(uid)
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'user_info': user_info,
|
||||
'member_list': member_list,
|
||||
'gift_member_list': gift_member_list,
|
||||
'all_members': all_members,
|
||||
'permissions': permissions, # 返回当前客服的权限列表
|
||||
}
|
||||
@@ -459,14 +619,18 @@ class KefuUpdateDashouView(APIView):
|
||||
elif caozuo == 'jia_yue':
|
||||
if '001bb' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限加打手余额'})
|
||||
from jituan.services.group_finance_gate import deny_group_finance_exec
|
||||
deny = deny_group_finance_exec(request.user, request)
|
||||
from jituan.services.group_finance_gate import deny_balance_add_exec
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
deny = deny_balance_add_exec(
|
||||
request, permissions=permissions, target_user=dashou_user,
|
||||
)
|
||||
if deny:
|
||||
return deny
|
||||
amount = Decimal(str(value))
|
||||
dashou_profile.yue += amount
|
||||
dashou_profile.save()
|
||||
after = dashou_profile.yue
|
||||
club_id = resolve_club_id_from_request(request) or ''
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=kefu.user.Phone,
|
||||
@@ -475,7 +639,8 @@ class KefuUpdateDashouView(APIView):
|
||||
xiugaitijiaoq=before['yue'],
|
||||
qitashuoming=(
|
||||
f'【后台】增加打手可提现余额:{before["yue"]}元 → {after}元,'
|
||||
f'增加{amount}元,打手ID={dashou_id},操作人={kefu.user.Phone}'
|
||||
f'增加{amount}元,打手ID={dashou_id},俱乐部={club_id},'
|
||||
f'操作人={kefu.user.Phone}'
|
||||
),
|
||||
)
|
||||
return Response({'code': 0, 'msg': '加余额成功', 'data': {'new_yue': str(after)}})
|
||||
@@ -506,14 +671,18 @@ class KefuUpdateDashouView(APIView):
|
||||
elif caozuo == 'jia_yajin':
|
||||
if '001bb' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限加打手押金'})
|
||||
from jituan.services.group_finance_gate import deny_group_finance_exec
|
||||
deny = deny_group_finance_exec(request.user, request)
|
||||
from jituan.services.group_finance_gate import deny_balance_add_exec
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
deny = deny_balance_add_exec(
|
||||
request, permissions=permissions, target_user=dashou_user,
|
||||
)
|
||||
if deny:
|
||||
return deny
|
||||
amount = Decimal(str(value))
|
||||
dashou_profile.yajin += amount
|
||||
dashou_profile.save()
|
||||
after = dashou_profile.yajin
|
||||
club_id = resolve_club_id_from_request(request) or ''
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=kefu.user.Phone,
|
||||
@@ -522,7 +691,8 @@ class KefuUpdateDashouView(APIView):
|
||||
yyajin=before['yajin'],
|
||||
qitashuoming=(
|
||||
f'【后台】增加打手押金:{before["yajin"]}元 → {after}元,'
|
||||
f'增加{amount}元,打手ID={dashou_id},操作人={kefu.user.Phone}'
|
||||
f'增加{amount}元,打手ID={dashou_id},俱乐部={club_id},'
|
||||
f'操作人={kefu.user.Phone}'
|
||||
),
|
||||
)
|
||||
return Response({'code': 0, 'msg': '加押金成功', 'data': {'new_yajin': str(after)}})
|
||||
@@ -577,20 +747,33 @@ class KefuUpdateDashouView(APIView):
|
||||
new_status = int(value)
|
||||
if new_status not in [0, 1]:
|
||||
return Response({'code': 400, 'msg': '状态值无效(0=封禁,1=正常)'})
|
||||
yuanyin = (request.data.get('yuanyin') or request.data.get('fengjin_yuanyin') or '').strip()
|
||||
dashou_profile.zhanghaozhuangtai = new_status
|
||||
dashou_profile.save()
|
||||
if new_status == 0:
|
||||
dashou_profile.fengjin_yuanyin = yuanyin[:200]
|
||||
else:
|
||||
dashou_profile.fengjin_yuanyin = ''
|
||||
dashou_profile.save(update_fields=['zhanghaozhuangtai', 'fengjin_yuanyin'])
|
||||
status_label = '正常' if new_status == 1 else '封禁'
|
||||
old_label = '正常' if before['zhanghaozhuangtai'] == 1 else '封禁'
|
||||
reason_part = f',原因={yuanyin}' if (new_status == 0 and yuanyin) else ''
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=kefu.user.Phone,
|
||||
leixing=XIUGAI_LEIXING_DASHOU,
|
||||
qitashuoming=(
|
||||
f'【后台】修改打手账号状态:{old_label} → {status_label},'
|
||||
f'【后台】修改打手账号状态:{old_label} → {status_label}{reason_part},'
|
||||
f'打手ID={dashou_id},操作人={kefu.user.Phone}'
|
||||
),
|
||||
)
|
||||
return Response({'code': 0, 'msg': '修改账号状态成功', 'data': {'new_zhanghaozhuangtai': new_status}})
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '修改账号状态成功',
|
||||
'data': {
|
||||
'new_zhanghaozhuangtai': new_status,
|
||||
'fengjin_yuanyin': dashou_profile.fengjin_yuanyin or '',
|
||||
},
|
||||
})
|
||||
|
||||
elif caozuo == 'gai_zaixianzhuangtai':
|
||||
if '001ee' not in permissions:
|
||||
@@ -934,6 +1117,7 @@ class KefuGetShangjiaDetailView(APIView):
|
||||
'jinyuedingdan': shangjia.jinyuedingdan,
|
||||
'jinyueliushui': str(shangjia.jinyueliushui),
|
||||
'is_youzhi_shangjia': bool(shangjia.is_youzhi_shangjia),
|
||||
'is_teyue_shangjia': bool(getattr(shangjia, 'is_teyue_shangjia', False)),
|
||||
'CreateTime': shangjia.CreateTime.isoformat() if shangjia.CreateTime else None,
|
||||
'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '',
|
||||
}
|
||||
@@ -967,7 +1151,7 @@ class KefuUpdateShangjiaView(APIView):
|
||||
return Response({'code': 401, 'msg': '缺少username'})
|
||||
if not yonghuid:
|
||||
return Response({'code': 400, 'msg': '缺少商家ID'})
|
||||
if action not in ('add_balance', 'sub_balance', 'ban', 'unban', 'set_youzhi'):
|
||||
if action not in ('add_balance', 'sub_balance', 'ban', 'unban', 'set_youzhi', 'set_teyue'):
|
||||
return Response({'code': 400, 'msg': 'action参数无效'})
|
||||
|
||||
# 权限校验
|
||||
@@ -976,7 +1160,7 @@ class KefuUpdateShangjiaView(APIView):
|
||||
return permissions
|
||||
|
||||
# 操作权限校验
|
||||
if action in ('add_balance', 'ban', 'unban', 'set_youzhi') and '1100a' not in permissions:
|
||||
if action in ('add_balance', 'ban', 'unban', 'set_youzhi', 'set_teyue') and '1100a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限执行此操作(需要1100a)'})
|
||||
if action == 'sub_balance' and '1100b' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限减少商家余额(需要1100b)'})
|
||||
@@ -990,8 +1174,11 @@ class KefuUpdateShangjiaView(APIView):
|
||||
|
||||
# 增加余额
|
||||
if action == 'add_balance':
|
||||
from jituan.services.group_finance_gate import deny_group_finance_exec
|
||||
deny = deny_group_finance_exec(request.user, request)
|
||||
from jituan.services.group_finance_gate import deny_balance_add_exec
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
deny = deny_balance_add_exec(
|
||||
request, permissions=permissions, target_user=shangjia.user,
|
||||
)
|
||||
if deny:
|
||||
return deny
|
||||
amount_str = request.data.get('amount')
|
||||
@@ -1007,6 +1194,7 @@ class KefuUpdateShangjiaView(APIView):
|
||||
return Response({'code': 400, 'msg': '增加后余额超出上限'})
|
||||
shangjia.yue = new_yue
|
||||
shangjia.save(update_fields=['yue'])
|
||||
club_id = resolve_club_id_from_request(request) or ''
|
||||
write_xiugai_log(
|
||||
yonghuid=yonghuid,
|
||||
xiugaiid=kefu.user.Phone,
|
||||
@@ -1015,7 +1203,8 @@ class KefuUpdateShangjiaView(APIView):
|
||||
shangjiayueq=old_yue,
|
||||
qitashuoming=(
|
||||
f'【后台】增加商家余额:{old_yue}元 → {new_yue}元,增加{amount}元,'
|
||||
f'商家ID={yonghuid},昵称={shangjia.nicheng},操作人={kefu.user.Phone}'
|
||||
f'商家ID={yonghuid},昵称={shangjia.nicheng},俱乐部={club_id},'
|
||||
f'操作人={kefu.user.Phone}'
|
||||
),
|
||||
)
|
||||
return Response({'code': 0, 'msg': '余额增加成功', 'data': {'new_balance': str(shangjia.yue)}})
|
||||
@@ -1102,12 +1291,139 @@ class KefuUpdateShangjiaView(APIView):
|
||||
'data': {'is_youzhi_shangjia': is_youzhi},
|
||||
})
|
||||
|
||||
if action == 'set_teyue':
|
||||
if '1100a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限设置特约商家(需要1100a)'})
|
||||
is_teyue = bool(request.data.get('is_teyue_shangjia', False))
|
||||
shangjia.is_teyue_shangjia = is_teyue
|
||||
shangjia.save(update_fields=['is_teyue_shangjia'])
|
||||
write_xiugai_log(
|
||||
yonghuid=yonghuid,
|
||||
xiugaiid=kefu.user.Phone,
|
||||
leixing=XIUGAI_LEIXING_SHANGJIA,
|
||||
qitashuoming=(
|
||||
f'【后台】设置特约商家(可更换打手):{is_teyue},'
|
||||
f'商家ID={yonghuid},昵称={shangjia.nicheng},操作人={kefu.user.Phone}'
|
||||
),
|
||||
)
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '特约商家设置成功',
|
||||
'data': {'is_teyue_shangjia': is_teyue},
|
||||
})
|
||||
|
||||
return Response({'code': 400, 'msg': '未知操作'})
|
||||
|
||||
|
||||
class KefuAddShangjiaView(APIView):
|
||||
"""
|
||||
后台按用户ID开通商家(分俱乐部,禁止前端自助注册场景使用)
|
||||
POST /houtai/tjsj 或 /jituan/houtai/tjsj
|
||||
参数:username, yonghuid(用户业务ID), nicheng(可选)
|
||||
权限:1100a
|
||||
"""
|
||||
permission_classes = []
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
username = request.data.get('username', '').strip()
|
||||
yonghuid = str(request.data.get('yonghuid', '')).strip()
|
||||
nicheng_in = (request.data.get('nicheng') or '').strip()
|
||||
|
||||
if not username:
|
||||
return Response({'code': 401, 'msg': '缺少username'})
|
||||
if not yonghuid:
|
||||
return Response({'code': 400, 'msg': '请填写用户ID'})
|
||||
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '1100a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限添加商家(需要1100a)'})
|
||||
|
||||
try:
|
||||
user = User.query.get(UserUID=yonghuid)
|
||||
except User.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '用户不存在,请确认用户ID'})
|
||||
|
||||
denied = forbid_if_user_out_of_scope(request, user)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
if not getattr(user, 'IsActive', True):
|
||||
return Response({'code': 400, 'msg': '该账号已被封禁,无法开通商家'})
|
||||
|
||||
try:
|
||||
dashou = user.DashouProfile
|
||||
if int(getattr(dashou, 'zhanghaozhuangtai', 1) or 0) != 1:
|
||||
return Response({'code': 400, 'msg': '该账号已被封禁,无法开通商家'})
|
||||
except UserDashou.DoesNotExist:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if UserShangjia.query.filter(user=user).exists():
|
||||
return Response({'code': 400, 'msg': '该用户已是商家,无需重复添加'})
|
||||
|
||||
from merchant_ops.services.authz import assert_no_active_staff_when_merchant_register, StaffAuthError
|
||||
try:
|
||||
assert_no_active_staff_when_merchant_register(user)
|
||||
except StaffAuthError as e:
|
||||
return Response({'code': 403, 'msg': getattr(e, 'msg', None) or str(e)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
boss_nickname = '普通商家'
|
||||
try:
|
||||
boss = user.BossProfile
|
||||
if boss.nickname and boss.nickname.strip():
|
||||
boss_nickname = boss.nickname.strip()
|
||||
except UserBoss.DoesNotExist:
|
||||
pass
|
||||
|
||||
shangjia_nicheng = nicheng_in or boss_nickname
|
||||
if UserShangjia.query.filter(nicheng=shangjia_nicheng).exists():
|
||||
shangjia_nicheng = f"{shangjia_nicheng}{random.randint(1000, 9999)}"
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
shop = UserShangjia.query.create(
|
||||
user=user,
|
||||
nicheng=shangjia_nicheng,
|
||||
zhuangtai=1,
|
||||
yue=0.00,
|
||||
fabu=0,
|
||||
tuikuan=0,
|
||||
chengjiao=0,
|
||||
jinridingdan=0,
|
||||
jinriliushui=0.00,
|
||||
jinyuedingdan=0,
|
||||
jinyueliushui=0.00,
|
||||
dianhua='',
|
||||
wechat='',
|
||||
)
|
||||
write_xiugai_log(
|
||||
yonghuid=yonghuid,
|
||||
xiugaiid=kefu.user.Phone,
|
||||
leixing=XIUGAI_LEIXING_SHANGJIA,
|
||||
qitashuoming=(
|
||||
f'【后台】添加商家:用户ID={yonghuid},昵称={shop.nicheng},'
|
||||
f'操作人={kefu.user.Phone}'
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('后台添加商家失败')
|
||||
return Response({'code': 500, 'msg': f'添加失败:{e}'})
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '商家添加成功',
|
||||
'data': {
|
||||
'yonghuid': yonghuid,
|
||||
'nicheng': shop.nicheng,
|
||||
'zhuangtai': shop.zhuangtai,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class KefuChatPermissionsView(APIView):
|
||||
@@ -1132,7 +1448,9 @@ class KefuChatPermissionsView(APIView):
|
||||
|
||||
# ---------- 验证通过,permissions 已经是权限码列表 ----------
|
||||
# 4. 检查是否拥有至少一个聊天权限
|
||||
has_chat_perm = any(p in CHAT_PERM_CODES for p in permissions)
|
||||
# 必须用 has_perm_code:系统超管是 _AllPermissions,
|
||||
# 「any(p in CHAT_PERM_CODES for p in permissions)」会漏掉超管(列表里往往只有 000001)
|
||||
has_chat_perm = has_perm_code(permissions, *CHAT_PERM_CODES)
|
||||
|
||||
# 5. 若有聊天权限,获取 GoEasy AppKey
|
||||
goeasy_appkey = ''
|
||||
@@ -1150,6 +1468,303 @@ class KefuChatPermissionsView(APIView):
|
||||
})
|
||||
|
||||
|
||||
class KefuImUserClubFilterView(APIView):
|
||||
"""
|
||||
批量校验 GoEasy 客户 ID 所属俱乐部(供客服待接入/已接入列表前端过滤)。
|
||||
POST /houtai/kfimssclub
|
||||
Body: { "phone": "客服手机号", "im_ids": ["Boss1234567", "Ds7654321"] }
|
||||
返回 allowed_ids:当前后台俱乐部可见的 IM ID(集团汇总则全部可解析的都返回)
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
_IM_UID_RE = re.compile(
|
||||
r'^(Boss|Ds|Sj|Gs|Zz|Guanshi|Zuzhang|Kaoheguan)(\d+)$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||
kefu_obj, permissions = verify_kefu_permission(request, phone or None)
|
||||
if kefu_obj is None:
|
||||
return permissions
|
||||
if not has_perm_code(permissions, *CHAT_PERM_CODES):
|
||||
return Response({'code': 403, 'msg': '无聊天权限', 'data': None})
|
||||
|
||||
raw_ids = request.data.get('im_ids') or request.data.get('ids') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return Response({'code': 400, 'msg': 'im_ids 必须为数组', 'data': None})
|
||||
|
||||
# 去重、截断,避免一次打太多
|
||||
im_ids = []
|
||||
seen = set()
|
||||
for x in raw_ids[:200]:
|
||||
s = str(x or '').strip()
|
||||
if s and s not in seen:
|
||||
seen.add(s)
|
||||
im_ids.append(s)
|
||||
|
||||
uid_to_ims = {}
|
||||
for im_id in im_ids:
|
||||
m = self._IM_UID_RE.match(im_id)
|
||||
if not m:
|
||||
continue
|
||||
uid = m.group(2)
|
||||
uid_to_ims.setdefault(uid, []).append(im_id)
|
||||
|
||||
club_map = {}
|
||||
if uid_to_ims:
|
||||
for u in User.query.filter(UserUID__in=list(uid_to_ims.keys())).only('UserUID', 'ClubID'):
|
||||
cid = (getattr(u, 'ClubID', None) or '').strip() or CLUB_ID_DEFAULT
|
||||
for im_id in uid_to_ims.get(u.UserUID, []):
|
||||
club_map[im_id] = cid
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||
|
||||
scope = resolve_club_scope(request)
|
||||
if scope == DATA_SCOPE_ALL:
|
||||
allowed_ids = [i for i in im_ids if i in club_map]
|
||||
else:
|
||||
current = (resolve_club_id_from_request(request) or '').strip()
|
||||
# 严格按用户归属俱乐部过滤;ClubID 空的用户归入默认俱乐部
|
||||
allowed_ids = [
|
||||
i for i in im_ids
|
||||
if (club_map.get(i) or '').strip() == current
|
||||
]
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': 'ok',
|
||||
'data': {
|
||||
'allowed_ids': allowed_ids,
|
||||
'club_map': club_map,
|
||||
'current_club_id': (
|
||||
None if scope == DATA_SCOPE_ALL
|
||||
else (resolve_club_id_from_request(request) or '').strip()
|
||||
),
|
||||
'scope': 'all' if scope == DATA_SCOPE_ALL else 'single',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class DashouGiftHuiyuanSaveView(APIView):
|
||||
"""
|
||||
赠送会员 / 改期(免费接单资格,写入 dashou_zengsong_huiyuan)
|
||||
POST /houtai/dszsonghy
|
||||
Body: {
|
||||
phone, dashou_id, huiyuan_id,
|
||||
daoqi_time: 'YYYY-MM-DD' 或 'YYYY-MM-DD HH:MM:SS',
|
||||
id?: 已有记录ID(改期时建议传)
|
||||
}
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
from jituan.services.gift_huiyuan import (
|
||||
has_gift_huiyuan_permission,
|
||||
parse_gift_daoqi_time,
|
||||
upsert_gift_huiyuan,
|
||||
update_gift_huiyuan_daoqi,
|
||||
)
|
||||
from products.models import Huiyuan as HuiyuanModel
|
||||
|
||||
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||
dashou_id = (request.data.get('dashou_id') or request.data.get('uid') or '').strip()
|
||||
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
|
||||
record_id = request.data.get('id')
|
||||
raw_daoqi = request.data.get('daoqi_time')
|
||||
|
||||
kefu, permissions = verify_kefu_permission(request, phone or None)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if not has_gift_huiyuan_permission(permissions):
|
||||
return Response({'code': 403, 'msg': '无赠送会员权限(需打手基础管理或加积分权限)'})
|
||||
|
||||
if not dashou_id:
|
||||
return Response({'code': 400, 'msg': '缺少打手ID'})
|
||||
daoqi_time, err = parse_gift_daoqi_time(raw_daoqi)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
|
||||
try:
|
||||
dashou_user = User.query.get(UserUID=dashou_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '打手不存在'})
|
||||
denied = forbid_if_user_out_of_scope(request, dashou_user)
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
dashou_user.DashouProfile
|
||||
except ObjectDoesNotExist:
|
||||
return Response({'code': 404, 'msg': '该用户不是打手'})
|
||||
|
||||
song_ren = getattr(kefu.user, 'Phone', None) or phone
|
||||
operator = song_ren
|
||||
|
||||
# 有记录ID:只改期
|
||||
if record_id not in (None, ''):
|
||||
try:
|
||||
rid = int(record_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '无效的记录ID'})
|
||||
rec, err = update_gift_huiyuan_daoqi(
|
||||
record_id=rid,
|
||||
yonghu_id=dashou_id,
|
||||
daoqi_time=daoqi_time,
|
||||
song_ren=song_ren,
|
||||
)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
try:
|
||||
hy_name = HuiyuanModel.query.get(huiyuan_id=rec.huiyuan_id).jieshao
|
||||
except HuiyuanModel.DoesNotExist:
|
||||
hy_name = rec.huiyuan_id
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=operator,
|
||||
leixing=XIUGAI_LEIXING_DASHOU,
|
||||
huiyuan_id=rec.huiyuan_id,
|
||||
qitashuoming=(
|
||||
f'【后台】修改赠送会员到期时间:会员ID={rec.huiyuan_id}({hy_name}),'
|
||||
f'新到期={rec.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")},'
|
||||
f'打手ID={dashou_id},操作人={operator}'
|
||||
),
|
||||
)
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '改期成功',
|
||||
'data': {
|
||||
'id': rec.id,
|
||||
'huiyuan_id': rec.huiyuan_id,
|
||||
'daoqi_time': rec.daoqi_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
},
|
||||
})
|
||||
|
||||
if not huiyuan_id:
|
||||
return Response({'code': 400, 'msg': '缺少会员ID'})
|
||||
|
||||
rec, created, jifen_info, err = upsert_gift_huiyuan(
|
||||
yonghu_id=dashou_id,
|
||||
huiyuan_id=huiyuan_id,
|
||||
daoqi_time=daoqi_time,
|
||||
song_ren=song_ren,
|
||||
)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
|
||||
try:
|
||||
hy_name = HuiyuanModel.query.get(huiyuan_id=huiyuan_id).jieshao
|
||||
except HuiyuanModel.DoesNotExist:
|
||||
hy_name = huiyuan_id
|
||||
|
||||
if jifen_info:
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=operator,
|
||||
leixing=XIUGAI_LEIXING_DASHOU,
|
||||
jifen=jifen_info['after'],
|
||||
yjifen=jifen_info['before'],
|
||||
qitashuoming=(
|
||||
f'【后台】送会员时整改积分:{jifen_info["before"]} → {jifen_info["after"]},'
|
||||
f'原积分={jifen_info["before"]},整改后={jifen_info["after"]},'
|
||||
f'打手ID={dashou_id},操作人={operator}(送会员免费接单)'
|
||||
),
|
||||
)
|
||||
|
||||
action_label = '赠送' if created else '更新到期时间'
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=operator,
|
||||
leixing=XIUGAI_LEIXING_DASHOU,
|
||||
huiyuan_id=huiyuan_id,
|
||||
qitashuoming=(
|
||||
f'【后台】{action_label}赠送会员(免费接单):会员ID={huiyuan_id}({hy_name}),'
|
||||
f'到期={rec.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")},'
|
||||
f'打手ID={dashou_id},操作人={operator}(写入赠送会员表,非购买会员)'
|
||||
),
|
||||
)
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '赠送成功' if created else '到期时间已更新',
|
||||
'data': {
|
||||
'id': rec.id,
|
||||
'huiyuan_id': rec.huiyuan_id,
|
||||
'daoqi_time': rec.daoqi_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'created': created,
|
||||
'jifen_adjusted': bool(jifen_info),
|
||||
'jifen': jifen_info,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class DashouGiftHuiyuanRemoveView(APIView):
|
||||
"""
|
||||
移除赠送会员
|
||||
POST /houtai/dsycsonghy
|
||||
Body: { phone, dashou_id, id? , huiyuan_id? }
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
from jituan.services.gift_huiyuan import (
|
||||
has_gift_huiyuan_permission,
|
||||
remove_gift_huiyuan,
|
||||
)
|
||||
|
||||
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||
dashou_id = (request.data.get('dashou_id') or request.data.get('uid') or '').strip()
|
||||
record_id = request.data.get('id')
|
||||
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
|
||||
|
||||
kefu, permissions = verify_kefu_permission(request, phone or None)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if not has_gift_huiyuan_permission(permissions):
|
||||
return Response({'code': 403, 'msg': '无移除赠送会员权限'})
|
||||
|
||||
if not dashou_id:
|
||||
return Response({'code': 400, 'msg': '缺少打手ID'})
|
||||
|
||||
try:
|
||||
dashou_user = User.query.get(UserUID=dashou_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '打手不存在'})
|
||||
denied = forbid_if_user_out_of_scope(request, dashou_user)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
rid = None
|
||||
if record_id not in (None, ''):
|
||||
try:
|
||||
rid = int(record_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '无效的记录ID'})
|
||||
|
||||
ok, err = remove_gift_huiyuan(
|
||||
record_id=rid,
|
||||
yonghu_id=dashou_id if not rid else None,
|
||||
huiyuan_id=huiyuan_id if not rid else None,
|
||||
)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
|
||||
operator = getattr(kefu.user, 'Phone', None) or phone
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=operator,
|
||||
leixing=XIUGAI_LEIXING_DASHOU,
|
||||
huiyuan_id=huiyuan_id or '',
|
||||
qitashuoming=(
|
||||
f'【后台】移除赠送会员(免费接单):记录ID={rid or "-"},'
|
||||
f'会员ID={huiyuan_id or "-"},打手ID={dashou_id},操作人={operator}'
|
||||
),
|
||||
)
|
||||
return Response({'code': 0, 'msg': '移除成功'})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -583,8 +583,13 @@ class FineApplyView(APIView):
|
||||
if not dashou_id:
|
||||
return Response({'code': 400, 'msg': '该订单无接单打手,无法罚款'})
|
||||
|
||||
# 重复罚款检查
|
||||
if Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=OrderID).exists():
|
||||
# 重复罚款检查(组队单按订单维度防重复)
|
||||
from orders.services.team_recruit import get_active_recruit
|
||||
_active_team = get_active_recruit(OrderID)
|
||||
if _active_team:
|
||||
if Penalty.query.filter(RelatedOrderID=OrderID).exclude(Status=4).exists():
|
||||
return Response({'code': 400, 'msg': '该订单已有罚单'})
|
||||
elif Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=OrderID).exists():
|
||||
return Response({'code': 400, 'msg': '该打手已被罚款过'})
|
||||
|
||||
# 检查权限(打手身份 shenfen=1)
|
||||
@@ -610,24 +615,48 @@ class FineApplyView(APIView):
|
||||
return Response({'code': 500, 'msg': '图片上传失败'})
|
||||
uploaded_urls.append(file_path)
|
||||
|
||||
# 创建罚单
|
||||
# 创建罚单(组队均摊)
|
||||
with transaction.atomic():
|
||||
penalty = Penalty.query.create(
|
||||
PenalizedUserID=dashou_id,
|
||||
ApplicantID=request.user.Phone,
|
||||
Identity=1, # 被处罚人身份:1 打手
|
||||
Reason=Reason,
|
||||
FineAmount=FineAmount,
|
||||
RelatedOrderID=OrderID,
|
||||
Status=1, # 待缴纳
|
||||
AffectsGrabbing=AffectsGrabbing,
|
||||
ApplicantIdentity=1, # 申请人身份:1 客服
|
||||
ClubID=resolve_penalty_club_id(
|
||||
from orders.services.team_recruit import maybe_split_penalties
|
||||
from users.fadan_fenhong_utils import lock_penalty_bonus
|
||||
club_for_p = resolve_penalty_club_id(
|
||||
order=order,
|
||||
request=request,
|
||||
user=request.user,
|
||||
applicant_id=getattr(request.user, 'UserUID', None),
|
||||
),
|
||||
)
|
||||
split = maybe_split_penalties(
|
||||
order_id=OrderID,
|
||||
total_amount=FineAmount,
|
||||
reason=Reason,
|
||||
applicant_id=request.user.Phone,
|
||||
applicant_identity=1,
|
||||
affects_grabbing=AffectsGrabbing,
|
||||
club_id=club_for_p,
|
||||
)
|
||||
if split:
|
||||
for p in split:
|
||||
for relative_url in uploaded_urls:
|
||||
PenaltyAppealImage.query.create(
|
||||
Penalty=p,
|
||||
PenalizedUserID=p.PenalizedUserID,
|
||||
ImageURL=relative_url,
|
||||
Purpose=1,
|
||||
)
|
||||
lock_penalty_bonus(p)
|
||||
penalty = split[0]
|
||||
else:
|
||||
penalty = Penalty.query.create(
|
||||
PenalizedUserID=dashou_id,
|
||||
ApplicantID=request.user.Phone,
|
||||
Identity=1,
|
||||
Reason=Reason,
|
||||
FineAmount=FineAmount,
|
||||
RelatedOrderID=OrderID,
|
||||
Status=1,
|
||||
AffectsGrabbing=AffectsGrabbing,
|
||||
ApplicantIdentity=1,
|
||||
ClubID=club_for_p,
|
||||
)
|
||||
for relative_url in uploaded_urls:
|
||||
PenaltyAppealImage.query.create(
|
||||
@@ -636,7 +665,6 @@ class FineApplyView(APIView):
|
||||
ImageURL=relative_url,
|
||||
Purpose=1
|
||||
)
|
||||
from users.fadan_fenhong_utils import lock_penalty_bonus
|
||||
lock_penalty_bonus(penalty)
|
||||
|
||||
logger.info(f"客服罚款申请成功: 订单 {OrderID}, 打手 {dashou_id}, 金额 {FineAmount}")
|
||||
|
||||
@@ -141,18 +141,26 @@ class GetProductBaseDataView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 获取所有商品类型(按 paixu 升序)
|
||||
# 获取所有商品类型(按 paixu 升序);商品数按当前俱乐部统计
|
||||
from jituan.services.club_context import filter_club_char_field, resolve_club_scope
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
|
||||
try:
|
||||
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
||||
# 批量聚合商品数量,避免循环内 N+1 count
|
||||
# 批量聚合商品数量,避免循环内 N+1 count(按俱乐部)
|
||||
_public_sp = filter_club_char_field(
|
||||
Shangpin.query.filter(
|
||||
dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
|
||||
),
|
||||
request,
|
||||
field='club_id',
|
||||
)
|
||||
# 用 Django QS 聚合,避免 Fluent values 链在部分环境下异常
|
||||
_type_counts = {
|
||||
item['leixing_id']: item['cnt']
|
||||
for item in Shangpin.query.filter(
|
||||
dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
|
||||
).values('leixing_id').annotate(cnt=Count('id'))
|
||||
for item in _public_sp.values('leixing_id').annotate(cnt=Count('id'))
|
||||
}
|
||||
type_list = []
|
||||
for t in type_qs:
|
||||
@@ -166,12 +174,18 @@ class GetProductBaseDataView(APIView):
|
||||
'product_count': product_count,
|
||||
})
|
||||
|
||||
# 获取所有专区(按 paixu 升序)
|
||||
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu')
|
||||
# 获取专区(按俱乐部隔离;集团汇总看全部)
|
||||
zone_qs = filter_club_char_field(
|
||||
ShangpinZhuanqu.query.all().order_by('paixu'), request, field='club_id'
|
||||
)
|
||||
_zone_counts = {
|
||||
item['zhuanqu_id']: item['cnt']
|
||||
for item in Shangpin.query.filter(
|
||||
for item in filter_club_char_field(
|
||||
Shangpin.query.filter(
|
||||
dianpu__isnull=True, dianpu_leixing__isnull=True, zhuanqu_id__isnull=False
|
||||
),
|
||||
request,
|
||||
field='club_id',
|
||||
).values('zhuanqu_id').annotate(cnt=Count('id'))
|
||||
}
|
||||
zone_list = []
|
||||
@@ -184,10 +198,38 @@ class GetProductBaseDataView(APIView):
|
||||
'paixu': z.paixu,
|
||||
'shenhezhuangtai': z.shenhezhuangtai,
|
||||
'product_count': product_count,
|
||||
'club_id': getattr(z, 'club_id', '') or '',
|
||||
'tupian_url': getattr(z, 'tupian_url', '') or '',
|
||||
})
|
||||
|
||||
# 获取所有会员(只需 ID、介绍、价格)
|
||||
member_list = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'))
|
||||
member_list = []
|
||||
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'):
|
||||
member_list.append({
|
||||
'huiyuan_id': m['huiyuan_id'],
|
||||
'jieshao': m['jieshao'] or '',
|
||||
'jiage': float(m['jiage'] or 0),
|
||||
})
|
||||
|
||||
# 本俱乐部「只看审核」开关(集团汇总不下发可写状态)
|
||||
club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
|
||||
zhi_kan_shenhe = True
|
||||
club_id = ''
|
||||
if club_scoped:
|
||||
club_id = resolve_club_id_for_write(request) or ''
|
||||
try:
|
||||
from shop.services.shenhe_config import (
|
||||
get_or_create_shenhe_config,
|
||||
is_zhi_kan_shenhe_enabled,
|
||||
)
|
||||
get_or_create_shenhe_config(club_id)
|
||||
zhi_kan_shenhe = is_zhi_kan_shenhe_enabled(club_id)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception(
|
||||
'读取只看审核配置失败 club_id=%s(请确认已 migrate shop 0004)',
|
||||
club_id,
|
||||
)
|
||||
zhi_kan_shenhe = True
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
@@ -195,8 +237,14 @@ class GetProductBaseDataView(APIView):
|
||||
'type_list': type_list,
|
||||
'zone_list': zone_list,
|
||||
'member_list': member_list,
|
||||
'zhi_kan_shenhe': zhi_kan_shenhe,
|
||||
'club_scoped': club_scoped,
|
||||
'club_id': club_id,
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception('GetProductBaseDataView 失败')
|
||||
return Response({'code': 500, 'msg': '获取商品基础数据失败,请稍后重试'})
|
||||
|
||||
|
||||
|
||||
@@ -235,9 +283,6 @@ class GetProductListView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 获取参数
|
||||
leixing_id = request.data.get('leixing_id')
|
||||
@@ -257,11 +302,13 @@ class GetProductListView(APIView):
|
||||
except:
|
||||
return Response({'code': 400, 'msg': '分页参数错误'})
|
||||
|
||||
# 🔥 修改:构建查询集,增加公共商品筛选(店铺为空且店铺商品类型为空)
|
||||
# 🔥 公共商品 + 按俱乐部隔离(集团汇总看全部)
|
||||
from jituan.services.club_context import filter_club_char_field
|
||||
qs = Shangpin.query.filter(
|
||||
dianpu__isnull=True, # 所属店铺为空
|
||||
dianpu_leixing__isnull=True # 店铺商品类型为空
|
||||
)
|
||||
qs = filter_club_char_field(qs, request, field='club_id')
|
||||
|
||||
if leixing_id:
|
||||
try:
|
||||
@@ -323,8 +370,10 @@ class GetProductListView(APIView):
|
||||
'jiage': str(p.jiage),
|
||||
'kucun': p.kucun,
|
||||
'zhenshi_xiaoliang': p.zhenshi_xiaoliang,
|
||||
'duiwai_xiaoliang': p.duiwai_xiaoliang,
|
||||
'tupian_url': p.tupian_url or '',
|
||||
'jieshao': p.jieshao or '',
|
||||
'club_id': getattr(p, 'club_id', '') or '',
|
||||
})
|
||||
|
||||
return Response({
|
||||
@@ -365,9 +414,6 @@ class GetProductListView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 获取所有商品类型(按 paixu 升序)
|
||||
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
||||
@@ -404,6 +450,7 @@ class GetProductListView(APIView):
|
||||
'paixu': z.paixu,
|
||||
'shenhezhuangtai': z.shenhezhuangtai,
|
||||
'product_count': product_count,
|
||||
'tupian_url': getattr(z, 'tupian_url', '') or '',
|
||||
})
|
||||
|
||||
# 获取所有会员(只需 ID、介绍、价格)
|
||||
@@ -454,9 +501,6 @@ class GetProductListView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 获取参数
|
||||
leixing_id = request.data.get('leixing_id')
|
||||
@@ -578,9 +622,6 @@ class SaveProductOrderView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
sort_type = request.data.get('type', '').strip().lower()
|
||||
if sort_type not in ('leixing', 'zhuanqu'):
|
||||
@@ -643,9 +684,6 @@ class AddProductView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 获取表单字段
|
||||
biaoqian = request.data.get('biaoqian', '').strip()
|
||||
@@ -658,6 +696,9 @@ class AddProductView(APIView):
|
||||
huiyuan_id = request.data.get('huiyuan_id', '').strip()
|
||||
yongjin_str = request.data.get('yongjin', '0')
|
||||
fencheng_type = request.data.get('fencheng_type', 'default')
|
||||
# 兼容列表页 ewai_dashou_fencheng 与旧字段 ewai_PlayerCommission
|
||||
ewai_PlayerCommission_str = request.data.get('ewai_dashou_fencheng')
|
||||
if ewai_PlayerCommission_str is None or ewai_PlayerCommission_str == '':
|
||||
ewai_PlayerCommission_str = request.data.get('ewai_PlayerCommission', '0')
|
||||
jieshao = request.data.get('jieshao', '').strip()
|
||||
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
|
||||
@@ -707,6 +748,14 @@ class AddProductView(APIView):
|
||||
zhuanqu_obj = ShangpinZhuanqu.query.get(id=zhuanqu_id)
|
||||
if zhuanqu_obj.leixing_id != leixing_id:
|
||||
return Response({'code': 400, 'msg': '专区不属于该商品类型'})
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||
write_club = resolve_club_id_for_write(request)
|
||||
zone_club = getattr(zhuanqu_obj, 'club_id', None) or 'xq'
|
||||
if zone_club != write_club:
|
||||
return Response({'code': 400, 'msg': '专区不属于当前俱乐部,请先切换俱乐部或选择本俱乐部专区'})
|
||||
except ShangpinZhuanqu.DoesNotExist:
|
||||
return Response({'code': 400, 'msg': '商品专区不存在'})
|
||||
|
||||
@@ -774,6 +823,8 @@ class AddProductView(APIView):
|
||||
|
||||
# 创建商品
|
||||
try:
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
club_id = resolve_club_id_for_write(request)
|
||||
with transaction.atomic():
|
||||
product = Shangpin.query.create(
|
||||
biaoqian=biaoqian,
|
||||
@@ -793,6 +844,7 @@ class AddProductView(APIView):
|
||||
ewai_dashou_fencheng=ewai_fencheng,
|
||||
paixu=0, # 新商品默认排序最后
|
||||
shenhezhuangtai=1, # 正常状态
|
||||
club_id=club_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"创建商品失败: {str(e)}", exc_info=True)
|
||||
@@ -843,9 +895,6 @@ class DeleteProductView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 查询商品是否存在
|
||||
try:
|
||||
@@ -908,9 +957,6 @@ class GetProductDetailView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 查询商品
|
||||
try:
|
||||
@@ -946,7 +992,10 @@ class GetProductDetailView(APIView):
|
||||
'huiyuan_id': product.huiyuan_id,
|
||||
'yongjin': str(product.yongjin) if product.yongjin is not None else None,
|
||||
'tupian_url': product.tupian_url,
|
||||
'kaioi_ewai_PlayerCommission': product.kaioi_ewai_dashou_fencheng,
|
||||
# 兼容两套字段名(客服详情页用 kaioi_ewai_dashou_fencheng)
|
||||
'kaioi_ewai_dashou_fencheng': bool(product.kaioi_ewai_dashou_fencheng),
|
||||
'ewai_dashou_fencheng': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
|
||||
'kaioi_ewai_PlayerCommission': bool(product.kaioi_ewai_dashou_fencheng),
|
||||
'ewai_PlayerCommission': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
|
||||
'CreateTime': product.CreateTime.isoformat() if product.CreateTime else None,
|
||||
'shenhezhuangtai': product.shenhezhuangtai,
|
||||
@@ -992,9 +1041,6 @@ class UpdateProductView(APIView):
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 使用事务,将 select_for_update 放在事务内
|
||||
with transaction.atomic():
|
||||
@@ -1070,16 +1116,26 @@ class UpdateProductView(APIView):
|
||||
except:
|
||||
yongjin = Decimal('0')
|
||||
|
||||
# ---------- 3. 打手分成 ----------
|
||||
kaioi_ewai = request.data.get('kaioi_ewai_PlayerCommission') == 'true'
|
||||
# ---------- 3. 打手分成(兼容详情页字段名与旧 PlayerCommission 别名) ----------
|
||||
raw_kaioi = request.data.get('kaioi_ewai_dashou_fencheng')
|
||||
if raw_kaioi is None:
|
||||
raw_kaioi = request.data.get('kaioi_ewai_PlayerCommission')
|
||||
kaioi_ewai = str(raw_kaioi).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
ewai_fencheng = Decimal('0')
|
||||
if kaioi_ewai:
|
||||
raw_amt = request.data.get('ewai_dashou_fencheng')
|
||||
if raw_amt is None or raw_amt == '':
|
||||
raw_amt = request.data.get('ewai_PlayerCommission', 0)
|
||||
try:
|
||||
ewai_fencheng = Decimal(str(request.data.get('ewai_PlayerCommission', 0)))
|
||||
ewai_fencheng = Decimal(str(raw_amt))
|
||||
if ewai_fencheng < 0:
|
||||
return Response({'code': 400, 'msg': '分成金额不能为负数'})
|
||||
except:
|
||||
except Exception:
|
||||
return Response({'code': 400, 'msg': '分成金额格式错误'})
|
||||
else:
|
||||
# 默认分成:清空固定金额,下单按俱乐部利率算
|
||||
ewai_fencheng = None
|
||||
|
||||
jieshao = request.data.get('jieshao', '').strip()
|
||||
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
|
||||
@@ -1146,6 +1202,52 @@ class UpdateProductView(APIView):
|
||||
return Response({'code': 0, 'msg': '商品修改成功', 'data': {'id': product.id}})
|
||||
|
||||
|
||||
|
||||
class UpdateZhiKanShenheView(APIView):
|
||||
"""
|
||||
商品管理页:按俱乐部更新「只看审核商品」开关。
|
||||
权限:2200a
|
||||
POST /houtai/htxgzhikan
|
||||
"""
|
||||
permission_classes = []
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
username = request.data.get('username', '').strip()
|
||||
if not username:
|
||||
return Response({'code': 401, 'msg': '缺少username'})
|
||||
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '2200a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||
|
||||
val = request.data.get('zhi_kan_shenhe')
|
||||
if val is None:
|
||||
return Response({'code': 400, 'msg': '缺少参数 zhi_kan_shenhe'})
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改只看审核开关'})
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
club_id = resolve_club_id_for_write(request)
|
||||
config = get_or_create_shenhe_config(club_id)
|
||||
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
|
||||
config.zhi_kan_shenhe = bool(val)
|
||||
config.save(update_fields=['zhi_kan_shenhe'])
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '只看审核开关已更新',
|
||||
'data': {'club_id': club_id, 'zhi_kan_shenhe': bool(val)},
|
||||
})
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception('更新只看审核开关失败')
|
||||
return Response({'code': 500, 'msg': '服务器错误'})
|
||||
|
||||
|
||||
|
||||
@@ -223,9 +223,15 @@ class ModifyRolePermissionView(APIView):
|
||||
if not role_code:
|
||||
return Response({'code': 400, 'msg': '缺少角色编码'})
|
||||
|
||||
from jituan.services.admin_context import can_manage_admin_assignments, can_manage_roles_for_club
|
||||
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
||||
explicit_club = (request.data.get('target_club_id') or request.data.get('club_id') or '').strip() or None
|
||||
bind_club_id = resolve_role_club_id(request, explicit_club)
|
||||
if bind_club_id:
|
||||
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
|
||||
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色'})
|
||||
elif not can_manage_admin_assignments(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '您无权管理全局角色'})
|
||||
role = resolve_role_by_name(role_code, bind_club_id)
|
||||
if not role:
|
||||
return Response({'code': 404, 'msg': '角色不存在或与当前俱乐部不匹配'})
|
||||
@@ -354,11 +360,17 @@ class AddRoleView(APIView):
|
||||
if '000001' not in permissions:
|
||||
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
||||
|
||||
# 检查角色名称是否已存在(同俱乐部内唯一)
|
||||
# 检查角色名称是否已存在(同俱乐部内唯一);单俱乐部视图强制写入当前顶栏俱乐部
|
||||
from jituan.services.admin_context import can_manage_admin_assignments, can_manage_roles_for_club
|
||||
from jituan.services.club_rbac import resolve_role_club_id, role_name_exists
|
||||
target_club_id = resolve_role_club_id(
|
||||
request, (request.data.get('target_club_id') or '').strip() or None,
|
||||
)
|
||||
if target_club_id:
|
||||
if not can_manage_roles_for_club(request.user, permissions, target_club_id):
|
||||
return Response({'code': 403, 'msg': '您无权在该俱乐部下创建角色'})
|
||||
elif not can_manage_admin_assignments(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '仅集团权限可创建全局角色'})
|
||||
if role_name_exists(role_name, target_club_id):
|
||||
return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'})
|
||||
|
||||
@@ -494,16 +506,45 @@ class GetAdminUserListView(APIView):
|
||||
'is_primary': a.is_primary,
|
||||
})
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||
from jituan.services.club_rbac import filter_roles_for_request
|
||||
|
||||
scope = resolve_club_scope(request)
|
||||
curr_club = (resolve_club_id_from_request(request) or '').strip()
|
||||
# 单俱乐部视图:角色下拉/展示只允许「本俱乐部角色」;若本店无角色则仅全局
|
||||
allowed_role_club_ids = None
|
||||
if scope != DATA_SCOPE_ALL:
|
||||
scoped_roles = list(filter_roles_for_request(request).values_list('ClubID', flat=True))
|
||||
allowed_role_club_ids = {(c or '').strip() for c in scoped_roles}
|
||||
|
||||
def _roles_in_view(roles_data):
|
||||
if scope == DATA_SCOPE_ALL or allowed_role_club_ids is None:
|
||||
return roles_data
|
||||
return [
|
||||
r for r in roles_data
|
||||
if (r.get('club_id') or '').strip() in allowed_role_club_ids
|
||||
]
|
||||
|
||||
def _assignments_in_view(assignments):
|
||||
if scope == DATA_SCOPE_ALL:
|
||||
return assignments
|
||||
# 本俱乐部任职 + 集团级(club_id 空);绝不展示其他子公司任职
|
||||
return [
|
||||
a for a in assignments
|
||||
if not a.get('club_id') or a.get('club_id') == curr_club
|
||||
]
|
||||
|
||||
data_list = []
|
||||
for user in _page_users:
|
||||
roles_data = _roles_by_user.get(user.UserUUID, [])
|
||||
roles_data = _roles_in_view(_roles_by_user.get(user.UserUUID, []))
|
||||
data_list.append({
|
||||
'phone': user.Phone or '',
|
||||
'yonghuid': user.UserUID or '',
|
||||
'nicheng': user.KefuProfile.nicheng,
|
||||
'club_id': getattr(user, 'ClubID', '') or '',
|
||||
'roles': roles_data,
|
||||
'assignments': _assignments_by_user.get(user.UserUID, []),
|
||||
'assignments': _assignments_in_view(_assignments_by_user.get(user.UserUID, [])),
|
||||
'status': user.KefuProfile.zhuangtai,
|
||||
'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '',
|
||||
'update_time': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '',
|
||||
@@ -512,6 +553,15 @@ class GetAdminUserListView(APIView):
|
||||
})
|
||||
|
||||
from jituan.services.admin_context import can_manage_admin_assignments, get_manageable_club_ids
|
||||
manageable = sorted(get_manageable_club_ids(
|
||||
request.user, permissions, current_club_id=curr_club,
|
||||
))
|
||||
if scope != DATA_SCOPE_ALL:
|
||||
# 顶栏在具体俱乐部时,可管理集合收敛到当前店(含超级管理 000001)
|
||||
manageable = [c for c in manageable if c == curr_club]
|
||||
# 兜底:本店超级管理一定能管本店用户(不依赖集团任职)
|
||||
if not manageable and curr_club and permissions and '000001' in permissions:
|
||||
manageable = [curr_club]
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
@@ -520,7 +570,12 @@ class GetAdminUserListView(APIView):
|
||||
'page': page,
|
||||
'pageSize': page_size,
|
||||
'can_manage_club_assignments': can_manage_admin_assignments(request.user, permissions),
|
||||
'manageable_club_ids': sorted(get_manageable_club_ids(request.user, permissions)),
|
||||
'can_manage_club_users': bool(manageable) or (
|
||||
bool(permissions) and '000001' in permissions and scope != DATA_SCOPE_ALL
|
||||
),
|
||||
'manageable_club_ids': manageable,
|
||||
'current_club_id': curr_club,
|
||||
'scope': scope,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -546,19 +601,23 @@ class GetAdminRolesView(APIView):
|
||||
if not can_access_admin_user_management(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||
|
||||
from jituan.services.admin_context import can_manage_admin_assignments, get_manageable_club_ids
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.admin_context import can_manage_admin_assignments
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
from jituan.services.club_rbac import filter_roles_for_request
|
||||
|
||||
scope = resolve_club_scope(request)
|
||||
all_clubs = request.data.get('all_clubs') in (True, 1, '1', 'true', 'yes')
|
||||
if all_clubs and can_manage_admin_assignments(request.user, permissions):
|
||||
# 仅集团汇总视图 + 集团任职管理权 才可拉全量子公司角色;
|
||||
# 顶栏在具体俱乐部时(含集团账号切到某店),绝不容许其他俱乐部角色泄露。
|
||||
if (
|
||||
scope == DATA_SCOPE_ALL
|
||||
and all_clubs
|
||||
and can_manage_admin_assignments(request.user, permissions)
|
||||
):
|
||||
roles = Role.objects.filter(RoleStatus=1).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
||||
else:
|
||||
manageable = get_manageable_club_ids(request.user, permissions)
|
||||
if manageable:
|
||||
roles = Role.objects.filter(
|
||||
RoleStatus=1, ClubID__in=manageable,
|
||||
).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
||||
else:
|
||||
roles = filter_roles_for_request(request).values('RoleName', 'ClubID')
|
||||
roles = filter_roles_for_request(request).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
@@ -671,9 +730,11 @@ class ModifyAdminUserView(APIView):
|
||||
club_assign_actions = {'add_club_assignment', 'remove_club_assignment'}
|
||||
role_manage_actions = {'add_user_roles', 'remove_user_role'}
|
||||
if action in club_assign_actions:
|
||||
# 跨店任职仍需集团权限;本店超级管理只管功能角色与账号,不分配其他俱乐部任职
|
||||
if not can_manage_admin_assignments(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '无权分配俱乐部任职'})
|
||||
return Response({'code': 403, 'msg': '无权分配俱乐部任职(需集团权限)'})
|
||||
elif action in role_manage_actions:
|
||||
# 本店超级管理 000001 可给本俱乐部用户增删功能角色
|
||||
if not can_access_admin_user_management(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '您无权管理角色分配'})
|
||||
elif not can_manage_admin_assignments(request.user, permissions) and '000001' not in permissions:
|
||||
@@ -703,7 +764,17 @@ class ModifyAdminUserView(APIView):
|
||||
if nicheng is not None:
|
||||
target_kefu.nicheng = nicheng
|
||||
if status is not None:
|
||||
target_kefu.zhuangtai = int(status)
|
||||
new_status = int(status)
|
||||
target_kefu.zhuangtai = new_status
|
||||
if new_status != 1:
|
||||
# 禁用账号:停用全部集团/俱乐部任职,并摘掉非白名单的 IsSuperuser
|
||||
from jituan.constants import SUPER_ADMIN_PHONES
|
||||
from jituan.services.admin_assignments import deactivate_all_assignments_for_user
|
||||
deactivate_all_assignments_for_user(target_user.UserUID)
|
||||
if (target_user.Phone or '') not in SUPER_ADMIN_PHONES:
|
||||
if target_user.IsSuperuser:
|
||||
target_user.IsSuperuser = False
|
||||
target_user.save(update_fields=['IsSuperuser'])
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
return Response({'code': 400, 'msg': '密码至少6位'})
|
||||
@@ -721,12 +792,16 @@ class ModifyAdminUserView(APIView):
|
||||
role_codes = request.data.get('role_codes', [])
|
||||
if not role_codes:
|
||||
return Response({'code': 400, 'msg': '请选择角色'})
|
||||
from jituan.services.club_rbac import resolve_role_by_name
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
bind_club_id = (request.data.get('target_club_id') or '').strip()
|
||||
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
||||
bind_club_id = resolve_role_club_id(
|
||||
request, (request.data.get('target_club_id') or '').strip() or None,
|
||||
)
|
||||
if not bind_club_id:
|
||||
bind_club_id = resolve_club_id_from_request(request)
|
||||
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
|
||||
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再分配角色'})
|
||||
if not (
|
||||
can_manage_roles_for_club(request.user, permissions, bind_club_id)
|
||||
or ('000001' in permissions)
|
||||
):
|
||||
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
|
||||
added, missing = [], []
|
||||
for rc in role_codes:
|
||||
@@ -766,12 +841,16 @@ class ModifyAdminUserView(APIView):
|
||||
role_code = request.data.get('role_code')
|
||||
if not role_code:
|
||||
return Response({'code': 400, 'msg': '缺少角色编码'})
|
||||
from jituan.services.club_rbac import resolve_role_by_name
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
bind_club_id = (request.data.get('target_club_id') or '').strip()
|
||||
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
||||
bind_club_id = resolve_role_club_id(
|
||||
request, (request.data.get('target_club_id') or '').strip() or None,
|
||||
)
|
||||
if not bind_club_id:
|
||||
bind_club_id = resolve_club_id_from_request(request)
|
||||
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
|
||||
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再操作角色'})
|
||||
if not (
|
||||
can_manage_roles_for_club(request.user, permissions, bind_club_id)
|
||||
or ('000001' in permissions)
|
||||
):
|
||||
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
|
||||
role = resolve_role_by_name(role_code, bind_club_id)
|
||||
if not role:
|
||||
@@ -839,12 +918,17 @@ class AddAdminUserView(APIView):
|
||||
can_manage_admin_assignments,
|
||||
can_manage_roles_for_club,
|
||||
)
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
|
||||
target_club_id = resolve_club_id_from_request(request)
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL or not target_club_id:
|
||||
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再添加用户'})
|
||||
# 俱乐部超级管理 000001 即可添加本店后台用户,无需集团权限
|
||||
if not (
|
||||
can_manage_admin_assignments(request.user, permissions)
|
||||
or can_manage_roles_for_club(request.user, permissions, target_club_id)
|
||||
or ('000001' in permissions)
|
||||
):
|
||||
return Response({'code': 403, 'msg': '您无权在该俱乐部添加用户'})
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class HqbkxxView(APIView):
|
||||
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
return Response({'code': 400, 'msg': scope_err}, status=400)
|
||||
|
||||
# 5. 查询所有板块
|
||||
bankuai_list = []
|
||||
@@ -241,7 +241,7 @@ class BkxgView(APIView):
|
||||
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
return Response({'code': 400, 'msg': scope_err}, status=400)
|
||||
|
||||
# 5. 获取操作类型
|
||||
action = request.data.get('action')
|
||||
@@ -279,9 +279,13 @@ class BkxgView(APIView):
|
||||
})
|
||||
|
||||
# ==================== 以下操作需要 bankuai_id ====================
|
||||
bankuai_id = request.data.get('bankuai_id')
|
||||
if not bankuai_id:
|
||||
raw_bankuai_id = request.data.get('bankuai_id')
|
||||
if raw_bankuai_id is None or str(raw_bankuai_id).strip() == '':
|
||||
return Response({'code': 1, 'msg': '缺少 bankuai_id'}, status=400)
|
||||
try:
|
||||
bankuai_id = int(raw_bankuai_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 1, 'msg': '无效的 bankuai_id'}, status=400)
|
||||
|
||||
try:
|
||||
bk = Bankuai.query.get(bankuai_id=bankuai_id)
|
||||
|
||||
@@ -142,8 +142,10 @@ class ShopListView(APIView):
|
||||
zhengqu_zonge_min = request.data.get('zhengqu_zonge_min')
|
||||
zhengqu_zonge_max = request.data.get('zhengqu_zonge_max')
|
||||
|
||||
# 4. 构建查询集(先查店铺,预加载关联凭证)
|
||||
# 4. 构建查询集(先查店铺,预加载关联凭证;按俱乐部隔离)
|
||||
from jituan.services.club_context import filter_club_char_field
|
||||
queryset = Dianpu.query.select_related('pingzheng').all()
|
||||
queryset = filter_club_char_field(queryset, request, field='club_id')
|
||||
|
||||
if dianpu_mingcheng:
|
||||
queryset = queryset.filter(dianpu_mingcheng__icontains=dianpu_mingcheng)
|
||||
@@ -189,6 +191,7 @@ class ShopListView(APIView):
|
||||
'kaiqi_meiri_xiane': dianpu.kaiqi_meiri_xiane,
|
||||
'pingzheng__yonghu': pingzheng.yonghu, # 用户ID
|
||||
'pingzheng__zhanghao': pingzheng.zhanghao, # 登录账号
|
||||
'club_id': getattr(dianpu, 'club_id', '') or '',
|
||||
})
|
||||
|
||||
# 获取默认配置(ID=1,不存在则创建)
|
||||
@@ -327,6 +330,7 @@ class ShopModifyView(APIView):
|
||||
mima=mima,
|
||||
is_active=True
|
||||
)
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
dianpu = Dianpu.query.create(
|
||||
pingzheng=pingzheng,
|
||||
dianpu_mingcheng=dianpu_mingcheng,
|
||||
@@ -336,6 +340,7 @@ class ShopModifyView(APIView):
|
||||
shouyi_choucheng_feilv=request.data.get('shouyi_choucheng_feilv', 0),
|
||||
kaiqi_meiri_xiane=request.data.get('kaiqi_meiri_xiane', False),
|
||||
meiri_tixian_xiane=request.data.get('meiri_tixian_xiane', 0),
|
||||
club_id=resolve_club_id_for_write(request),
|
||||
)
|
||||
return Response({'code': 0, 'msg': '店铺创建成功', 'dianpu_id': dianpu.id})
|
||||
except IntegrityError as e:
|
||||
@@ -416,15 +421,38 @@ class ShopPublicTypeAndAuditView(APIView):
|
||||
logger.exception("查询公共商品类型失败")
|
||||
return Response({'code': 500, 'msg': '服务器错误,请稍后重试'})
|
||||
|
||||
# ---------- 获取审核模式配置 ----------
|
||||
audit_config = DianpuShangpinShenheShezhi.query.filter(id=1).first()
|
||||
kaiqi_shenhe = audit_config.kaiqi_shenhe if audit_config else False
|
||||
# ---------- 获取本俱乐部审核模式配置 ----------
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
# 集团汇总:不自动创建,返回默认值提示前端切到具体俱乐部
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'public_types': type_list,
|
||||
'kaiqi_shenhe': False,
|
||||
'zhi_kan_shenhe': True,
|
||||
'club_scoped': False,
|
||||
'msg': '请切换到具体俱乐部查看/修改审核展示开关',
|
||||
}
|
||||
})
|
||||
|
||||
club_id = resolve_club_id_for_write(request)
|
||||
audit_config = get_or_create_shenhe_config(club_id)
|
||||
kaiqi_shenhe = audit_config.kaiqi_shenhe
|
||||
zhi_kan_shenhe = bool(getattr(audit_config, 'zhi_kan_shenhe', True))
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'public_types': type_list,
|
||||
'kaiqi_shenhe': kaiqi_shenhe
|
||||
'kaiqi_shenhe': kaiqi_shenhe,
|
||||
'zhi_kan_shenhe': zhi_kan_shenhe,
|
||||
'club_id': club_id,
|
||||
'club_scoped': True,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -458,8 +486,10 @@ class ShopListForProductView(APIView):
|
||||
# 店铺状态筛选(1正常,0封禁,不传则全部)
|
||||
zhuangtai = request.data.get('zhuangtai')
|
||||
|
||||
# ---------- 构建查询集(关联凭证表获取用户ID) ----------
|
||||
# ---------- 构建查询集(关联凭证表获取用户ID;按俱乐部隔离) ----------
|
||||
from jituan.services.club_context import filter_club_char_field
|
||||
shops = Dianpu.query.select_related('pingzheng').all()
|
||||
shops = filter_club_char_field(shops, request, field='club_id')
|
||||
|
||||
# 状态筛选:只接受 0 或 1
|
||||
if zhuangtai is not None:
|
||||
@@ -615,6 +645,8 @@ class ShopProductModifyView(APIView):
|
||||
|
||||
if action == 'update_audit_mode':
|
||||
return self._update_audit_mode(request, user_perms)
|
||||
elif action == 'update_zhi_kan_shenhe':
|
||||
return self._update_zhi_kan_shenhe(request, user_perms)
|
||||
elif action == 'update_mapping_status':
|
||||
return self._update_mapping_status(request, user_perms)
|
||||
else:
|
||||
@@ -622,22 +654,61 @@ class ShopProductModifyView(APIView):
|
||||
|
||||
@transaction.atomic # ✅ 事务修复点
|
||||
def _update_audit_mode(self, request, user_perms):
|
||||
if not has_perm_code(permissions, '1199ab'):
|
||||
if not has_perm_code(user_perms, '1199ab'):
|
||||
return Response({'code': 403, 'msg': '无权限修改审核模式'})
|
||||
|
||||
kaiqi = request.data.get('kaiqi_shenhe')
|
||||
if kaiqi is None:
|
||||
return Response({'code': 400, 'msg': '缺少参数 kaiqi_shenhe'})
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改审核模式'})
|
||||
|
||||
try:
|
||||
config, _ = DianpuShangpinShenheShezhi.objects.select_for_update().get_or_create(id=1)
|
||||
club_id = resolve_club_id_for_write(request)
|
||||
config = get_or_create_shenhe_config(club_id)
|
||||
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
|
||||
config.kaiqi_shenhe = bool(kaiqi)
|
||||
config.save(update_fields=['kaiqi_shenhe'])
|
||||
return Response({'code': 0, 'msg': '审核模式已更新'})
|
||||
return Response({'code': 0, 'msg': '审核模式已更新', 'data': {'club_id': club_id}})
|
||||
except Exception as e:
|
||||
logger.exception("更新审核模式失败")
|
||||
return Response({'code': 500, 'msg': '服务器错误'})
|
||||
|
||||
@transaction.atomic
|
||||
def _update_zhi_kan_shenhe(self, request, user_perms):
|
||||
"""点单端:未绑店用户是否按身份只看审核专用商品(按俱乐部)。"""
|
||||
if not has_perm_code(user_perms, '1199ab'):
|
||||
return Response({'code': 403, 'msg': '无权限修改只看审核开关'})
|
||||
|
||||
val = request.data.get('zhi_kan_shenhe')
|
||||
if val is None:
|
||||
return Response({'code': 400, 'msg': '缺少参数 zhi_kan_shenhe'})
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改只看审核开关'})
|
||||
|
||||
try:
|
||||
club_id = resolve_club_id_for_write(request)
|
||||
config = get_or_create_shenhe_config(club_id)
|
||||
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
|
||||
config.zhi_kan_shenhe = bool(val)
|
||||
config.save(update_fields=['zhi_kan_shenhe'])
|
||||
return Response({'code': 0, 'msg': '只看审核开关已更新', 'data': {'club_id': club_id}})
|
||||
except Exception:
|
||||
logger.exception("更新只看审核开关失败")
|
||||
return Response({'code': 500, 'msg': '服务器错误'})
|
||||
|
||||
@transaction.atomic
|
||||
def _update_mapping_status(self, request, user_perms):
|
||||
mapping_id = request.data.get('mapping_id')
|
||||
|
||||
@@ -236,21 +236,36 @@ class GetProductTypeZoneView(APIView):
|
||||
return permissions
|
||||
if '7007a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
# 查询所有商品类型(含专区数量)
|
||||
# 一次性分组聚合所有类型的专区数量,避免循环内 N+1 count
|
||||
# 注:ShangpinZhuanqu.leixing_id 是 PositiveIntegerField(非 FK),无法用反向 annotate
|
||||
zone_count_rows = ShangpinZhuanqu.query.values('leixing_id').annotate(cnt=Count('id'))
|
||||
zone_count_map = {row['leixing_id']: row['cnt'] for row in zone_count_rows}
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope, resolve_club_id_from_request
|
||||
from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
|
||||
|
||||
types = ShangpinLeixing.query.all().order_by('-paixu') # paixu 越大越靠前
|
||||
club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
|
||||
club_id = resolve_club_id_from_request(request)
|
||||
|
||||
try:
|
||||
zone_qs_for_count = filter_club_char_field(
|
||||
ShangpinZhuanqu.query.all(), request, field='club_id'
|
||||
)
|
||||
zone_count_map = {
|
||||
row['leixing_id']: row['cnt']
|
||||
for row in zone_qs_for_count.values('leixing_id').annotate(cnt=Count('id'))
|
||||
}
|
||||
|
||||
if club_scoped:
|
||||
# 具体俱乐部:仅本俱乐部已上架类型(与抢单端一致)
|
||||
admin_payload = build_club_leixing_admin_list(request)
|
||||
type_list = []
|
||||
for t in types:
|
||||
# 从预聚合的 map 中取专区数量
|
||||
zone_count = zone_count_map.get(t.id, 0)
|
||||
for item in (admin_payload.get('list') or []):
|
||||
type_list.append({
|
||||
**item,
|
||||
'product_count': zone_count_map.get(item['id'], 0),
|
||||
'club_enabled': True,
|
||||
})
|
||||
else:
|
||||
type_list = []
|
||||
for t in ShangpinLeixing.query.all().order_by('-paixu'):
|
||||
type_list.append({
|
||||
'id': t.id,
|
||||
'jieshao': t.jieshao or '',
|
||||
@@ -260,11 +275,12 @@ class GetProductTypeZoneView(APIView):
|
||||
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
|
||||
'shenhezhuangtai': t.shenhezhuangtai,
|
||||
'paixu': t.paixu,
|
||||
'product_count': zone_count,
|
||||
'product_count': zone_count_map.get(t.id, 0),
|
||||
})
|
||||
|
||||
# 查询所有专区
|
||||
zones = ShangpinZhuanqu.query.all().order_by('-paixu')
|
||||
zones = filter_club_char_field(
|
||||
ShangpinZhuanqu.query.all().order_by('-paixu'), request, field='club_id'
|
||||
)
|
||||
zone_list = [
|
||||
{
|
||||
'id': z.id,
|
||||
@@ -272,19 +288,18 @@ class GetProductTypeZoneView(APIView):
|
||||
'leixing_id': z.leixing_id,
|
||||
'shenhezhuangtai': z.shenhezhuangtai,
|
||||
'paixu': z.paixu,
|
||||
'club_id': getattr(z, 'club_id', '') or '',
|
||||
}
|
||||
for z in zones
|
||||
]
|
||||
|
||||
# 查询所有会员(只返回 id、介绍、价格)
|
||||
members = Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
|
||||
member_list = [
|
||||
{
|
||||
'huiyuan_id': m['huiyuan_id'],
|
||||
'jieshao': m['jieshao'],
|
||||
'jiage': float(m['jiage']),
|
||||
'jieshao': m['jieshao'] or '',
|
||||
'jiage': float(m['jiage'] or 0),
|
||||
}
|
||||
for m in members
|
||||
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
|
||||
]
|
||||
|
||||
return Response({
|
||||
@@ -293,8 +308,13 @@ class GetProductTypeZoneView(APIView):
|
||||
'type_list': type_list,
|
||||
'zone_list': zone_list,
|
||||
'member_list': member_list,
|
||||
'club_scoped': club_scoped,
|
||||
'club_id': club_id if club_scoped else '',
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception('GetProductTypeZoneView 失败')
|
||||
return Response({'code': 500, 'msg': '获取类型专区失败,请稍后重试'})
|
||||
|
||||
|
||||
|
||||
@@ -321,11 +341,12 @@ class ModifyProductTypeZoneView(APIView):
|
||||
return permissions
|
||||
if '7007a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
||||
|
||||
# 类型仍须集团汇总;专区允许在具体俱乐部操作
|
||||
if obj_type == 'leixing':
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
|
||||
if obj_type == 'leixing':
|
||||
return self.handle_leixing(request, action)
|
||||
elif obj_type == 'zhuanqu':
|
||||
return self.handle_zhuanqu(request, action)
|
||||
@@ -566,6 +587,24 @@ class ModifyProductTypeZoneView(APIView):
|
||||
else:
|
||||
return Response({'code': 400, 'msg': '无效的action'})
|
||||
|
||||
def _upload_zhuanqu_icon(self, image_file):
|
||||
"""专区图标上传到 OSS,相对路径前缀 shangpin/zhuanqu/(按俱乐部存库,路径共用前缀)。"""
|
||||
try:
|
||||
valid, msg = validate_image(image_file)
|
||||
if not valid:
|
||||
return None, msg
|
||||
except ImportError:
|
||||
pass
|
||||
ext = image_file.name.split('.')[-1].lower() if image_file.name and '.' in image_file.name else 'png'
|
||||
if ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp'):
|
||||
ext = 'png'
|
||||
new_filename = f"{int(time.time())}_{uuid.uuid4().hex[:12]}.{ext}"
|
||||
oss_path = f"shangpin/zhuanqu/{new_filename}"
|
||||
url = upload_to_oss(image_file, oss_path)
|
||||
if not url:
|
||||
return None, '图片上传失败'
|
||||
return oss_path, None
|
||||
|
||||
def add_zhuanqu(self, request):
|
||||
mingzi = request.data.get('mingzi', '').strip()
|
||||
leixing_id = request.data.get('leixing_id')
|
||||
@@ -588,17 +627,37 @@ class ModifyProductTypeZoneView(APIView):
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'})
|
||||
|
||||
# 图标:优先上传文件;否则可选手填相对路径(兼容旧用法)
|
||||
tupian_url = (request.data.get('tupian_url') or '').strip()
|
||||
image_file = request.FILES.get('file')
|
||||
uploaded_path = None
|
||||
if image_file:
|
||||
uploaded_path, err = self._upload_zhuanqu_icon(image_file)
|
||||
if err:
|
||||
return Response({'code': 500 if err == '图片上传失败' else 400, 'msg': err})
|
||||
tupian_url = uploaded_path
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
club_id = resolve_club_id_for_write(request)
|
||||
zhuanqu = ShangpinZhuanqu.query.create(
|
||||
mingzi=mingzi,
|
||||
leixing_id=leixing_id,
|
||||
shenhezhuangtai=shenhezhuangtai,
|
||||
paixu=0
|
||||
paixu=0,
|
||||
club_id=club_id,
|
||||
tupian_url=tupian_url,
|
||||
)
|
||||
return Response({'code': 0, 'msg': '添加成功', 'data': {'id': zhuanqu.id}})
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '添加成功',
|
||||
'data': {'id': zhuanqu.id, 'club_id': club_id, 'tupian_url': tupian_url},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"添加专区失败: {e}", exc_info=True)
|
||||
if uploaded_path:
|
||||
delete_from_oss(uploaded_path)
|
||||
return Response({'code': 500, 'msg': '数据库错误'})
|
||||
|
||||
def update_zhuanqu(self, request):
|
||||
@@ -610,6 +669,15 @@ class ModifyProductTypeZoneView(APIView):
|
||||
except ShangpinZhuanqu.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '专区不存在'})
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||
write_club = resolve_club_id_for_write(request)
|
||||
zone_club = getattr(zhuanqu, 'club_id', None) or 'xq'
|
||||
if zone_club != write_club:
|
||||
return Response({'code': 403, 'msg': '专区不属于当前俱乐部'})
|
||||
|
||||
changes = {}
|
||||
mingzi = request.data.get('mingzi')
|
||||
if mingzi is not None and mingzi.strip() != zhuanqu.mingzi:
|
||||
@@ -637,6 +705,20 @@ class ModifyProductTypeZoneView(APIView):
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'})
|
||||
|
||||
old_image_path = getattr(zhuanqu, 'tupian_url', None) or ''
|
||||
image_file = request.FILES.get('file')
|
||||
uploaded_path = None
|
||||
if image_file:
|
||||
uploaded_path, err = self._upload_zhuanqu_icon(image_file)
|
||||
if err:
|
||||
return Response({'code': 500 if err == '图片上传失败' else 400, 'msg': err})
|
||||
if uploaded_path != old_image_path:
|
||||
changes['tupian_url'] = uploaded_path
|
||||
elif 'tupian_url' in request.data:
|
||||
new_url = (request.data.get('tupian_url') or '').strip()
|
||||
if new_url != old_image_path:
|
||||
changes['tupian_url'] = new_url
|
||||
|
||||
if not changes:
|
||||
return Response({'code': 400, 'msg': '未做任何修改'})
|
||||
|
||||
@@ -645,9 +727,18 @@ class ModifyProductTypeZoneView(APIView):
|
||||
for field, value in changes.items():
|
||||
setattr(zhuanqu, field, value)
|
||||
zhuanqu.save()
|
||||
return Response({'code': 0, 'msg': '修改成功'})
|
||||
# 换图或清空后删旧 OSS(新上传路径与旧不同时)
|
||||
if 'tupian_url' in changes and old_image_path and old_image_path != changes['tupian_url']:
|
||||
delete_from_oss(old_image_path)
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '修改成功',
|
||||
'data': {'id': zhuanqu.id, 'tupian_url': getattr(zhuanqu, 'tupian_url', '') or ''},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"更新专区失败: {e}", exc_info=True)
|
||||
if uploaded_path:
|
||||
delete_from_oss(uploaded_path)
|
||||
return Response({'code': 500, 'msg': '修改失败'})
|
||||
|
||||
def delete_zhuanqu(self, request):
|
||||
@@ -661,6 +752,16 @@ class ModifyProductTypeZoneView(APIView):
|
||||
except ShangpinZhuanqu.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '专区不存在'})
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||
write_club = resolve_club_id_for_write(request)
|
||||
zone_club = getattr(zhuanqu, 'club_id', None) or 'xq'
|
||||
if zone_club != write_club:
|
||||
return Response({'code': 403, 'msg': '专区不属于当前俱乐部'})
|
||||
|
||||
old_image_path = getattr(zhuanqu, 'tupian_url', None) or ''
|
||||
with transaction.atomic():
|
||||
if delete_products:
|
||||
Shangpin.query.filter(zhuanqu_id=zone_id).delete()
|
||||
@@ -668,6 +769,9 @@ class ModifyProductTypeZoneView(APIView):
|
||||
Shangpin.query.filter(zhuanqu_id=zone_id).update(zhuanqu_id=None)
|
||||
zhuanqu.delete()
|
||||
|
||||
if old_image_path:
|
||||
delete_from_oss(old_image_path)
|
||||
|
||||
return Response({'code': 0, 'msg': '删除成功'})
|
||||
|
||||
|
||||
|
||||
@@ -175,6 +175,58 @@ class KhpzhqView(APIView):
|
||||
|
||||
|
||||
|
||||
_CHENGHAO_LEIXING_OK = frozenset({'dashou', 'boss', 'shangjia', 'guanshi', 'zuzhang'})
|
||||
_CHENGHAO_LEIXING_ALIAS = {
|
||||
'打手': 'dashou',
|
||||
'老板': 'boss',
|
||||
'商家': 'shangjia',
|
||||
'管事': 'guanshi',
|
||||
'组长': 'zuzhang',
|
||||
}
|
||||
|
||||
|
||||
def _normalize_chenghao_leixing(raw, default='dashou'):
|
||||
"""兼容 null / 中文显示名 / 首尾空格,统一为合法 leixing 代码。"""
|
||||
if raw is None or raw == '':
|
||||
return default
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return default
|
||||
if s in _CHENGHAO_LEIXING_OK:
|
||||
return s
|
||||
mapped = _CHENGHAO_LEIXING_ALIAS.get(s) or _CHENGHAO_LEIXING_ALIAS.get(s.lower())
|
||||
if mapped:
|
||||
return mapped
|
||||
lower = s.lower()
|
||||
if lower in _CHENGHAO_LEIXING_OK:
|
||||
return lower
|
||||
return None
|
||||
|
||||
|
||||
def _parse_optional_bankuai_id(raw):
|
||||
"""空值视为未绑定板块;非法/不存在返回 False。"""
|
||||
if raw is None or raw == '':
|
||||
return None
|
||||
try:
|
||||
bid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if bid <= 0:
|
||||
return None
|
||||
if not Bankuai.query.filter(bankuai_id=bid).exists():
|
||||
return False
|
||||
return bid
|
||||
|
||||
|
||||
def _parse_chenghao_id(raw):
|
||||
if raw is None or str(raw).strip() == '':
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class ChzsgcView(APIView):
|
||||
"""
|
||||
称号(标签)增删改查统一接口
|
||||
@@ -197,18 +249,21 @@ class ChzsgcView(APIView):
|
||||
|
||||
# ========== 创建称号 ==========
|
||||
if action == 'create':
|
||||
mingcheng = request.data.get('mingcheng', '').strip()
|
||||
leixing = request.data.get('leixing', 'dashou')
|
||||
bankuai_id = request.data.get('bankuai_id')
|
||||
texiao_miaoshu = request.data.get('texiao_miaoshu', '')
|
||||
kaohe_guize = request.data.get('kaohe_guize', '')
|
||||
kaioi_jinpai = request.data.get('kaioi_jinpai', False)
|
||||
mingcheng = (request.data.get('mingcheng') or '').strip()
|
||||
leixing = _normalize_chenghao_leixing(request.data.get('leixing'), default='dashou')
|
||||
bankuai_id = _parse_optional_bankuai_id(request.data.get('bankuai_id'))
|
||||
texiao_miaoshu = request.data.get('texiao_miaoshu', '') or ''
|
||||
kaohe_guize = request.data.get('kaohe_guize', '') or ''
|
||||
kaioi_jinpai = bool(request.data.get('kaioi_jinpai', False))
|
||||
feiyong_list = request.data.get('feiyong_list', [])
|
||||
|
||||
if not mingcheng:
|
||||
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
|
||||
if leixing not in ['dashou','boss','shangjia','guanshi','zuzhang']:
|
||||
if leixing is None:
|
||||
logger.warning('ChzsgcView create 无效 leixing=%r', request.data.get('leixing'))
|
||||
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
|
||||
if bankuai_id is False:
|
||||
return Response({'code': 1, 'msg': '板块不存在'}, status=400)
|
||||
if Chenghao.query.filter(mingcheng=mingcheng).exists():
|
||||
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
|
||||
|
||||
@@ -238,7 +293,7 @@ class ChzsgcView(APIView):
|
||||
|
||||
# ========== 修改称号 ==========
|
||||
elif action == 'update':
|
||||
ch_id = request.data.get('chenghao_id')
|
||||
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||
if not ch_id:
|
||||
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
|
||||
try:
|
||||
@@ -246,17 +301,25 @@ class ChzsgcView(APIView):
|
||||
except Chenghao.DoesNotExist:
|
||||
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
|
||||
|
||||
mingcheng = request.data.get('mingcheng', ch.mingcheng).strip()
|
||||
leixing = request.data.get('leixing', ch.leixing)
|
||||
bankuai_id = request.data.get('bankuai_id', ch.bankuai_id)
|
||||
mingcheng = (request.data.get('mingcheng') if request.data.get('mingcheng') is not None else ch.mingcheng) or ''
|
||||
mingcheng = str(mingcheng).strip()
|
||||
raw_leixing = request.data.get('leixing', ch.leixing)
|
||||
leixing = _normalize_chenghao_leixing(raw_leixing, default=ch.leixing or 'dashou')
|
||||
if 'bankuai_id' in request.data:
|
||||
bankuai_id = _parse_optional_bankuai_id(request.data.get('bankuai_id'))
|
||||
else:
|
||||
bankuai_id = ch.bankuai_id
|
||||
texiao_miaoshu = request.data.get('texiao_miaoshu', ch.texiao_miaoshu)
|
||||
kaohe_guize = request.data.get('kaohe_guize', ch.kaohe_guize)
|
||||
kaioi_jinpai = request.data.get('kaioi_jinpai', ch.kaioi_jinpai)
|
||||
|
||||
if not mingcheng:
|
||||
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
|
||||
if leixing not in ['dashou','boss','shangjia','guanshi','zuzhang']:
|
||||
if leixing is None:
|
||||
logger.warning('ChzsgcView update 无效 leixing=%r ch_id=%s', raw_leixing, ch_id)
|
||||
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
|
||||
if bankuai_id is False:
|
||||
return Response({'code': 1, 'msg': '板块不存在'}, status=400)
|
||||
if Chenghao.query.filter(mingcheng=mingcheng).exclude(id=ch_id).exists():
|
||||
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
|
||||
|
||||
@@ -265,7 +328,7 @@ class ChzsgcView(APIView):
|
||||
ch.bankuai_id = bankuai_id
|
||||
ch.texiao_miaoshu = texiao_miaoshu
|
||||
ch.kaohe_guize = kaohe_guize
|
||||
ch.kaioi_jinpai = kaioi_jinpai
|
||||
ch.kaioi_jinpai = bool(kaioi_jinpai)
|
||||
ch.save()
|
||||
|
||||
# 如果前端传递了 feiyong_list,则全量替换费用
|
||||
@@ -284,7 +347,7 @@ class ChzsgcView(APIView):
|
||||
|
||||
# ========== 删除称号 ==========
|
||||
elif action == 'delete':
|
||||
ch_id = request.data.get('chenghao_id')
|
||||
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||
if not ch_id:
|
||||
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
|
||||
try:
|
||||
@@ -295,11 +358,11 @@ class ChzsgcView(APIView):
|
||||
return Response({'code': 0, 'msg': '删除成功'})
|
||||
|
||||
# ========== 添加/修改某次费用 ==========
|
||||
elif action == 'add_feiyong':
|
||||
ch_id = request.data.get('chenghao_id')
|
||||
elif action in ('add_feiyong', 'update_feiyong'):
|
||||
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||
cishu = request.data.get('cishu')
|
||||
feiyong = request.data.get('feiyong', 0)
|
||||
if not ch_id or not cishu:
|
||||
if not ch_id or cishu is None or cishu == '':
|
||||
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
|
||||
try:
|
||||
ch = Chenghao.query.get(id=ch_id)
|
||||
@@ -308,16 +371,16 @@ class ChzsgcView(APIView):
|
||||
if int(cishu) < 1:
|
||||
return Response({'code': 1, 'msg': '次数必须>=1'}, status=400)
|
||||
KaoheCishuFeiyong.query.update_or_create(
|
||||
chenghao=ch, cishu=cishu,
|
||||
chenghao=ch, cishu=int(cishu),
|
||||
defaults={'feiyong': feiyong}
|
||||
)
|
||||
return Response({'code': 0, 'msg': '添加/修改费用成功'})
|
||||
|
||||
# ========== 删除某次费用 ==========
|
||||
elif action == 'remove_feiyong':
|
||||
ch_id = request.data.get('chenghao_id')
|
||||
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||
cishu = request.data.get('cishu')
|
||||
if not ch_id or not cishu:
|
||||
if not ch_id or cishu is None or cishu == '':
|
||||
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
|
||||
if int(cishu) == 1:
|
||||
return Response({'code': 1, 'msg': '不能删除第1次考核费用'}, status=400)
|
||||
@@ -325,13 +388,9 @@ class ChzsgcView(APIView):
|
||||
ch = Chenghao.query.get(id=ch_id)
|
||||
except Chenghao.DoesNotExist:
|
||||
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
|
||||
KaoheCishuFeiyong.query.filter(chenghao=ch, cishu=cishu).delete()
|
||||
KaoheCishuFeiyong.query.filter(chenghao=ch, cishu=int(cishu)).delete()
|
||||
return Response({'code': 0, 'msg': '删除成功'})
|
||||
|
||||
# ========== 修改某次费用(等价于add) ==========
|
||||
elif action == 'update_feiyong':
|
||||
return self.add_feiyong(request) # 复用逻辑
|
||||
|
||||
else:
|
||||
return Response({'code': 1, 'msg': '未知操作'}, status=400)
|
||||
|
||||
|
||||
@@ -505,8 +505,10 @@ class UpdateZuzhangView(APIView):
|
||||
if new_val > old_ketixian_jine:
|
||||
if '6600a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限增加可提现金额(需要6600a)'})
|
||||
from jituan.services.group_finance_gate import deny_group_finance_exec
|
||||
deny = deny_group_finance_exec(request.user, request)
|
||||
from jituan.services.group_finance_gate import deny_balance_add_exec
|
||||
deny = deny_balance_add_exec(
|
||||
request, permissions=permissions, target_user=user,
|
||||
)
|
||||
if deny:
|
||||
return deny
|
||||
action_desc = f'增加{new_val - old_ketixian_jine}元'
|
||||
@@ -519,6 +521,8 @@ class UpdateZuzhangView(APIView):
|
||||
if new_val != old_ketixian_jine:
|
||||
zuzhang.ketixian_jine = new_val
|
||||
zuzhang.save(update_fields=['ketixian_jine'])
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
club_id = resolve_club_id_from_request(request) or ''
|
||||
write_xiugai_log(
|
||||
yonghuid=yonghuid,
|
||||
xiugaiid=operator,
|
||||
@@ -527,7 +531,7 @@ class UpdateZuzhangView(APIView):
|
||||
xiugaitijiaoq=old_ketixian_jine,
|
||||
qitashuoming=(
|
||||
f'【后台】修改组长可提现金额:{old_ketixian_jine}元 → {new_val}元({action_desc}),'
|
||||
f'组长ID={yonghuid},操作人={operator}'
|
||||
f'组长ID={yonghuid},俱乐部={club_id},操作人={operator}'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.urls import path
|
||||
from .views import ShangpinGonggaoView, AdminUpdateConfigView, AdminUploadImageView, AdminConfigQueryView, \
|
||||
DashouPaihangView,GetAdminZhanghaoView, GetXiugaiJiluView,ShangjiaTianjiaMobanView, ShangjiaMobanListView, \
|
||||
ShangjiaGengxinMobanView, ShangjiaShanchuMobanView, ShangjiaGenerateLinkView,KehuGetDingdanLianjieView, \
|
||||
KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView,\
|
||||
KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView, CYaoqingQRCodeView,\
|
||||
GetDynamicConfigView, HaibaoPeizhiView, PopupConfigView, GetWithdrawModeView,CheckPhoneAuthView,\
|
||||
ShangjiaLianjieListView, HuashuHqView, HuashuMatchView
|
||||
|
||||
@@ -31,6 +31,7 @@ urlpatterns = [
|
||||
path('gzal', GuanZhuAListView.as_view(), name='关注阿龙'),
|
||||
path('guanshiewm', GuanshiQRCodeView.as_view(), name='管事生成二维码'),
|
||||
path('zuzhanghb', ZuzhangHaibaoView.as_view(), name='组长二维码'),
|
||||
path('cyaoqingewm', CYaoqingQRCodeView.as_view(), name='点单端邀请二维码'),
|
||||
path('qdpzhq', GetDynamicConfigView.as_view(), name='小程序获取配置'),
|
||||
path('haibaopz', HaibaoPeizhiView.as_view(), name='推广海报配置'),
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ from .merchant_links import (
|
||||
IPUtils, OrderLinkThrottle,
|
||||
)
|
||||
from .qrcode_poster import (
|
||||
GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView,
|
||||
GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView, CYaoqingQRCodeView,
|
||||
)
|
||||
from .scripts import (
|
||||
HuashuHqView, HuashuMatchView,
|
||||
@@ -65,7 +65,7 @@ __all__ = [
|
||||
'KehuGetDingdanLianjieView', 'KehuTianxieDingdanView',
|
||||
'IPUtils', 'OrderLinkThrottle',
|
||||
# qrcode_poster
|
||||
'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView',
|
||||
'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView', 'CYaoqingQRCodeView',
|
||||
# scripts
|
||||
'HuashuHqView', 'HuashuMatchView',
|
||||
'MiniappScriptHoutaiListView', 'MiniappScriptHoutaiModifyView',
|
||||
|
||||
@@ -81,15 +81,23 @@ class ShangpinGonggaoView(APIView):
|
||||
"""
|
||||
try:
|
||||
logger.info("收到商品公告和轮播图请求")
|
||||
from jituan.services.display_config import get_gonggao_content, get_lunbo_urls, normalize_page_key
|
||||
from jituan.services.display_config import (
|
||||
get_gonggao_content,
|
||||
get_lunbo_urls,
|
||||
lunbo_image_type_for_page,
|
||||
normalize_page_key,
|
||||
)
|
||||
|
||||
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
|
||||
image_type = lunbo_image_type_for_page(page_key)
|
||||
gonggao_content = get_gonggao_content(request, notice_type=1, page_key=page_key)
|
||||
lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=1)
|
||||
lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=image_type)
|
||||
from jituan.services.club_context import resolve_effective_club_id
|
||||
response_data = {
|
||||
"shangpingonggao": gonggao_content,
|
||||
"shangpinlunbo": lunbo_urls,
|
||||
"page_key": page_key,
|
||||
"club_id": resolve_effective_club_id(request, None),
|
||||
}
|
||||
logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}")
|
||||
# 4. 返回响应
|
||||
|
||||
@@ -46,8 +46,10 @@ from users.models import (
|
||||
)
|
||||
from users.business_models import User
|
||||
|
||||
from orders.models import CommissionRate, Order, MerchantOrderExt
|
||||
from orders.models import Order, MerchantOrderExt
|
||||
from orders.notice_tasks import dingdan_guangbo
|
||||
from orders.utils import calc_shangjia_order_fencheng
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
|
||||
from rank.models import Chenghao, DingdanBiaoqian
|
||||
|
||||
@@ -103,12 +105,9 @@ class ShangjiaGenerateLinkView(APIView):
|
||||
if jiage > shangjia.yue:
|
||||
return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元', 'data': {}}, status=400)
|
||||
|
||||
# 利率
|
||||
try:
|
||||
lilu = float(CommissionRate.query.get(Platform='3').Rate) or 1.0
|
||||
except CommissionRate.DoesNotExist:
|
||||
lilu = 1.0
|
||||
dashou_fencheng = Decimal(str(round(float(jiage) * lilu, 2)))
|
||||
# 打手+管事分成(与普通派单 sjpaifa 同一套算法,打手优先)
|
||||
club_id = resolve_club_id_from_request(request)
|
||||
dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage, club_id=club_id)
|
||||
|
||||
# 抢单要求:根据模板是否启用佣金
|
||||
chenghao_id = moban.TitleID
|
||||
@@ -154,13 +153,14 @@ class ShangjiaGenerateLinkView(APIView):
|
||||
dingdan_id = generate_dingdan_id()
|
||||
secure_token = generate_secure_token(dingdan_id, user.UserUID)
|
||||
|
||||
# 创建订单主表
|
||||
# 创建订单主表(分红在生成链接时锁定,填表入池不再改)
|
||||
dingdan = Order.query.create(
|
||||
OrderID=dingdan_id,
|
||||
Status=13,
|
||||
Platform=2,
|
||||
Amount=jiage,
|
||||
PlayerCommission=dashou_fencheng,
|
||||
ManagerCommission=guanshi_fencheng,
|
||||
ProductTypeID=shangpin_type_id,
|
||||
GrabRequirement=final_yaoqiuleixing,
|
||||
MembershipID=final_huiyuan_id,
|
||||
@@ -168,6 +168,7 @@ class ShangjiaGenerateLinkView(APIView):
|
||||
Description=moban.TemplateDesc[:2000],
|
||||
User1ID=f"Sj{user.UserUID}",
|
||||
ImageURL=user.Avatar or '',
|
||||
ClubID=club_id,
|
||||
)
|
||||
|
||||
# 商家扩展表
|
||||
|
||||
@@ -269,6 +269,16 @@ class CheckPhoneAuthView(APIView):
|
||||
except User.DoesNotExist:
|
||||
user = request.user
|
||||
|
||||
# 俱乐部关闭强制收集手机号 → 前端 ensurePhoneAuth 不再跳转认证页
|
||||
try:
|
||||
from jituan.services.club_context import resolve_effective_club_id
|
||||
from jituan.services.force_phone_auth import is_club_force_phone_auth
|
||||
club_id = resolve_effective_club_id(request, user)
|
||||
if not is_club_force_phone_auth(club_id):
|
||||
return Response({'code': 0, 'need_auth': False})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._phone_auth_completed(user):
|
||||
return Response({'code': 0, 'need_auth': False})
|
||||
|
||||
|
||||
@@ -351,9 +351,137 @@ class GuanshiQRCodeView(APIView):
|
||||
return None
|
||||
|
||||
|
||||
class CYaoqingQRCodeView(APIView):
|
||||
"""
|
||||
点单端老板推荐官小程序码(落地点单首页,按 UserUID 绑定,无邀请码)。
|
||||
POST /peizhi/cyaoqingewm
|
||||
"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
current_user = request.user
|
||||
try:
|
||||
from jituan.services.club_context import resolve_effective_club_id
|
||||
from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid
|
||||
from users.services.c_invite import get_or_create_c_yaoqing
|
||||
from users.models import YonghuCYaoqing
|
||||
|
||||
if not getattr(current_user, 'UserUID', None):
|
||||
return Response(
|
||||
{'code': 500, 'message': '用户身份异常', 'data': None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
club_id = resolve_effective_club_id(request, current_user)
|
||||
profile = get_or_create_c_yaoqing(current_user)
|
||||
inviter_uid = str(current_user.UserUID)
|
||||
|
||||
access_token = get_club_mini_access_token(club_id)
|
||||
if not access_token:
|
||||
return Response(
|
||||
{'code': 500, 'message': '获取微信access_token失败', 'data': None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# 必须进点单首页,禁止进打手/管事页
|
||||
page_path = f'pages/index/index?yaoqingren={urllib.parse.quote(inviter_uid, safe="")}'
|
||||
post_data = {
|
||||
'path': page_path,
|
||||
'width': 430,
|
||||
'auto_color': False,
|
||||
'line_color': {'r': 0, 'g': 0, 'b': 0},
|
||||
}
|
||||
|
||||
def _request_qr(token):
|
||||
try:
|
||||
return requests.post(
|
||||
f'https://api.weixin.qq.com/wxa/getwxacode?access_token={token}',
|
||||
json=post_data,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('请求微信接口异常: %s', e, exc_info=True)
|
||||
return None
|
||||
|
||||
wx_resp = _request_qr(access_token)
|
||||
if wx_resp is None:
|
||||
return Response(
|
||||
{'code': 500, 'message': '调用微信服务失败', 'data': None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
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_club_mini_access_token(club_id, force_refresh=True)
|
||||
if access_token:
|
||||
wx_resp = _request_qr(access_token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if wx_resp.status_code != 200:
|
||||
errmsg = '未知错误'
|
||||
try:
|
||||
errmsg = wx_resp.json().get('errmsg', errmsg)
|
||||
except Exception:
|
||||
pass
|
||||
return Response(
|
||||
{'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
content_type = wx_resp.headers.get('content-type', '')
|
||||
if 'image' not in content_type:
|
||||
try:
|
||||
errmsg = wx_resp.json().get('errmsg', '未知错误')
|
||||
except Exception:
|
||||
errmsg = '生成二维码失败'
|
||||
return Response(
|
||||
{'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
timestamp = int(time.time())
|
||||
filename = f'{current_user.UserUID}_{timestamp}.png'
|
||||
oss_path = f'erweima/cuser/{club_id}/{filename}'
|
||||
file_obj = io.BytesIO(wx_resp.content)
|
||||
full_url = upload_to_oss(file_obj, oss_path)
|
||||
if not full_url:
|
||||
return Response(
|
||||
{'code': 500, 'message': '图片上传失败', 'data': None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
cos_domain = getattr(settings, 'COS_DOMAIN', '')
|
||||
if cos_domain and full_url.startswith(cos_domain):
|
||||
relative_path = full_url[len(cos_domain):].lstrip('/')
|
||||
else:
|
||||
relative_path = oss_path
|
||||
|
||||
old_url = profile.invite_qrcode_url
|
||||
if old_url:
|
||||
try:
|
||||
delete_from_oss(old_url)
|
||||
except Exception as e:
|
||||
logger.warning('删除旧点单端推荐二维码失败: %s', e)
|
||||
|
||||
with transaction.atomic():
|
||||
locked = YonghuCYaoqing.objects.select_for_update().get(pk=profile.pk)
|
||||
locked.invite_qrcode_url = relative_path
|
||||
locked.save(update_fields=['invite_qrcode_url', 'UpdateTime'])
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {'url': relative_path, 'uid': inviter_uid},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('生成点单端推荐二维码失败: %s', e, exc_info=True)
|
||||
return Response(
|
||||
{'code': 500, 'message': '服务器内部错误', 'data': None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class ZuzhangHaibaoView(APIView):
|
||||
|
||||
0
gongdan/__init__.py
Normal file
0
gongdan/__init__.py
Normal file
8
gongdan/apps.py
Normal file
8
gongdan/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""投诉工单应用。"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GongdanConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gongdan'
|
||||
verbose_name = '投诉工单'
|
||||
60
gongdan/constants.py
Normal file
60
gongdan/constants.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""投诉工单常量。"""
|
||||
|
||||
# 投诉类型
|
||||
TYPE_ORDER = 1 # 订单投诉
|
||||
TYPE_RECHARGE = 2 # 充值投诉
|
||||
TYPE_PENALTY = 3 # 罚款投诉
|
||||
TYPE_GUANSHI = 4 # 管事投诉
|
||||
TYPE_FRAUD = 5 # 被骗投诉
|
||||
TYPE_ZUZHANG = 6 # 组长投诉
|
||||
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_ORDER, '订单投诉'),
|
||||
(TYPE_RECHARGE, '充值投诉'),
|
||||
(TYPE_PENALTY, '罚款投诉'),
|
||||
(TYPE_GUANSHI, '管事投诉'),
|
||||
(TYPE_FRAUD, '被骗投诉'),
|
||||
(TYPE_ZUZHANG, '组长投诉'),
|
||||
)
|
||||
|
||||
TYPE_LABELS = dict(TYPE_CHOICES)
|
||||
|
||||
# 状态:1处理中 2已处理 3不满意 4已关闭 5协商中
|
||||
STATUS_PROCESSING = 1
|
||||
STATUS_DONE = 2
|
||||
STATUS_DISSATISFIED = 3
|
||||
STATUS_CLOSED = 4
|
||||
STATUS_NEGOTIATING = 5
|
||||
|
||||
STATUS_CHOICES = (
|
||||
(STATUS_PROCESSING, '处理中'),
|
||||
(STATUS_DONE, '已处理'),
|
||||
(STATUS_DISSATISFIED, '不满意'),
|
||||
(STATUS_CLOSED, '已关闭'),
|
||||
(STATUS_NEGOTIATING, '协商中'),
|
||||
)
|
||||
|
||||
STATUS_LABELS = dict(STATUS_CHOICES)
|
||||
|
||||
# 未结清(拦提现):处理中 / 不满意 / 协商中
|
||||
OPEN_STATUSES = (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING)
|
||||
|
||||
TARGET_ROLE_DASHOU = 'dashou'
|
||||
TARGET_ROLE_GUANSHI = 'guanshi'
|
||||
TARGET_ROLE_ZUZHANG = 'zuzhang'
|
||||
TARGET_ROLE_PLATFORM = 'platform'
|
||||
TARGET_ROLE_LABELS = {
|
||||
TARGET_ROLE_DASHOU: '接单员',
|
||||
TARGET_ROLE_GUANSHI: '管事',
|
||||
TARGET_ROLE_ZUZHANG: '组长',
|
||||
TARGET_ROLE_PLATFORM: '平台',
|
||||
}
|
||||
|
||||
MAX_IMAGES = 9
|
||||
MAX_SHUOMING_LEN = 500
|
||||
MAX_SHENGJI_TIMES = 5
|
||||
OSS_PREFIX = 'gongdan'
|
||||
|
||||
# 权限码
|
||||
PERM_VIEW = 'gdck666'
|
||||
PERM_HANDLE = 'gdcl666'
|
||||
0
gongdan/management/__init__.py
Normal file
0
gongdan/management/__init__.py
Normal file
0
gongdan/management/commands/__init__.py
Normal file
0
gongdan/management/commands/__init__.py
Normal file
24
gongdan/management/commands/seed_gongdan_withdraw_block.py
Normal file
24
gongdan/management/commands/seed_gongdan_withdraw_block.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""种子:俱乐部工单提现拦截开关。默认仅 lxs 打开。"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from gongdan.services import ensure_club_gongdan_config
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '初始化俱乐部工单配置(complaint_withdraw_block)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--club-id', default='lxs', help='俱乐部 ID,默认 lxs')
|
||||
parser.add_argument('--enable', action='store_true', default=True, help='打开提现拦截')
|
||||
parser.add_argument('--disable', action='store_true', help='关闭提现拦截')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
club_id = (options.get('club_id') or 'lxs').strip()
|
||||
enable = not options.get('disable')
|
||||
row = ensure_club_gongdan_config(club_id, withdraw_block=enable)
|
||||
if row.complaint_withdraw_block != enable:
|
||||
row.complaint_withdraw_block = enable
|
||||
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'club={club_id} complaint_withdraw_block={row.complaint_withdraw_block}'
|
||||
))
|
||||
68
gongdan/migrations/0001_initial.py
Normal file
68
gongdan/migrations/0001_initial.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# Generated manually for gongdan
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Gongdan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('gongdan_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='工单号')),
|
||||
('leixing', models.IntegerField(choices=[(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'), (4, '管事投诉'), (5, '被骗投诉')], db_index=True, verbose_name='投诉类型')),
|
||||
('order_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='订单ID')),
|
||||
('chongzhi_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='充值单ID')),
|
||||
('fadan_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='罚单ID')),
|
||||
('guanshi_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='被投诉管事UID')),
|
||||
('shuoming', models.CharField(max_length=500, verbose_name='投诉说明')),
|
||||
('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人UserUID')),
|
||||
('club_id', models.CharField(blank=True, db_index=True, max_length=16, null=True, verbose_name='俱乐部')),
|
||||
('zhuangtai', models.IntegerField(choices=[(1, '处理中'), (2, '已处理'), (3, '不满意'), (4, '已关闭')], db_index=True, default=1, verbose_name='状态')),
|
||||
('chuli_jieguo', models.CharField(blank=True, max_length=500, null=True, verbose_name='处理结果说明')),
|
||||
('chuliren_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='处理人UID')),
|
||||
('chuliren_name', models.CharField(blank=True, max_length=64, null=True, verbose_name='处理人展示名')),
|
||||
('chuli_time', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')),
|
||||
('shengji_cishu', models.IntegerField(default=0, verbose_name='升级次数')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={'db_table': 'gongdan', 'verbose_name': '投诉工单', 'ordering': ['-CreateTime']},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GongdanImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('gongdan_hao', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')),
|
||||
('url', models.CharField(max_length=512, verbose_name='图片URL')),
|
||||
('paixu', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='images', to='gongdan.gongdan')),
|
||||
],
|
||||
options={'db_table': 'gongdan_image', 'verbose_name': '工单证据图', 'ordering': ['paixu', 'id']},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GongdanShengji',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('gongdan_hao', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')),
|
||||
('cishu', models.IntegerField(verbose_name='第几次升级')),
|
||||
('shuoming', models.CharField(max_length=500, verbose_name='补充说明')),
|
||||
('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='shengji_list', to='gongdan.gongdan')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'gongdan_shengji',
|
||||
'verbose_name': '工单升级记录',
|
||||
'ordering': ['cishu', 'id'],
|
||||
'unique_together': {('gongdan', 'cishu')},
|
||||
},
|
||||
),
|
||||
]
|
||||
81
gongdan/migrations/0002_gongdan_target_intervene.py
Normal file
81
gongdan/migrations/0002_gongdan_target_intervene.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# 工单:被投诉对象 / 平台介入 / 协商中状态 / 俱乐部提现拦截开关
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gongdan', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='target_role',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=16, verbose_name='被投诉角色'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='target_user_id',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=32, verbose_name='被投诉人UID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='platform_intervened',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='平台已介入'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='intervene_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='介入时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='intervene_by',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入操作人UID(仅存库)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='intervene_reason',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入原因码'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gongdan',
|
||||
name='leixing',
|
||||
field=models.IntegerField(
|
||||
choices=[
|
||||
(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'),
|
||||
(4, '管事投诉'), (5, '被骗投诉'), (6, '组长投诉'),
|
||||
],
|
||||
db_index=True,
|
||||
verbose_name='投诉类型',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gongdan',
|
||||
name='zhuangtai',
|
||||
field=models.IntegerField(
|
||||
choices=[
|
||||
(1, '处理中'), (2, '已处理'), (3, '不满意'),
|
||||
(4, '已关闭'), (5, '协商中'),
|
||||
],
|
||||
db_index=True,
|
||||
default=1,
|
||||
verbose_name='状态',
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubGongdanConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')),
|
||||
('complaint_withdraw_block', models.BooleanField(default=False, verbose_name='投诉拦截提现')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '俱乐部工单配置',
|
||||
'db_table': 'club_gongdan_config',
|
||||
},
|
||||
),
|
||||
]
|
||||
26
gongdan/migrations/0003_club_human_kefu.py
Normal file
26
gongdan/migrations/0003_club_human_kefu.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gongdan', '0002_gongdan_target_intervene'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clubgongdanconfig',
|
||||
name='human_kefu_wechat',
|
||||
field=models.CharField(blank=True, default='', max_length=64, verbose_name='人工客服微信号'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clubgongdanconfig',
|
||||
name='human_kefu_link',
|
||||
field=models.CharField(blank=True, default='', max_length=512, verbose_name='人工客服链接'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clubgongdanconfig',
|
||||
name='human_kefu_tip',
|
||||
field=models.CharField(blank=True, default='', max_length=120, verbose_name='人工客服提示文案'),
|
||||
),
|
||||
]
|
||||
26
gongdan/migrations/0004_gongdan_contact.py
Normal file
26
gongdan/migrations/0004_gongdan_contact.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gongdan', '0003_club_human_kefu'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='contact_qq',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='联系QQ'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='contact_wechat',
|
||||
field=models.CharField(blank=True, default='', max_length=64, verbose_name='联系微信'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='contact_phone',
|
||||
field=models.CharField(blank=True, default='', max_length=20, verbose_name='联系手机'),
|
||||
),
|
||||
]
|
||||
0
gongdan/migrations/__init__.py
Normal file
0
gongdan/migrations/__init__.py
Normal file
101
gongdan/models.py
Normal file
101
gongdan/models.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""投诉工单模型:主单 + 证据图 + 升级补充说明 + 俱乐部开关。"""
|
||||
from django.db import models
|
||||
from gvsdsdk.model_base import QModel
|
||||
|
||||
from gongdan.constants import STATUS_PROCESSING, TYPE_CHOICES, STATUS_CHOICES
|
||||
|
||||
|
||||
class Gongdan(QModel):
|
||||
"""投诉工单主表。"""
|
||||
gongdan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='工单号')
|
||||
leixing = models.IntegerField(db_index=True, choices=TYPE_CHOICES, verbose_name='投诉类型')
|
||||
order_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='订单ID')
|
||||
chongzhi_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='充值单ID')
|
||||
fadan_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='罚单ID')
|
||||
guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID(兼容)')
|
||||
|
||||
# 通用被投诉对象(存库;接口对普通端脱敏不展示 UID)
|
||||
target_role = models.CharField(max_length=16, blank=True, default='', db_index=True, verbose_name='被投诉角色')
|
||||
target_user_id = models.CharField(max_length=32, blank=True, default='', db_index=True, verbose_name='被投诉人UID')
|
||||
|
||||
shuoming = models.CharField(max_length=500, verbose_name='投诉说明')
|
||||
# 投诉人自填联系方式(选填,便于客服回访)
|
||||
contact_qq = models.CharField(max_length=32, blank=True, default='', verbose_name='联系QQ')
|
||||
contact_wechat = models.CharField(max_length=64, blank=True, default='', verbose_name='联系微信')
|
||||
contact_phone = models.CharField(max_length=20, blank=True, default='', verbose_name='联系手机')
|
||||
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人UserUID')
|
||||
club_id = models.CharField(max_length=16, null=True, blank=True, db_index=True, verbose_name='俱乐部')
|
||||
|
||||
zhuangtai = models.IntegerField(
|
||||
default=STATUS_PROCESSING, db_index=True, choices=STATUS_CHOICES, verbose_name='状态',
|
||||
)
|
||||
chuli_jieguo = models.CharField(max_length=500, null=True, blank=True, verbose_name='处理结果说明')
|
||||
chuliren_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='处理人UID')
|
||||
chuliren_name = models.CharField(max_length=64, null=True, blank=True, verbose_name='处理人展示名')
|
||||
chuli_time = models.DateTimeField(null=True, blank=True, verbose_name='处理时间')
|
||||
|
||||
# 平台介入(对外只展示「平台已介入」,不露客服 UID)
|
||||
platform_intervened = models.BooleanField(default=False, db_index=True, verbose_name='平台已介入')
|
||||
intervene_at = models.DateTimeField(null=True, blank=True, verbose_name='介入时间')
|
||||
intervene_by = models.CharField(max_length=32, blank=True, default='', verbose_name='介入操作人UID(仅存库)')
|
||||
intervene_reason = models.CharField(max_length=32, blank=True, default='', verbose_name='介入原因码')
|
||||
|
||||
shengji_cishu = models.IntegerField(default=0, verbose_name='升级次数')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'gongdan'
|
||||
verbose_name = '投诉工单'
|
||||
ordering = ['-CreateTime']
|
||||
|
||||
|
||||
class GongdanImage(QModel):
|
||||
"""工单证据图(最多9张)。"""
|
||||
gongdan = models.ForeignKey(
|
||||
Gongdan, on_delete=models.CASCADE, related_name='images', db_column='gongdan_pk',
|
||||
)
|
||||
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
|
||||
url = models.CharField(max_length=512, verbose_name='图片URL')
|
||||
paixu = models.IntegerField(default=0, verbose_name='排序')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'gongdan_image'
|
||||
verbose_name = '工单证据图'
|
||||
ordering = ['paixu', 'id']
|
||||
|
||||
|
||||
class GongdanShengji(QModel):
|
||||
"""升级/继续投诉补充说明。"""
|
||||
gongdan = models.ForeignKey(
|
||||
Gongdan, on_delete=models.CASCADE, related_name='shengji_list', db_column='gongdan_pk',
|
||||
)
|
||||
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
|
||||
cishu = models.IntegerField(verbose_name='第几次升级')
|
||||
shuoming = models.CharField(max_length=500, verbose_name='补充说明')
|
||||
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'gongdan_shengji'
|
||||
verbose_name = '工单升级记录'
|
||||
ordering = ['cishu', 'id']
|
||||
unique_together = [['gongdan', 'cishu']]
|
||||
|
||||
|
||||
class ClubGongdanConfig(QModel):
|
||||
"""俱乐部工单相关开关(默认全关,仅种子打开的俱乐部生效)。"""
|
||||
club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部')
|
||||
# 未结清被投诉/牵连时禁止提现
|
||||
complaint_withdraw_block = models.BooleanField(default=False, verbose_name='投诉拦截提现')
|
||||
# 投诉页人工客服(分俱乐部配置;有则前端展示)
|
||||
human_kefu_wechat = models.CharField(max_length=64, blank=True, default='', verbose_name='人工客服微信号')
|
||||
human_kefu_link = models.CharField(max_length=512, blank=True, default='', verbose_name='人工客服链接')
|
||||
human_kefu_tip = models.CharField(max_length=120, blank=True, default='', verbose_name='人工客服提示文案')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_gongdan_config'
|
||||
verbose_name = '俱乐部工单配置'
|
||||
831
gongdan/services.py
Normal file
831
gongdan/services.py
Normal file
@@ -0,0 +1,831 @@
|
||||
"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from gongdan.constants import (
|
||||
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, TYPE_ZUZHANG,
|
||||
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
|
||||
STATUS_NEGOTIATING, STATUS_LABELS, OPEN_STATUSES, MAX_IMAGES, MAX_SHUOMING_LEN,
|
||||
MAX_SHENGJI_TIMES, TARGET_ROLE_GUANSHI, TARGET_ROLE_ZUZHANG, TARGET_ROLE_LABELS,
|
||||
TARGET_ROLE_DASHOU, TARGET_ROLE_PLATFORM,
|
||||
)
|
||||
from gongdan.models import Gongdan, GongdanImage, GongdanShengji, ClubGongdanConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GongdanError(Exception):
|
||||
def __init__(self, message, code='error'):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _gen_gongdan_id():
|
||||
ts = int(time.time() * 1000)
|
||||
ns = int(time.time_ns() // 1000) % 1000000
|
||||
rnd = random.randint(0, 99)
|
||||
return f'GD{ts:011d}{ns:06d}{rnd:02d}'
|
||||
|
||||
|
||||
def _trim(text, max_len=MAX_SHUOMING_LEN):
|
||||
return (text or '').strip()[:max_len]
|
||||
|
||||
|
||||
def is_complaint_withdraw_block_enabled(club_id: str) -> bool:
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return False
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
return bool(row and row.complaint_withdraw_block)
|
||||
|
||||
|
||||
def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) -> ClubGongdanConfig:
|
||||
cid = (club_id or '').strip()
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
if not row:
|
||||
row = ClubGongdanConfig(club_id=cid, complaint_withdraw_block=bool(withdraw_block))
|
||||
row.save()
|
||||
return row
|
||||
|
||||
|
||||
def get_club_gongdan_config(club_id: str) -> dict:
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return {
|
||||
'club_id': '',
|
||||
'complaint_withdraw_block': False,
|
||||
'human_kefu_wechat': '',
|
||||
'human_kefu_link': '',
|
||||
'human_kefu_tip': '',
|
||||
'human_kefu_available': False,
|
||||
}
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
wechat = ((row.human_kefu_wechat if row else '') or '').strip()
|
||||
link = ((row.human_kefu_link if row else '') or '').strip()
|
||||
tip = ((row.human_kefu_tip if row else '') or '').strip()
|
||||
return {
|
||||
'club_id': cid,
|
||||
'complaint_withdraw_block': bool(row and row.complaint_withdraw_block),
|
||||
'human_kefu_wechat': wechat,
|
||||
'human_kefu_link': link,
|
||||
'human_kefu_tip': tip or ('添加人工客服微信咨询' if wechat else ('点击联系人工客服' if link else '')),
|
||||
'human_kefu_available': bool(wechat or link),
|
||||
}
|
||||
|
||||
|
||||
def set_complaint_withdraw_block(club_id: str, enabled: bool) -> dict:
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
raise GongdanError('俱乐部不能为空', 'need_club')
|
||||
row = ensure_club_gongdan_config(cid, withdraw_block=bool(enabled))
|
||||
enabled = bool(enabled)
|
||||
if row.complaint_withdraw_block != enabled:
|
||||
row.complaint_withdraw_block = enabled
|
||||
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
|
||||
return get_club_gongdan_config(cid)
|
||||
|
||||
|
||||
def update_club_gongdan_config(club_id: str, **fields) -> dict:
|
||||
"""更新俱乐部工单配置(提现拦截 / 人工客服联系方式)。"""
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
raise GongdanError('俱乐部不能为空', 'need_club')
|
||||
row = ensure_club_gongdan_config(cid)
|
||||
dirty = []
|
||||
if 'complaint_withdraw_block' in fields and fields['complaint_withdraw_block'] is not None:
|
||||
val = bool(fields['complaint_withdraw_block'])
|
||||
if row.complaint_withdraw_block != val:
|
||||
row.complaint_withdraw_block = val
|
||||
dirty.append('complaint_withdraw_block')
|
||||
if 'human_kefu_wechat' in fields and fields['human_kefu_wechat'] is not None:
|
||||
val = str(fields['human_kefu_wechat'] or '').strip()[:64]
|
||||
if (row.human_kefu_wechat or '') != val:
|
||||
row.human_kefu_wechat = val
|
||||
dirty.append('human_kefu_wechat')
|
||||
if 'human_kefu_link' in fields and fields['human_kefu_link'] is not None:
|
||||
val = str(fields['human_kefu_link'] or '').strip()[:512]
|
||||
if (row.human_kefu_link or '') != val:
|
||||
row.human_kefu_link = val
|
||||
dirty.append('human_kefu_link')
|
||||
if 'human_kefu_tip' in fields and fields['human_kefu_tip'] is not None:
|
||||
val = str(fields['human_kefu_tip'] or '').strip()[:120]
|
||||
if (row.human_kefu_tip or '') != val:
|
||||
row.human_kefu_tip = val
|
||||
dirty.append('human_kefu_tip')
|
||||
if dirty:
|
||||
dirty.append('UpdateTime')
|
||||
row.save(update_fields=dirty)
|
||||
return get_club_gongdan_config(cid)
|
||||
|
||||
|
||||
def resolve_inviter_complaint_target(yonghuid: str, leixing: int) -> dict:
|
||||
"""
|
||||
管事/组长投诉只能针对本人邀请人,禁止搜索乱选:
|
||||
- 管事投诉:打手的 yaoqingren,且对方须是管事
|
||||
- 组长投诉:管事的 yaoqingren,且对方须是组长
|
||||
无邀请人则不可投诉。
|
||||
"""
|
||||
uid = (yonghuid or '').strip()
|
||||
if not uid:
|
||||
raise GongdanError('请先登录', 'need_login')
|
||||
|
||||
from users.models import UserDashou, UserGuanshi, UserZuzhang
|
||||
|
||||
if leixing == TYPE_GUANSHI:
|
||||
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if not ds:
|
||||
raise GongdanError('仅打手可投诉自己的管事', 'need_dashou')
|
||||
inviter = (ds.yaoqingren or '').strip()
|
||||
if not inviter:
|
||||
raise GongdanError('你没有邀请人,无法投诉管事', 'no_inviter')
|
||||
gs = UserGuanshi.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first()
|
||||
if not gs:
|
||||
raise GongdanError('你的邀请人不是有效管事,无法投诉', 'inviter_not_guanshi')
|
||||
return {
|
||||
'role': TARGET_ROLE_GUANSHI,
|
||||
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI],
|
||||
'user_id': inviter,
|
||||
'nicheng': _user_nicheng(inviter),
|
||||
'can_complain': True,
|
||||
}
|
||||
|
||||
if leixing == TYPE_ZUZHANG:
|
||||
gs = UserGuanshi.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if not gs:
|
||||
raise GongdanError('仅管事可投诉自己的组长', 'need_guanshi')
|
||||
inviter = (gs.yaoqingren or '').strip()
|
||||
if not inviter:
|
||||
raise GongdanError('你没有邀请人,无法投诉组长', 'no_inviter')
|
||||
zz = UserZuzhang.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first()
|
||||
if not zz:
|
||||
raise GongdanError('你的邀请人不是有效组长,无法投诉', 'inviter_not_zuzhang')
|
||||
return {
|
||||
'role': TARGET_ROLE_ZUZHANG,
|
||||
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG],
|
||||
'user_id': inviter,
|
||||
'nicheng': _user_nicheng(inviter),
|
||||
'can_complain': True,
|
||||
}
|
||||
|
||||
raise GongdanError('仅管事/组长投诉需要邀请人', 'bad_type')
|
||||
|
||||
|
||||
def peek_inviter_complaint_target(yonghuid: str, leixing: int) -> dict:
|
||||
"""前端展示用:失败时返回 can_complain=False + 原因,不抛错。"""
|
||||
try:
|
||||
return resolve_inviter_complaint_target(yonghuid, leixing)
|
||||
except GongdanError as e:
|
||||
role = TARGET_ROLE_GUANSHI if leixing == TYPE_GUANSHI else TARGET_ROLE_ZUZHANG
|
||||
return {
|
||||
'role': role,
|
||||
'role_label': TARGET_ROLE_LABELS.get(role, ''),
|
||||
'user_id': '',
|
||||
'nicheng': '',
|
||||
'can_complain': False,
|
||||
'reason': str(e),
|
||||
}
|
||||
|
||||
|
||||
def _user_nicheng(uid: str) -> str:
|
||||
"""展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。"""
|
||||
uid = (uid or '').strip()
|
||||
if not uid:
|
||||
return ''
|
||||
try:
|
||||
from users.models import UserDashou, UserBoss
|
||||
from users.business_models import User
|
||||
|
||||
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if ds and (ds.nicheng or '').strip():
|
||||
return (ds.nicheng or '').strip()[:32]
|
||||
boss = UserBoss.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if boss and (boss.nickname or '').strip():
|
||||
return (boss.nickname or '').strip()[:32]
|
||||
u = User.query.filter(UserUID=uid).first() or User.objects.filter(UserUID=uid).first()
|
||||
if u and (getattr(u, 'Phone', None) or '').strip():
|
||||
phone = str(u.Phone).strip()
|
||||
if len(phone) >= 4:
|
||||
return f'用户*{phone[-4:]}'
|
||||
return '用户'
|
||||
except Exception:
|
||||
return '用户'
|
||||
|
||||
|
||||
def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None, *, yonghuid=None):
|
||||
"""归一化被投诉对象。管事/组长投诉强制本人邀请人,忽略前端传入的选人。"""
|
||||
role = (target_role or '').strip().lower()
|
||||
tid = (target_user_id or '').strip()
|
||||
gid = (guanshi_id or '').strip() or None
|
||||
|
||||
if leixing == TYPE_GUANSHI:
|
||||
info = resolve_inviter_complaint_target(yonghuid, TYPE_GUANSHI)
|
||||
tid = info['user_id']
|
||||
return TARGET_ROLE_GUANSHI, tid, tid
|
||||
|
||||
if leixing == TYPE_ZUZHANG:
|
||||
info = resolve_inviter_complaint_target(yonghuid, TYPE_ZUZHANG)
|
||||
tid = info['user_id']
|
||||
return TARGET_ROLE_ZUZHANG, tid, None
|
||||
|
||||
if role and tid:
|
||||
if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'):
|
||||
raise GongdanError('无效的被投诉角色', 'bad_target_role')
|
||||
return role, tid, gid if role == TARGET_ROLE_GUANSHI else None
|
||||
|
||||
return role or '', tid or '', gid
|
||||
|
||||
|
||||
def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None):
|
||||
order_id = (order_id or '').strip() or None
|
||||
chongzhi_id = (chongzhi_id or '').strip() or None
|
||||
fadan_id = (fadan_id or '').strip() or None
|
||||
guanshi_id = (guanshi_id or '').strip() or None
|
||||
|
||||
if leixing == TYPE_ORDER:
|
||||
if not order_id:
|
||||
raise GongdanError('订单投诉请填写订单ID', 'need_order_id')
|
||||
from orders.models import Order
|
||||
if not Order.query.filter(OrderID=order_id).exists():
|
||||
raise GongdanError('订单不存在,请核对订单ID', 'order_not_found')
|
||||
return order_id, None, None, None
|
||||
|
||||
if leixing == TYPE_RECHARGE:
|
||||
if not chongzhi_id:
|
||||
raise GongdanError('充值投诉请填写充值记录ID', 'need_chongzhi_id')
|
||||
from products.models import Czjilu
|
||||
from products.czjilu_types import CZJILU_PAID_STATUS
|
||||
cz = Czjilu.query.filter(dingdan_id=chongzhi_id).first()
|
||||
if not cz:
|
||||
raise GongdanError('充值记录不存在,请核对充值ID', 'chongzhi_not_found')
|
||||
try:
|
||||
z = int(cz.zhuangtai) if cz.zhuangtai is not None else None
|
||||
except (TypeError, ValueError):
|
||||
z = None
|
||||
if z != CZJILU_PAID_STATUS:
|
||||
raise GongdanError('仅已支付的充值单可投诉', 'chongzhi_not_paid')
|
||||
return None, chongzhi_id, None, None
|
||||
|
||||
if leixing == TYPE_PENALTY:
|
||||
if not fadan_id:
|
||||
raise GongdanError('罚款投诉请填写罚单ID', 'need_fadan_id')
|
||||
from orders.models import Penalty
|
||||
try:
|
||||
fid = int(fadan_id)
|
||||
except (TypeError, ValueError):
|
||||
raise GongdanError('罚单ID格式不正确', 'bad_fadan_id')
|
||||
if not Penalty.query.filter(id=fid).exists():
|
||||
raise GongdanError('罚单不存在,请核对罚单ID', 'fadan_not_found')
|
||||
return None, None, str(fid), None
|
||||
|
||||
if leixing == TYPE_GUANSHI:
|
||||
return None, None, None, guanshi_id
|
||||
|
||||
if leixing in (TYPE_FRAUD, TYPE_ZUZHANG):
|
||||
return None, None, None, None
|
||||
|
||||
raise GongdanError('不支持的投诉类型', 'bad_type')
|
||||
|
||||
|
||||
def _effective_target_uid(gd) -> str:
|
||||
return (gd.target_user_id or gd.guanshi_id or '').strip()
|
||||
|
||||
|
||||
def _effective_target_role(gd) -> str:
|
||||
role = (gd.target_role or '').strip()
|
||||
if role:
|
||||
return role
|
||||
if gd.leixing == TYPE_GUANSHI or gd.guanshi_id:
|
||||
return TARGET_ROLE_GUANSHI
|
||||
if gd.leixing == TYPE_ZUZHANG:
|
||||
return TARGET_ROLE_ZUZHANG
|
||||
return ''
|
||||
|
||||
|
||||
def serialize_gongdan(gd, include_images=True, include_shengji=False, *, viewer_uid=None, for_admin=False):
|
||||
"""
|
||||
序列化。默认脱敏:不把申请人/被投诉人/客服 UID 作为展示字段下发。
|
||||
for_admin=True 时仍不主动展示 UID 列,只多给内部审计需要的标记。
|
||||
contact_peer_uid 仅当 viewer 是当事方时返回,供 IM 打开(前端勿渲染)。
|
||||
"""
|
||||
t_role = _effective_target_role(gd)
|
||||
t_uid = _effective_target_uid(gd)
|
||||
relation = ''
|
||||
if viewer_uid:
|
||||
if gd.shenqingren_id == viewer_uid:
|
||||
relation = 'created'
|
||||
elif t_uid and t_uid == viewer_uid:
|
||||
relation = 'received'
|
||||
else:
|
||||
relation = 'linked' if _viewer_is_linked_zuzhang(viewer_uid, gd) else ''
|
||||
|
||||
data = {
|
||||
'gongdan_id': gd.gongdan_id,
|
||||
'leixing': gd.leixing,
|
||||
'leixing_label': TYPE_LABELS.get(gd.leixing, str(gd.leixing)),
|
||||
'order_id': gd.order_id or '',
|
||||
'chongzhi_id': gd.chongzhi_id or '',
|
||||
'fadan_id': gd.fadan_id or '',
|
||||
'shuoming': gd.shuoming or '',
|
||||
'contact_qq': getattr(gd, 'contact_qq', '') or '',
|
||||
'contact_wechat': getattr(gd, 'contact_wechat', '') or '',
|
||||
'contact_phone': getattr(gd, 'contact_phone', '') or '',
|
||||
'club_id': gd.club_id or '',
|
||||
'zhuangtai': gd.zhuangtai,
|
||||
'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)),
|
||||
'chuli_jieguo': gd.chuli_jieguo or '',
|
||||
# 对外:只显示「平台已介入」,不返回客服 UID
|
||||
'platform_intervened': bool(gd.platform_intervened),
|
||||
'intervene_at': gd.intervene_at.isoformat() if gd.intervene_at else '',
|
||||
'intervene_reason': gd.intervene_reason or '',
|
||||
'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '',
|
||||
'shengji_cishu': gd.shengji_cishu or 0,
|
||||
'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '',
|
||||
'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '',
|
||||
'target_role': t_role,
|
||||
'target_role_label': TARGET_ROLE_LABELS.get(t_role, t_role or ''),
|
||||
'target_nicheng': _user_nicheng(t_uid),
|
||||
'complainant_nicheng': _user_nicheng(gd.shenqingren_id),
|
||||
'relation': relation,
|
||||
'is_open': gd.zhuangtai in OPEN_STATUSES,
|
||||
'can_contact': False,
|
||||
'contact_peer_uid': '',
|
||||
'contact_peer_nicheng': '',
|
||||
'can_contact_complainant': False,
|
||||
'contact_complainant_uid': '',
|
||||
'contact_complainant_nicheng': '',
|
||||
}
|
||||
if viewer_uid and relation in ('created', 'received'):
|
||||
peer = t_uid if relation == 'created' else gd.shenqingren_id
|
||||
if peer and peer != viewer_uid:
|
||||
data['can_contact'] = True
|
||||
data['contact_peer_uid'] = peer
|
||||
data['contact_peer_nicheng'] = _user_nicheng(peer)
|
||||
elif viewer_uid and relation == 'linked':
|
||||
# 牵连组长:默认可联系主被投诉管事;另可联系投诉人
|
||||
if t_uid and t_uid != viewer_uid:
|
||||
data['can_contact'] = True
|
||||
data['contact_peer_uid'] = t_uid
|
||||
data['contact_peer_nicheng'] = _user_nicheng(t_uid)
|
||||
if gd.shenqingren_id and gd.shenqingren_id != viewer_uid:
|
||||
data['can_contact_complainant'] = True
|
||||
data['contact_complainant_uid'] = gd.shenqingren_id
|
||||
data['contact_complainant_nicheng'] = _user_nicheng(gd.shenqingren_id)
|
||||
if for_admin:
|
||||
# 客服需要 UID 做内部处理;用户端绝不下发
|
||||
data['shenqingren_id'] = gd.shenqingren_id or ''
|
||||
data['target_user_id'] = t_uid or ''
|
||||
data['guanshi_id'] = gd.guanshi_id or ''
|
||||
data['chuliren_id'] = gd.chuliren_id or ''
|
||||
data['chuliren_name'] = gd.chuliren_name or ('平台客服' if gd.platform_intervened or gd.chuliren_id else '')
|
||||
else:
|
||||
data['chuliren_name'] = '平台客服' if (gd.platform_intervened or gd.chuliren_name) else ''
|
||||
|
||||
if include_images:
|
||||
data['images'] = [
|
||||
{'url': img.url, 'paixu': img.paixu}
|
||||
for img in gd.images.all().order_by('paixu', 'id')
|
||||
]
|
||||
if include_shengji:
|
||||
data['shengji_list'] = [
|
||||
{
|
||||
'cishu': s.cishu,
|
||||
'shuoming': s.shuoming,
|
||||
'CreateTime': s.CreateTime.isoformat() if s.CreateTime else '',
|
||||
}
|
||||
for s in gd.shengji_list.all().order_by('cishu', 'id')
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
def _viewer_is_linked_zuzhang(viewer_uid: str, gd) -> bool:
|
||||
"""下级管事被投诉时,其上级组长视为牵连方。"""
|
||||
t_uid = _effective_target_uid(gd)
|
||||
t_role = _effective_target_role(gd)
|
||||
if not t_uid or t_role != TARGET_ROLE_GUANSHI:
|
||||
return False
|
||||
try:
|
||||
from users.models import UserGuanshi
|
||||
gs = UserGuanshi.objects.filter(user__UserUID=t_uid).first()
|
||||
if not gs:
|
||||
return False
|
||||
return (gs.yaoqingren or '').strip() == str(viewer_uid)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _sub_guanshi_uids(zuzhang_uid: str):
|
||||
from users.models import UserGuanshi
|
||||
rows = UserGuanshi.objects.filter(yaoqingren=str(zuzhang_uid)).select_related('user')
|
||||
out = []
|
||||
for r in rows:
|
||||
uid = getattr(getattr(r, 'user', None), 'UserUID', None)
|
||||
if uid:
|
||||
out.append(str(uid))
|
||||
return out
|
||||
|
||||
|
||||
def open_complaint_q_for_target_uids(uids):
|
||||
"""未结清且指向这些 UID 的工单条件。"""
|
||||
uids = [str(u) for u in (uids or []) if u]
|
||||
if not uids:
|
||||
return Q(pk__in=[])
|
||||
return (
|
||||
Q(zhuangtai__in=OPEN_STATUSES)
|
||||
& (Q(target_user_id__in=uids) | Q(guanshi_id__in=uids))
|
||||
)
|
||||
|
||||
|
||||
def check_complaint_blocks_withdraw(user_main, club_id=None):
|
||||
"""
|
||||
提现拦截。
|
||||
返回 (ok, msg, extra)
|
||||
extra: reason=received|linked, tab, count
|
||||
"""
|
||||
try:
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
cid = (club_id or get_user_club_id(user_main) or '').strip()
|
||||
except Exception:
|
||||
cid = (club_id or '').strip()
|
||||
|
||||
if not is_complaint_withdraw_block_enabled(cid):
|
||||
return True, '', {}
|
||||
|
||||
uid = str(user_main.UserUID)
|
||||
base = Gongdan.query.filter(club_id=cid) if cid else Gongdan.query.all()
|
||||
|
||||
# 1) 自己是主被投诉人
|
||||
qs_recv = base.filter(open_complaint_q_for_target_uids([uid]))
|
||||
cnt = qs_recv.count()
|
||||
if cnt:
|
||||
return False, '你有未处理完的投诉,请处理后再提现', {
|
||||
'reason': 'received',
|
||||
'tab': 'received',
|
||||
'count': cnt,
|
||||
'code': 'COMPLAINT_BLOCK_WITHDRAW',
|
||||
}
|
||||
|
||||
# 2) 自己是组长,下级管事被投诉 → 牵连
|
||||
try:
|
||||
from users.models import UserZuzhang
|
||||
is_zz = UserZuzhang.query.filter(user=user_main).exists()
|
||||
except Exception:
|
||||
is_zz = False
|
||||
if is_zz:
|
||||
subs = _sub_guanshi_uids(uid)
|
||||
if subs:
|
||||
qs_link = base.filter(open_complaint_q_for_target_uids(subs)).filter(
|
||||
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
|
||||
)
|
||||
cnt2 = qs_link.count()
|
||||
if cnt2:
|
||||
return False, '下级管事有未处理投诉,暂不可提现', {
|
||||
'reason': 'linked',
|
||||
'tab': 'linked',
|
||||
'count': cnt2,
|
||||
'code': 'COMPLAINT_BLOCK_WITHDRAW',
|
||||
}
|
||||
|
||||
return True, '', {}
|
||||
|
||||
|
||||
def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20, yonghuid=None):
|
||||
"""已废弃搜索选人:仅返回本人邀请人(防乱投诉)。keyword/club 忽略。"""
|
||||
role = (role or '').strip().lower()
|
||||
if role == TARGET_ROLE_GUANSHI:
|
||||
leixing = TYPE_GUANSHI
|
||||
elif role == TARGET_ROLE_ZUZHANG:
|
||||
leixing = TYPE_ZUZHANG
|
||||
else:
|
||||
raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role')
|
||||
info = peek_inviter_complaint_target(yonghuid, leixing)
|
||||
rows = []
|
||||
if info.get('can_complain') and info.get('user_id'):
|
||||
rows.append({
|
||||
'user_id': info['user_id'],
|
||||
'nicheng': info.get('nicheng') or '',
|
||||
'role': info.get('role') or role,
|
||||
'role_label': info.get('role_label') or '',
|
||||
})
|
||||
return {
|
||||
'list': rows,
|
||||
'total': len(rows),
|
||||
'page': 1,
|
||||
'page_size': 1,
|
||||
'role': role,
|
||||
'can_complain': bool(info.get('can_complain')),
|
||||
'reason': info.get('reason') or '',
|
||||
'invite_only': True,
|
||||
}
|
||||
|
||||
|
||||
def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20):
|
||||
"""
|
||||
scope:
|
||||
created — 我发起的
|
||||
received — 我被投诉的
|
||||
linked — 下级牵连(组长)
|
||||
"""
|
||||
scope = (scope or 'created').strip().lower()
|
||||
page = max(int(page or 1), 1)
|
||||
page_size = min(max(int(page_size or 20), 1), 50)
|
||||
uid = str(yonghuid)
|
||||
qs = Gongdan.query.all()
|
||||
if club_id:
|
||||
qs = qs.filter(club_id=club_id)
|
||||
|
||||
if scope == 'received':
|
||||
qs = qs.filter(Q(target_user_id=uid) | Q(guanshi_id=uid)).order_by('-CreateTime')
|
||||
elif scope == 'linked':
|
||||
subs = _sub_guanshi_uids(uid)
|
||||
if not subs:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(Q(target_user_id__in=subs) | Q(guanshi_id__in=subs)).filter(
|
||||
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
|
||||
).order_by('-CreateTime')
|
||||
else:
|
||||
qs = qs.filter(shenqingren_id=uid).order_by('-CreateTime')
|
||||
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
rows = list(qs[start:start + page_size])
|
||||
return {
|
||||
'list': [serialize_gongdan(g, include_images=False, viewer_uid=uid) for g in rows],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'scope': scope,
|
||||
}
|
||||
|
||||
|
||||
def user_can_view_gongdan(gd, yonghuid) -> bool:
|
||||
uid = str(yonghuid)
|
||||
if gd.shenqingren_id == uid:
|
||||
return True
|
||||
if _effective_target_uid(gd) == uid:
|
||||
return True
|
||||
if _viewer_is_linked_zuzhang(uid, gd):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _replace_images(gd, image_urls):
|
||||
urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()]
|
||||
if len(urls) > MAX_IMAGES:
|
||||
raise GongdanError(f'最多上传{MAX_IMAGES}张图片', 'too_many_images')
|
||||
GongdanImage.objects.filter(gongdan=gd).delete()
|
||||
for i, url in enumerate(urls):
|
||||
GongdanImage.objects.create(
|
||||
gongdan=gd,
|
||||
gongdan_hao=gd.gongdan_id,
|
||||
url=url[:512],
|
||||
paixu=i,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_gongdan(
|
||||
*,
|
||||
yonghuid,
|
||||
leixing,
|
||||
shuoming,
|
||||
club_id=None,
|
||||
order_id=None,
|
||||
chongzhi_id=None,
|
||||
fadan_id=None,
|
||||
guanshi_id=None,
|
||||
target_role=None,
|
||||
target_user_id=None,
|
||||
image_urls=None,
|
||||
contact_qq=None,
|
||||
contact_wechat=None,
|
||||
contact_phone=None,
|
||||
):
|
||||
try:
|
||||
leixing = int(leixing)
|
||||
except (TypeError, ValueError):
|
||||
raise GongdanError('投诉类型无效', 'bad_type')
|
||||
if leixing not in TYPE_LABELS:
|
||||
raise GongdanError('投诉类型无效', 'bad_type')
|
||||
|
||||
text = _trim(shuoming)
|
||||
if not text:
|
||||
raise GongdanError('请填写投诉说明', 'need_shuoming')
|
||||
|
||||
order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs(
|
||||
leixing, order_id, chongzhi_id, fadan_id, guanshi_id or target_user_id,
|
||||
)
|
||||
t_role, t_uid, guanshi_id = _resolve_target(
|
||||
leixing,
|
||||
guanshi_id=guanshi_id,
|
||||
target_role=target_role,
|
||||
target_user_id=target_user_id or guanshi_id,
|
||||
yonghuid=yonghuid,
|
||||
)
|
||||
|
||||
gid = _gen_gongdan_id()
|
||||
for _ in range(5):
|
||||
if not Gongdan.query.filter(gongdan_id=gid).exists():
|
||||
break
|
||||
gid = _gen_gongdan_id()
|
||||
|
||||
gd = Gongdan.objects.create(
|
||||
gongdan_id=gid,
|
||||
leixing=leixing,
|
||||
order_id=order_id,
|
||||
chongzhi_id=chongzhi_id,
|
||||
fadan_id=fadan_id,
|
||||
guanshi_id=guanshi_id,
|
||||
target_role=t_role or '',
|
||||
target_user_id=t_uid or '',
|
||||
shuoming=text,
|
||||
contact_qq=_trim(contact_qq or '', 32),
|
||||
contact_wechat=_trim(contact_wechat or '', 64),
|
||||
contact_phone=_trim(contact_phone or '', 20),
|
||||
shenqingren_id=yonghuid,
|
||||
club_id=club_id or None,
|
||||
zhuangtai=STATUS_PROCESSING,
|
||||
shengji_cishu=0,
|
||||
)
|
||||
_replace_images(gd, image_urls)
|
||||
logger.info('创建工单 %s type=%s user=%s target=%s/%s', gid, leixing, yonghuid, t_role, t_uid)
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if gd.shenqingren_id != yonghuid:
|
||||
raise GongdanError('无权操作此工单', 'forbidden')
|
||||
if gd.zhuangtai not in (STATUS_DONE, STATUS_DISSATISFIED):
|
||||
raise GongdanError('当前状态不可继续投诉,请等待客服处理', 'bad_status')
|
||||
if (gd.shengji_cishu or 0) >= MAX_SHENGJI_TIMES:
|
||||
raise GongdanError(f'最多升级{MAX_SHENGJI_TIMES}次,请联系客服', 'too_many_shengji')
|
||||
|
||||
text = _trim(shuoming)
|
||||
if not text:
|
||||
raise GongdanError('请填写补充说明', 'need_shuoming')
|
||||
|
||||
next_cishu = (gd.shengji_cishu or 0) + 1
|
||||
GongdanShengji.objects.create(
|
||||
gongdan=gd,
|
||||
gongdan_hao=gd.gongdan_id,
|
||||
cishu=next_cishu,
|
||||
shuoming=text,
|
||||
shenqingren_id=yonghuid,
|
||||
)
|
||||
gd.shengji_cishu = next_cishu
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.chuli_jieguo = ''
|
||||
gd.chuli_time = None
|
||||
gd.save(update_fields=[
|
||||
'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime',
|
||||
])
|
||||
if image_urls is not None:
|
||||
_replace_images(gd, image_urls)
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def handle_gongdan(
|
||||
*,
|
||||
gongdan_id,
|
||||
handler_uid,
|
||||
handler_name,
|
||||
action,
|
||||
chuli_jieguo='',
|
||||
intervene_reason='',
|
||||
):
|
||||
"""
|
||||
action:
|
||||
done/finish/resolve → 已处理
|
||||
close → 关闭
|
||||
processing/reopen → 处理中
|
||||
dissatisfied → 不满意
|
||||
intervene → 标记平台介入(不改结案状态)
|
||||
negotiate → 协商中(客服 sparingly)
|
||||
"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
|
||||
action = (action or '').strip().lower()
|
||||
jieguo = _trim(chuli_jieguo)
|
||||
|
||||
if action in ('intervene', 'platform_intervene'):
|
||||
gd.platform_intervened = True
|
||||
gd.intervene_at = timezone.now()
|
||||
gd.intervene_by = (handler_uid or '')[:32]
|
||||
gd.intervene_reason = (intervene_reason or 'kefu')[:32]
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.save()
|
||||
logger.info('工单平台介入 %s by=%s', gongdan_id, handler_uid)
|
||||
return gd
|
||||
|
||||
if action in ('done', 'finish', 'resolve'):
|
||||
if not jieguo:
|
||||
raise GongdanError('请填写处理结果', 'need_jieguo')
|
||||
gd.zhuangtai = STATUS_DONE
|
||||
gd.chuli_jieguo = jieguo
|
||||
gd.chuliren_id = handler_uid
|
||||
gd.chuliren_name = (handler_name or '平台客服')[:64]
|
||||
gd.chuli_time = timezone.now()
|
||||
if not gd.platform_intervened:
|
||||
gd.platform_intervened = True
|
||||
gd.intervene_at = gd.intervene_at or timezone.now()
|
||||
gd.intervene_by = (handler_uid or '')[:32]
|
||||
gd.intervene_reason = gd.intervene_reason or 'kefu_done'
|
||||
elif action in ('close',):
|
||||
gd.zhuangtai = STATUS_CLOSED
|
||||
gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭'
|
||||
gd.chuliren_id = handler_uid
|
||||
gd.chuliren_name = (handler_name or '平台客服')[:64]
|
||||
gd.chuli_time = timezone.now()
|
||||
elif action in ('processing', 'reopen'):
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
elif action in ('negotiate',):
|
||||
gd.zhuangtai = STATUS_NEGOTIATING
|
||||
elif action in ('dissatisfied',):
|
||||
gd.zhuangtai = STATUS_DISSATISFIED
|
||||
else:
|
||||
raise GongdanError('未知处理动作', 'bad_action')
|
||||
|
||||
gd.save()
|
||||
logger.info('工单处理 %s action=%s by=%s', gongdan_id, action, handler_uid)
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_mark_dissatisfied(*, gongdan_id, yonghuid):
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if gd.shenqingren_id != yonghuid:
|
||||
raise GongdanError('无权操作此工单', 'forbidden')
|
||||
if gd.zhuangtai != STATUS_DONE:
|
||||
raise GongdanError('仅已处理的工单可标记不满意', 'bad_status')
|
||||
gd.zhuangtai = STATUS_DISSATISFIED
|
||||
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_resolve_gongdan(*, gongdan_id, yonghuid):
|
||||
"""投诉人确认已解决(私了结清)。"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if gd.shenqingren_id != yonghuid:
|
||||
raise GongdanError('仅投诉人可确认解决', 'forbidden')
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
raise GongdanError('当前状态无需确认解决', 'bad_status')
|
||||
gd.zhuangtai = STATUS_DONE
|
||||
gd.chuli_jieguo = gd.chuli_jieguo or '双方协商已解决'
|
||||
gd.chuli_time = timezone.now()
|
||||
gd.chuliren_name = gd.chuliren_name or '双方协商'
|
||||
gd.save()
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_start_negotiate(*, gongdan_id, yonghuid):
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if not user_can_view_gongdan(gd, yonghuid):
|
||||
raise GongdanError('无权操作此工单', 'forbidden')
|
||||
if gd.zhuangtai not in (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING):
|
||||
raise GongdanError('当前状态不可转协商', 'bad_status')
|
||||
gd.zhuangtai = STATUS_NEGOTIATING
|
||||
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_apply_intervene(*, gongdan_id, yonghuid):
|
||||
"""用户申请平台介入:打标,仍保持未结清。"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if gd.shenqingren_id != yonghuid and _effective_target_uid(gd) != yonghuid:
|
||||
raise GongdanError('无权申请介入', 'forbidden')
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
raise GongdanError('当前状态不可申请介入', 'bad_status')
|
||||
gd.platform_intervened = True
|
||||
if not gd.intervene_at:
|
||||
gd.intervene_at = timezone.now()
|
||||
gd.intervene_reason = gd.intervene_reason or 'user_apply'
|
||||
if gd.zhuangtai == STATUS_NEGOTIATING:
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.save()
|
||||
return gd
|
||||
41
gongdan/urls.py
Normal file
41
gongdan/urls.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.urls import path
|
||||
|
||||
from gongdan.views import (
|
||||
GongdanUploadImageView,
|
||||
GongdanCreateView,
|
||||
GongdanMyListView,
|
||||
GongdanDetailView,
|
||||
GongdanEscalateView,
|
||||
GongdanDissatisfiedView,
|
||||
GongdanResolveView,
|
||||
GongdanNegotiateView,
|
||||
GongdanApplyInterveneView,
|
||||
GongdanWithdrawCheckView,
|
||||
GongdanSearchTargetView,
|
||||
GongdanMyInviterTargetView,
|
||||
GongdanMetaView,
|
||||
ChongzhiRecordListView,
|
||||
GongdanAdminListView,
|
||||
GongdanAdminHandleView,
|
||||
GongdanAdminClubConfigView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('upload-image/', GongdanUploadImageView.as_view(), name='工单图片上传'),
|
||||
path('create/', GongdanCreateView.as_view(), name='创建投诉工单'),
|
||||
path('my-list/', GongdanMyListView.as_view(), name='我的投诉列表'),
|
||||
path('detail/', GongdanDetailView.as_view(), name='工单详情'),
|
||||
path('escalate/', GongdanEscalateView.as_view(), name='继续投诉升级'),
|
||||
path('dissatisfied/', GongdanDissatisfiedView.as_view(), name='标记不满意'),
|
||||
path('resolve/', GongdanResolveView.as_view(), name='投诉人确认解决'),
|
||||
path('negotiate/', GongdanNegotiateView.as_view(), name='转协商中'),
|
||||
path('apply-intervene/', GongdanApplyInterveneView.as_view(), name='申请平台介入'),
|
||||
path('withdraw-block-check/', GongdanWithdrawCheckView.as_view(), name='提现投诉拦截检查'),
|
||||
path('search-target/', GongdanSearchTargetView.as_view(), name='搜索被投诉对象'),
|
||||
path('my-inviter-target/', GongdanMyInviterTargetView.as_view(), name='本人邀请人投诉对象'),
|
||||
path('meta/', GongdanMetaView.as_view(), name='工单枚举'),
|
||||
path('chongzhi-list/', ChongzhiRecordListView.as_view(), name='我的充值记录'),
|
||||
path('admin/list/', GongdanAdminListView.as_view(), name='客服工单列表'),
|
||||
path('admin/handle/', GongdanAdminHandleView.as_view(), name='客服处理工单'),
|
||||
path('admin/club-config/', GongdanAdminClubConfigView.as_view(), name='工单俱乐部开关'),
|
||||
]
|
||||
602
gongdan/views.py
Normal file
602
gongdan/views.py
Normal file
@@ -0,0 +1,602 @@
|
||||
"""投诉工单 API:用户提交/列表/升级 + 图片上传 + 客服处理。"""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.utils import timezone
|
||||
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 rest_framework import status
|
||||
|
||||
from gongdan.constants import (
|
||||
MAX_IMAGES, OSS_PREFIX, PERM_VIEW, PERM_HANDLE, TYPE_LABELS, STATUS_LABELS,
|
||||
TYPE_GUANSHI, TYPE_ZUZHANG,
|
||||
)
|
||||
from gongdan.models import Gongdan
|
||||
from gongdan.services import (
|
||||
GongdanError,
|
||||
create_gongdan,
|
||||
escalate_gongdan,
|
||||
handle_gongdan,
|
||||
serialize_gongdan,
|
||||
user_mark_dissatisfied,
|
||||
list_gongdan_for_user,
|
||||
user_can_view_gongdan,
|
||||
user_resolve_gongdan,
|
||||
user_start_negotiate,
|
||||
user_apply_intervene,
|
||||
check_complaint_blocks_withdraw,
|
||||
search_complaint_targets,
|
||||
get_club_gongdan_config,
|
||||
set_complaint_withdraw_block,
|
||||
update_club_gongdan_config,
|
||||
peek_inviter_complaint_target,
|
||||
)
|
||||
from utils.oss_utils import upload_to_oss, validate_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ok(data=None, message='ok', code=200):
|
||||
body = {'code': code, 'message': message, 'msg': message}
|
||||
if data is not None:
|
||||
body['data'] = data
|
||||
return Response(body, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def _err(message, http=400, code=None):
|
||||
c = code if code is not None else http
|
||||
return Response(
|
||||
{'code': c, 'message': message, 'msg': message},
|
||||
status=http,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_club(request):
|
||||
try:
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
return resolve_club_id_for_write(request, request.user)
|
||||
except Exception:
|
||||
return getattr(request.user, 'club_id', None) or None
|
||||
|
||||
|
||||
class GongdanUploadImageView(APIView):
|
||||
"""上传证据图,返回 URL。路径:POST /gongdan/upload-image/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
def post(self, request):
|
||||
f = request.FILES.get('file') or request.FILES.get('image') or request.FILES.get('tupian')
|
||||
if not f:
|
||||
return _err('请选择图片')
|
||||
ok, msg = validate_image(f)
|
||||
if not ok:
|
||||
return _err(msg)
|
||||
ext = (f.name.rsplit('.', 1)[-1] if '.' in f.name else 'jpg').lower()
|
||||
if ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'):
|
||||
ext = 'jpg'
|
||||
uid = getattr(request.user, 'UserUID', 'anon')
|
||||
day = timezone.now().strftime('%Y%m%d')
|
||||
path = f'{OSS_PREFIX}/{day}/{uid}/{uuid.uuid4().hex}.{ext}'
|
||||
url = upload_to_oss(f, path)
|
||||
if not url:
|
||||
return _err('上传失败,请稍后重试', http=500)
|
||||
return _ok({'url': url}, '上传成功')
|
||||
|
||||
|
||||
class GongdanCreateView(APIView):
|
||||
"""创建投诉工单。POST /gongdan/create/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
||||
|
||||
def post(self, request):
|
||||
d = request.data
|
||||
images = d.get('images') or d.get('image_urls') or []
|
||||
if isinstance(images, str):
|
||||
images = [x.strip() for x in images.split(',') if x.strip()]
|
||||
try:
|
||||
gd = create_gongdan(
|
||||
yonghuid=request.user.UserUID,
|
||||
leixing=d.get('leixing'),
|
||||
shuoming=d.get('shuoming') or d.get('content') or '',
|
||||
club_id=_resolve_club(request),
|
||||
order_id=d.get('order_id') or d.get('OrderID'),
|
||||
chongzhi_id=d.get('chongzhi_id') or d.get('dingdanid') or d.get('dingdan_id'),
|
||||
fadan_id=d.get('fadan_id') or d.get('penalty_id'),
|
||||
guanshi_id=d.get('guanshi_id'),
|
||||
target_role=d.get('target_role'),
|
||||
target_user_id=d.get('target_user_id') or d.get('zuzhang_id') or d.get('guanshi_id'),
|
||||
image_urls=images,
|
||||
contact_qq=d.get('contact_qq') or d.get('qq') or '',
|
||||
contact_wechat=d.get('contact_wechat') or d.get('wechat') or d.get('wx') or '',
|
||||
contact_phone=d.get('contact_phone') or d.get('phone') or d.get('mobile') or '',
|
||||
)
|
||||
return _ok(
|
||||
serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID),
|
||||
'提交成功',
|
||||
)
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
except Exception as e:
|
||||
logger.error('创建工单失败: %s', e, exc_info=True)
|
||||
return _err('提交失败,请稍后重试', http=500)
|
||||
|
||||
|
||||
class GongdanMyListView(APIView):
|
||||
"""我的投诉列表。POST/GET /gongdan/my-list/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return self._list(request)
|
||||
|
||||
def post(self, request):
|
||||
return self._list(request)
|
||||
|
||||
def _list(self, request):
|
||||
scope = (
|
||||
request.data.get('scope')
|
||||
or request.data.get('tab')
|
||||
or request.query_params.get('scope')
|
||||
or request.query_params.get('tab')
|
||||
or 'created'
|
||||
)
|
||||
page = int(request.data.get('page') or request.query_params.get('page') or 1)
|
||||
page_size = min(int(request.data.get('page_size') or request.query_params.get('page_size') or 20), 50)
|
||||
club_id = _resolve_club(request)
|
||||
data = list_gongdan_for_user(
|
||||
request.user.UserUID,
|
||||
scope=scope,
|
||||
club_id=club_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return _ok(data)
|
||||
|
||||
|
||||
class GongdanDetailView(APIView):
|
||||
"""工单详情。POST /gongdan/detail/ {gongdan_id}"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
gid = (request.data.get('gongdan_id') or '').strip()
|
||||
if not gid:
|
||||
return _err('工单号不能为空')
|
||||
gd = Gongdan.query.filter(gongdan_id=gid).first()
|
||||
if not gd:
|
||||
return _err('工单不存在', http=404)
|
||||
# 申请人 / 被投诉人 / 牵连组长 / 客服
|
||||
if user_can_view_gongdan(gd, request.user.UserUID):
|
||||
return _ok(serialize_gongdan(
|
||||
gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID,
|
||||
))
|
||||
from backend.utils import verify_kefu_permission
|
||||
from gongdan.constants import PERM_VIEW, PERM_HANDLE
|
||||
kefu, perms_or_resp = verify_kefu_permission(
|
||||
request, request.data.get('username') or getattr(request.user, 'Phone', None),
|
||||
)
|
||||
if not kefu:
|
||||
return perms_or_resp
|
||||
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
|
||||
if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms:
|
||||
is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False)
|
||||
if not is_admin:
|
||||
return _err('无权查看', http=403)
|
||||
return _ok(serialize_gongdan(
|
||||
gd, include_images=True, include_shengji=True, for_admin=True,
|
||||
))
|
||||
|
||||
|
||||
class GongdanEscalateView(APIView):
|
||||
"""继续投诉/升级。POST /gongdan/escalate/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
d = request.data
|
||||
images = d.get('images')
|
||||
if images is not None and isinstance(images, str):
|
||||
images = [x.strip() for x in images.split(',') if x.strip()]
|
||||
try:
|
||||
gd = escalate_gongdan(
|
||||
gongdan_id=(d.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
shuoming=d.get('shuoming') or '',
|
||||
image_urls=images,
|
||||
)
|
||||
return _ok(
|
||||
serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID),
|
||||
'已提交补充投诉',
|
||||
)
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
except Exception as e:
|
||||
logger.error('升级工单失败: %s', e, exc_info=True)
|
||||
return _err('提交失败', http=500)
|
||||
|
||||
|
||||
class GongdanDissatisfiedView(APIView):
|
||||
"""标记不满意。POST /gongdan/dissatisfied/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
gd = user_mark_dissatisfied(
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已标记不满意,可继续投诉')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanResolveView(APIView):
|
||||
"""投诉人确认已解决。POST /gongdan/resolve/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
gd = user_resolve_gongdan(
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已确认解决')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanNegotiateView(APIView):
|
||||
"""转协商中。POST /gongdan/negotiate/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
gd = user_start_negotiate(
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已进入协商')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanApplyInterveneView(APIView):
|
||||
"""申请平台介入。POST /gongdan/apply-intervene/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
gd = user_apply_intervene(
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已申请平台介入')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanWithdrawCheckView(APIView):
|
||||
"""提现前投诉拦截检查。POST /gongdan/withdraw-block-check/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
ok, msg, extra = check_complaint_blocks_withdraw(request.user, club_id=_resolve_club(request))
|
||||
return _ok({
|
||||
'blocked': not ok,
|
||||
'msg': msg,
|
||||
'reason': extra.get('reason') or '',
|
||||
'tab': extra.get('tab') or '',
|
||||
'count': extra.get('count') or 0,
|
||||
})
|
||||
|
||||
|
||||
class GongdanSearchTargetView(APIView):
|
||||
"""本人邀请人(已禁止全库搜索)。POST /gongdan/search-target/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
d = request.data
|
||||
try:
|
||||
data = search_complaint_targets(
|
||||
role=d.get('role') or d.get('target_role') or '',
|
||||
keyword=d.get('keyword') or d.get('q') or '',
|
||||
club_id=_resolve_club(request),
|
||||
page=d.get('page') or 1,
|
||||
page_size=d.get('page_size') or 20,
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(data)
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanMyInviterTargetView(APIView):
|
||||
"""查询本人可投诉的邀请人。POST /gongdan/my-inviter-target/ body: {leixing:4|6}"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
leixing = int(request.data.get('leixing') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return _err('投诉类型无效')
|
||||
if leixing not in (TYPE_GUANSHI, TYPE_ZUZHANG):
|
||||
return _err('仅管事/组长投诉需要邀请人')
|
||||
return _ok(peek_inviter_complaint_target(request.user.UserUID, leixing))
|
||||
|
||||
|
||||
class GongdanMetaView(APIView):
|
||||
"""类型/状态枚举 + 人工客服联系方式。GET /gongdan/meta/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
club_id = _resolve_club(request)
|
||||
cfg = get_club_gongdan_config(club_id) if club_id else get_club_gongdan_config('')
|
||||
return _ok({
|
||||
'types': [{'value': k, 'label': v} for k, v in TYPE_LABELS.items()],
|
||||
'statuses': [{'value': k, 'label': v} for k, v in STATUS_LABELS.items()],
|
||||
'max_images': MAX_IMAGES,
|
||||
'human_kefu': {
|
||||
'wechat': cfg.get('human_kefu_wechat') or '',
|
||||
'link': cfg.get('human_kefu_link') or '',
|
||||
'tip': cfg.get('human_kefu_tip') or '',
|
||||
'available': bool(cfg.get('human_kefu_available')),
|
||||
},
|
||||
'inviter_targets': {
|
||||
'guanshi': peek_inviter_complaint_target(request.user.UserUID, TYPE_GUANSHI),
|
||||
'zuzhang': peek_inviter_complaint_target(request.user.UserUID, TYPE_ZUZHANG),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class ChongzhiRecordListView(APIView):
|
||||
"""当前用户充值记录(Czjilu),供投诉选 ID / 充值记录页。POST /gongdan/chongzhi-list/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
from products.models import Czjilu
|
||||
from products.czjilu_types import (
|
||||
CZJILU_LEIXING_HUIYUAN, CZJILU_LEIXING_YAJIN, CZJILU_LEIXING_JIFEN,
|
||||
CZJILU_LEIXING_SHANGJIA,
|
||||
)
|
||||
page = int(request.data.get('page') or 1)
|
||||
page_size = min(int(request.data.get('page_size') or 20), 50)
|
||||
qs = Czjilu.query.filter(yonghuid=request.user.UserUID).order_by('-CreateTime')
|
||||
leixing = request.data.get('leixing')
|
||||
if leixing not in (None, ''):
|
||||
qs = qs.filter(leixing=int(leixing))
|
||||
# 投诉选单:仅已支付;普通充值记录页不传此参数则展示全部状态
|
||||
only_paid = str(
|
||||
request.data.get('only_paid')
|
||||
or request.data.get('for_complaint')
|
||||
or ''
|
||||
).lower() in ('1', 'true', 'yes')
|
||||
if only_paid:
|
||||
from products.czjilu_types import CZJILU_PAID_STATUS
|
||||
qs = qs.filter(zhuangtai=CZJILU_PAID_STATUS)
|
||||
total = qs.count()
|
||||
start = max(page - 1, 0) * page_size
|
||||
type_map = {
|
||||
CZJILU_LEIXING_HUIYUAN: '会员',
|
||||
CZJILU_LEIXING_YAJIN: '押金',
|
||||
CZJILU_LEIXING_JIFEN: '积分',
|
||||
CZJILU_LEIXING_SHANGJIA: '商家充值',
|
||||
5: '考核',
|
||||
6: '罚款',
|
||||
7: '转盘',
|
||||
}
|
||||
# 与 czjilu 实际状态一致:待支付/已取消/已支付等全量展示,不筛选
|
||||
status_map = {
|
||||
9: '待支付',
|
||||
3: '已支付',
|
||||
2: '已取消',
|
||||
1: '处理中',
|
||||
8: '履约失败',
|
||||
10: '业务失败',
|
||||
0: '未知',
|
||||
}
|
||||
rows = []
|
||||
for o in qs[start:start + page_size]:
|
||||
z = o.zhuangtai
|
||||
try:
|
||||
z_int = int(z) if z is not None else None
|
||||
except (TypeError, ValueError):
|
||||
z_int = None
|
||||
ct = ''
|
||||
if getattr(o, 'CreateTime', None):
|
||||
try:
|
||||
ct = o.CreateTime.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
ct = o.CreateTime.isoformat()
|
||||
rows.append({
|
||||
'dingdan_id': o.dingdan_id,
|
||||
'leixing': o.leixing,
|
||||
'leixing_label': type_map.get(o.leixing, f'类型{o.leixing}'),
|
||||
'jine': str(o.jine),
|
||||
'zhuangtai': z_int if z_int is not None else z,
|
||||
'zhuangtai_label': status_map.get(z_int, f'状态{z}' if z is not None else '未知'),
|
||||
'shuoming': o.shuoming or '',
|
||||
'club_id': getattr(o, 'club_id', '') or '',
|
||||
'CreateTime': ct,
|
||||
})
|
||||
return _ok({'list': rows, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
|
||||
# ---------- 客服后台 ----------
|
||||
|
||||
def _require_kefu_perm(request, *need_codes):
|
||||
from backend.utils import verify_kefu_permission
|
||||
username = request.data.get('username') or request.query_params.get('username') or getattr(request.user, 'Phone', None)
|
||||
kefu, perms_or_resp = verify_kefu_permission(request, username)
|
||||
if not kefu:
|
||||
return None, None, perms_or_resp
|
||||
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
|
||||
is_admin = (
|
||||
getattr(request.user, 'UserType', '') == 'admin'
|
||||
or getattr(request.user, 'IsSuperuser', False)
|
||||
or '000001' in perms
|
||||
)
|
||||
if is_admin:
|
||||
return kefu, perms, None
|
||||
if not any(c in perms for c in need_codes):
|
||||
return None, None, _err('无工单权限', http=403)
|
||||
return kefu, perms, None
|
||||
|
||||
|
||||
class GongdanAdminListView(APIView):
|
||||
"""客服工单列表。POST /gongdan/admin/list/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
|
||||
if err:
|
||||
return err
|
||||
page = int(request.data.get('page') or 1)
|
||||
page_size = min(int(request.data.get('page_size') or 20), 100)
|
||||
from django.db.models import Q
|
||||
qs = Gongdan.query.all().order_by('-CreateTime')
|
||||
try:
|
||||
from jituan.services.club_context import filter_club_char_field
|
||||
qs = filter_club_char_field(qs, request, field='club_id')
|
||||
except Exception:
|
||||
pass
|
||||
zhuangtai = request.data.get('zhuangtai')
|
||||
leixing = request.data.get('leixing')
|
||||
keyword = (request.data.get('keyword') or '').strip()
|
||||
only_escalated = str(request.data.get('only_escalated') or '').lower() in ('1', 'true', 'yes')
|
||||
need_handle = str(request.data.get('need_handle') or '').lower() in ('1', 'true', 'yes')
|
||||
if need_handle:
|
||||
qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5))
|
||||
elif zhuangtai not in (None, ''):
|
||||
qs = qs.filter(zhuangtai=int(zhuangtai))
|
||||
if only_escalated:
|
||||
qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5))
|
||||
if leixing not in (None, ''):
|
||||
qs = qs.filter(leixing=int(leixing))
|
||||
if keyword:
|
||||
qs = qs.filter(
|
||||
Q(gongdan_id__icontains=keyword)
|
||||
| Q(shenqingren_id__icontains=keyword)
|
||||
| Q(target_user_id__icontains=keyword)
|
||||
| Q(guanshi_id__icontains=keyword)
|
||||
| Q(order_id__icontains=keyword)
|
||||
| Q(chongzhi_id__icontains=keyword)
|
||||
| Q(fadan_id__icontains=keyword)
|
||||
)
|
||||
total = qs.count()
|
||||
start = max(page - 1, 0) * page_size
|
||||
rows = [
|
||||
serialize_gongdan(g, include_images=False, for_admin=True)
|
||||
for g in qs[start:start + page_size]
|
||||
]
|
||||
|
||||
# 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量
|
||||
stats_qs = Gongdan.query.all()
|
||||
try:
|
||||
from jituan.services.club_context import filter_club_char_field
|
||||
stats_qs = filter_club_char_field(stats_qs, request, field='club_id')
|
||||
except Exception:
|
||||
pass
|
||||
stats = {
|
||||
'total': stats_qs.count(),
|
||||
'pending': stats_qs.filter(zhuangtai=1).count(),
|
||||
'done': stats_qs.filter(zhuangtai=2).count(),
|
||||
'dissatisfied': stats_qs.filter(zhuangtai=3).count(),
|
||||
'closed': stats_qs.filter(zhuangtai=4).count(),
|
||||
'negotiating': stats_qs.filter(zhuangtai=5).count(),
|
||||
'escalated': stats_qs.filter(shengji_cishu__gt=0).filter(
|
||||
Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)
|
||||
).count(),
|
||||
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)).count(),
|
||||
}
|
||||
return _ok({
|
||||
'list': rows,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'stats': stats,
|
||||
})
|
||||
|
||||
|
||||
class GongdanAdminHandleView(APIView):
|
||||
"""客服处理工单。POST /gongdan/admin/handle/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
|
||||
if err:
|
||||
return err
|
||||
d = request.data
|
||||
handler_uid = getattr(request.user, 'UserUID', '') or getattr(request.user, 'Phone', '')
|
||||
handler_name = getattr(request.user, 'NickName', None) or getattr(request.user, 'Phone', '') or handler_uid
|
||||
try:
|
||||
gd = handle_gongdan(
|
||||
gongdan_id=(d.get('gongdan_id') or '').strip(),
|
||||
handler_uid=handler_uid,
|
||||
handler_name=handler_name,
|
||||
action=d.get('action') or 'done',
|
||||
chuli_jieguo=d.get('chuli_jieguo') or d.get('jieguo') or '',
|
||||
intervene_reason=d.get('intervene_reason') or '',
|
||||
)
|
||||
return _ok(
|
||||
serialize_gongdan(gd, include_images=True, include_shengji=True, for_admin=True),
|
||||
'处理成功',
|
||||
)
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
except Exception as e:
|
||||
logger.error('处理工单失败: %s', e, exc_info=True)
|
||||
return _err('处理失败', http=500)
|
||||
|
||||
|
||||
class GongdanAdminClubConfigView(APIView):
|
||||
"""
|
||||
当前俱乐部工单开关 + 人工客服联系方式。
|
||||
GET/POST /gongdan/admin/club-config/
|
||||
字段:complaint_withdraw_block / human_kefu_wechat / human_kefu_link / human_kefu_tip
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def _club_id(self, request):
|
||||
try:
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
return (resolve_club_id_from_request(request) or '').strip()
|
||||
except Exception:
|
||||
return (getattr(request.user, 'ClubID', None) or getattr(request.user, 'club_id', None) or '').strip()
|
||||
|
||||
def get(self, request):
|
||||
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
|
||||
if err:
|
||||
return err
|
||||
club_id = self._club_id(request)
|
||||
if not club_id:
|
||||
return _err('请先选择俱乐部')
|
||||
return _ok(get_club_gongdan_config(club_id))
|
||||
|
||||
def post(self, request):
|
||||
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
|
||||
if err:
|
||||
return err
|
||||
club_id = self._club_id(request)
|
||||
if not club_id:
|
||||
return _err('请先选择俱乐部')
|
||||
d = request.data or {}
|
||||
patch = {}
|
||||
if 'complaint_withdraw_block' in d:
|
||||
raw = d.get('complaint_withdraw_block')
|
||||
if isinstance(raw, str):
|
||||
patch['complaint_withdraw_block'] = raw.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
else:
|
||||
patch['complaint_withdraw_block'] = bool(raw)
|
||||
for key in ('human_kefu_wechat', 'human_kefu_link', 'human_kefu_tip'):
|
||||
if key in d:
|
||||
patch[key] = d.get(key)
|
||||
if not patch:
|
||||
return _err('缺少可保存字段')
|
||||
try:
|
||||
# 兼容旧前端只传 withdraw 开关
|
||||
if list(patch.keys()) == ['complaint_withdraw_block']:
|
||||
data = set_complaint_withdraw_block(club_id, patch['complaint_withdraw_block'])
|
||||
else:
|
||||
data = update_club_gongdan_config(club_id, **patch)
|
||||
return _ok(data, '已保存')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
@@ -28,3 +28,40 @@ ADMIN_SCOPE_CLUB = 'CLUB'
|
||||
|
||||
# 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图)
|
||||
SUPER_ADMIN_PHONES = frozenset({'1383714110'})
|
||||
|
||||
# 权限码:修改平台订单/商品抢单要求(须角色显式绑定,不随本店超管 000001 自动解锁)
|
||||
PERM_GRAB_REQUIREMENT_EDIT = '002ad'
|
||||
|
||||
# —— 支付通道 ——
|
||||
PAY_CHANNEL_WECHAT_JSAPI = 'wechat_jsapi'
|
||||
PAY_CHANNEL_WECHAT_V3 = 'wechat_v3'
|
||||
PAY_CHANNEL_ALIPAY_WAP = 'alipay_wap'
|
||||
PAY_CHANNEL_FUBEI_WX_MINI = 'fubei_wx_mini'
|
||||
|
||||
# 俱乐部「小程序收款」选用哪条通道(前端仍传 pay_method=wechat,后端按此路由)
|
||||
MINI_PAY_CHANNEL_WECHAT = 'wechat'
|
||||
MINI_PAY_CHANNEL_FUBEI = 'fubei'
|
||||
MINI_PAY_CHANNEL_CHOICES = (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI)
|
||||
|
||||
PAY_METHOD_WECHAT = 'wechat'
|
||||
PAY_METHOD_ALIPAY = 'alipay'
|
||||
PAY_METHOD_FUBEI = 'fubei'
|
||||
|
||||
PAY_METHOD_LABELS = {
|
||||
PAY_METHOD_WECHAT: '微信支付',
|
||||
PAY_METHOD_ALIPAY: '支付宝',
|
||||
PAY_METHOD_FUBEI: '微信支付', # 前端不感知,展示仍为微信
|
||||
}
|
||||
|
||||
PAY_METHOD_TO_CHANNEL = {
|
||||
PAY_METHOD_WECHAT: PAY_CHANNEL_WECHAT_JSAPI,
|
||||
PAY_METHOD_ALIPAY: PAY_CHANNEL_ALIPAY_WAP,
|
||||
PAY_METHOD_FUBEI: PAY_CHANNEL_FUBEI_WX_MINI,
|
||||
}
|
||||
|
||||
FUBEI_GATEWAY_PROD = 'https://shq-api.51fubei.com/gateway'
|
||||
FUBEI_GATEWAY_SANDBOX = 'https://shq-api.51fubei.com/gateway' # 付呗文档未单独给沙箱网关时同正式
|
||||
|
||||
# 付呗下单 biz 标记(写入 attach / 流水表,回调与退款用)
|
||||
FUBEI_BIZ_ORDER = 'order'
|
||||
FUBEI_BIZ_CZJILU = 'czjilu'
|
||||
|
||||
1
jituan/data/README.md
Normal file
1
jituan/data/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# 内置数据目录:UFO 商品导入 catalog 等
|
||||
4046
jituan/data/ufo_catalog.json
Normal file
4046
jituan/data/ufo_catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
210
jituan/management/commands/cleanup_lsx_empty_club.py
Normal file
210
jituan/management/commands/cleanup_lsx_empty_club.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""清理龙先生重复/无 pem 证书俱乐部。
|
||||
|
||||
规则:
|
||||
- 候选:club_id=lsx / 名称含「龙先生」 / wx_appid=龙先生小程序
|
||||
- 「有 pem」:pay_key_path / pay_cert_path / private_key_path 任一路径以 .pem 结尾
|
||||
(你后台上传的证书路径都是 *.pem;脚本自动建的/空配置通常没有)
|
||||
- 「可删」:上述三个字段都没有以 .pem 结尾的路径
|
||||
- 默认 dry-run;--apply 才删除
|
||||
- 若候选里没有任何「有 pem」的对照,默认不删;加 --force-delete-empty-only 才删
|
||||
|
||||
用法:
|
||||
python manage.py cleanup_lsx_empty_club
|
||||
python manage.py cleanup_lsx_empty_club --apply
|
||||
python manage.py cleanup_lsx_empty_club --apply --remove-seed-users
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from jituan.models import (
|
||||
Club,
|
||||
ClubDashouExamConfig,
|
||||
ClubDashouExamPass,
|
||||
ClubDashouExamQuestion,
|
||||
ClubDashouExamQuestionImage,
|
||||
ClubFadanFenhongLilv,
|
||||
ClubHuiyuanBundleInclude,
|
||||
ClubHuiyuanPrice,
|
||||
ClubKaoheChenghaoConfig,
|
||||
ClubLilubiao,
|
||||
ClubPaymentChannel,
|
||||
ClubShangpinLeixingConfig,
|
||||
ClubTixianQuota,
|
||||
ClubWithdrawConfig,
|
||||
)
|
||||
from users.business_models import User
|
||||
from users.models import UserBoss, UserGuanshi, UserZuzhang
|
||||
|
||||
LSX_APPID = 'wx7ff90e9d024fcdb8'
|
||||
SEED_UIDS = ('9910001', '9910002')
|
||||
|
||||
RELATED_MODELS = (
|
||||
ClubPaymentChannel,
|
||||
ClubHuiyuanPrice,
|
||||
ClubHuiyuanBundleInclude,
|
||||
ClubLilubiao,
|
||||
ClubWithdrawConfig,
|
||||
ClubTixianQuota,
|
||||
ClubShangpinLeixingConfig,
|
||||
ClubKaoheChenghaoConfig,
|
||||
ClubFadanFenhongLilv,
|
||||
ClubDashouExamConfig,
|
||||
ClubDashouExamQuestion,
|
||||
ClubDashouExamQuestionImage,
|
||||
ClubDashouExamPass,
|
||||
)
|
||||
|
||||
# 证书「文件」字段(应以 .pem 结尾);platform_cert_dir 是目录,不参与判定
|
||||
_PEM_FILE_FIELDS = ('pay_key_path', 'pay_cert_path', 'private_key_path')
|
||||
|
||||
|
||||
def _path_ends_with_pem(path):
|
||||
return str(path or '').strip().lower().endswith('.pem')
|
||||
|
||||
|
||||
def _has_pem_paths(club):
|
||||
"""后台上传过证书:路径以 .pem 结尾。"""
|
||||
return any(_path_ends_with_pem(getattr(club, f, '')) for f in _PEM_FILE_FIELDS)
|
||||
|
||||
|
||||
def _pem_field_summary(club):
|
||||
lines = []
|
||||
for f in _PEM_FILE_FIELDS:
|
||||
val = (getattr(club, f, '') or '').strip()
|
||||
if not val:
|
||||
tag = '空'
|
||||
elif _path_ends_with_pem(val):
|
||||
tag = 'pem✓'
|
||||
else:
|
||||
tag = '非pem✗'
|
||||
lines.append(f'{f}={val or "(空)"} [{tag}]')
|
||||
plat = (getattr(club, 'platform_cert_dir', '') or '').strip()
|
||||
lines.append(f'platform_cert_dir={plat or "(空)"} [目录仅展示]')
|
||||
return lines
|
||||
|
||||
|
||||
def _candidates():
|
||||
return Club.query.filter(
|
||||
Q(club_id__iexact='lsx')
|
||||
| Q(club_id__icontains='lsx')
|
||||
| Q(name__icontains='龙先生')
|
||||
| Q(wx_appid=LSX_APPID)
|
||||
).order_by('club_id')
|
||||
|
||||
|
||||
def _purge_club_related(club_id, stdout, style):
|
||||
for model in RELATED_MODELS:
|
||||
try:
|
||||
n = model.query.filter(club_id=club_id).count()
|
||||
if n:
|
||||
model.query.filter(club_id=club_id).delete()
|
||||
stdout.write(f' - 已删 {model.__name__} x{n}')
|
||||
except Exception as exc:
|
||||
stdout.write(style.WARNING(f' - 跳过 {model.__name__}: {exc}'))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '列出并删除龙先生相关「证书路径不以 .pem 结尾」的重复俱乐部(默认 dry-run)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正写入删除')
|
||||
parser.add_argument(
|
||||
'--force-delete-empty-only',
|
||||
action='store_true',
|
||||
help='即使没有「有 .pem」的对照俱乐部,也允许删除非 pem 候选',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--remove-seed-users',
|
||||
action='store_true',
|
||||
help='同时删除 seed_lsx_invite_data 种子用户 9910001/9910002',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--club-id',
|
||||
type=str,
|
||||
default='',
|
||||
help='只处理指定 club_id(精确匹配)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
apply = bool(options.get('apply'))
|
||||
force = bool(options.get('force_delete_empty_only'))
|
||||
only_id = (options.get('club_id') or '').strip()
|
||||
|
||||
qs = _candidates()
|
||||
if only_id:
|
||||
qs = Club.query.filter(club_id=only_id)
|
||||
|
||||
clubs = list(qs)
|
||||
if not clubs:
|
||||
self.stdout.write(self.style.WARNING('未找到龙先生/lsx 相关俱乐部'))
|
||||
return
|
||||
|
||||
with_pem = []
|
||||
without_pem = []
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write('判定标准:pay_key_path / pay_cert_path / private_key_path 是否以 .pem 结尾')
|
||||
self.stdout.write('龙先生相关俱乐部扫描结果:')
|
||||
for c in clubs:
|
||||
keyed = _has_pem_paths(c)
|
||||
(with_pem if keyed else without_pem).append(c)
|
||||
self.stdout.write('-' * 40)
|
||||
self.stdout.write(f'club_id={c.club_id} name={c.name} status={c.status}')
|
||||
self.stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
|
||||
for line in _pem_field_summary(c):
|
||||
self.stdout.write(f' {line}')
|
||||
self.stdout.write(
|
||||
f' >>> {"有 .pem 路径(保留)" if keyed else "无 .pem 路径(可删)"}'
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(f'有 .pem: {len(with_pem)} 条;无 .pem: {len(without_pem)} 条')
|
||||
|
||||
if not without_pem:
|
||||
self.stdout.write(self.style.SUCCESS('没有「无 .pem 路径」的俱乐部,无需删除。'))
|
||||
elif not with_pem and not force:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'全部候选都没有 .pem 路径。为安全起见不自动删。'
|
||||
'若确认要删,加 --force-delete-empty-only --apply'
|
||||
))
|
||||
else:
|
||||
for c in without_pem:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'将删除无 .pem 俱乐部: {c.club_id} ({c.name})'
|
||||
))
|
||||
if not apply:
|
||||
self.stdout.write(' (dry-run,未真正删除;加 --apply 执行)')
|
||||
continue
|
||||
with transaction.atomic():
|
||||
_purge_club_related(c.club_id, self.stdout, self.style)
|
||||
Club.query.filter(club_id=c.club_id).delete()
|
||||
self.stdout.write(self.style.SUCCESS(f' 已删除俱乐部 {c.club_id}'))
|
||||
|
||||
if options.get('remove_seed_users'):
|
||||
self.stdout.write('-' * 40)
|
||||
self.stdout.write('处理种子用户 9910001 / 9910002 ...')
|
||||
for uid in SEED_UIDS:
|
||||
user = User.query.filter(UserUID=uid).first()
|
||||
if not user:
|
||||
self.stdout.write(f' {uid}: 不存在')
|
||||
continue
|
||||
self.stdout.write(
|
||||
f' {uid}: ClubID={user.ClubID} Phone={user.Phone} OpenID={user.OpenID}'
|
||||
)
|
||||
if not apply:
|
||||
self.stdout.write(' (dry-run)')
|
||||
continue
|
||||
with transaction.atomic():
|
||||
UserZuzhang.query.filter(user=user).delete()
|
||||
UserGuanshi.query.filter(user=user).delete()
|
||||
UserBoss.query.filter(user=user).delete()
|
||||
user.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f' 已删除用户 {uid}'))
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n当前为预览。确认后执行:\n'
|
||||
' python manage.py cleanup_lsx_empty_club --apply\n'
|
||||
'如需顺带删种子账号:\n'
|
||||
' python manage.py cleanup_lsx_empty_club --apply --remove-seed-users'
|
||||
))
|
||||
387
jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py
Normal file
387
jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py
Normal file
@@ -0,0 +1,387 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
把龙先生(lxs)审核版商品目录复制到小溪(xx)。
|
||||
|
||||
范围(必须全部满足):
|
||||
- 源俱乐部固定 lxs(name 含「龙先生」)
|
||||
- 目标俱乐部固定 xx(name 含「小溪」「小西」或「小汐」)
|
||||
- 仅全局 ShangpinLeixing.shenhezhuangtai=2 的类型
|
||||
- 仅公共目录 dianpu IS NULL
|
||||
- 专区/商品均要求 shenhezhuangtai=2
|
||||
|
||||
步骤:
|
||||
1) (可选)清空 xx 下上述审核类型的专区+商品(只删 DB,不删 OSS)
|
||||
2) 从 lxs 复制同类型专区+商品到 xx(图片复用相对路径;销量归零)
|
||||
3) 确保 xx 的 ClubShangpinLeixingConfig 启用这些审核类型(图标可复用源配置)
|
||||
|
||||
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、全局类型表、订单等。
|
||||
|
||||
用法:
|
||||
python manage.py copy_lxs_shenhe_catalog_to_xx --list
|
||||
python manage.py copy_lxs_shenhe_catalog_to_xx
|
||||
python manage.py copy_lxs_shenhe_catalog_to_xx --apply --i-confirm=lxs-to-xx
|
||||
python manage.py copy_lxs_shenhe_catalog_to_xx --apply --i-confirm=lxs-to-xx --clear-target
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
SOURCE_CLUB = 'lxs'
|
||||
SOURCE_NAME_HINT = '龙先生'
|
||||
TARGET_CLUB = 'xx'
|
||||
TARGET_NAME_HINTS = ('小溪', '小西', '小汐')
|
||||
SHENHE = 2
|
||||
CONFIRM_TOKEN = 'lxs-to-xx'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '复制龙先生审核专区/商品到小溪(xx)(默认 dry-run)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||
parser.add_argument(
|
||||
'--i-confirm',
|
||||
type=str,
|
||||
default='',
|
||||
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--clear-target',
|
||||
action='store_true',
|
||||
help='写入前先清空 xx 下对应审核专区/商品(只删 DB)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list',
|
||||
action='store_true',
|
||||
help='只列出审核类型及 lxs/xx 下专区商品数量',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club, ClubShangpinLeixingConfig
|
||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm') or '').strip()
|
||||
clear_target = bool(options.get('clear_target'))
|
||||
|
||||
src_club = Club.query.filter(club_id=SOURCE_CLUB).first()
|
||||
if not src_club:
|
||||
raise CommandError(f'源俱乐部 {SOURCE_CLUB} 不存在')
|
||||
if SOURCE_NAME_HINT not in (src_club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:{SOURCE_CLUB} 的 name={src_club.name!r} 不含「{SOURCE_NAME_HINT}」'
|
||||
)
|
||||
|
||||
dst_club = Club.query.filter(club_id=TARGET_CLUB).first()
|
||||
if not dst_club:
|
||||
raise CommandError(
|
||||
f'目标俱乐部 {TARGET_CLUB} 不存在。请先在后台创建小溪俱乐部(club_id=xx)。'
|
||||
)
|
||||
if not any(h in (dst_club.name or '') for h in TARGET_NAME_HINTS):
|
||||
raise CommandError(
|
||||
f'安全拦截:{TARGET_CLUB} 的 name={dst_club.name!r} '
|
||||
f'不含「{"/".join(TARGET_NAME_HINTS)}」'
|
||||
)
|
||||
|
||||
audit_types = list(
|
||||
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||
)
|
||||
if not audit_types:
|
||||
raise CommandError('没有任何审核专用商品类型(shenhezhuangtai=2)')
|
||||
if len(audit_types) != 3:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'注意:当前审核类型数量={len(audit_types)}(预期常为 3),将按实际全部处理。'
|
||||
))
|
||||
|
||||
leixing_ids = [lx.id for lx in audit_types]
|
||||
|
||||
def counts(club_id):
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
return z, p
|
||||
|
||||
def per_type(club_id):
|
||||
rows = []
|
||||
for lx in audit_types:
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
rows.append((lx, z, p))
|
||||
return rows
|
||||
|
||||
if options.get('list'):
|
||||
self.stdout.write(self.style.NOTICE('审核类型及两侧存量:'))
|
||||
for lx in audit_types:
|
||||
sz, sp = (
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
Shangpin.query.filter(
|
||||
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
)
|
||||
dz, dp = (
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
Shangpin.query.filter(
|
||||
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
)
|
||||
self.stdout.write(
|
||||
f' id={lx.id}「{lx.jieshao}」 '
|
||||
f'lxs: zones={sz} products={sp} | '
|
||||
f'xx: zones={dz} products={dp}'
|
||||
)
|
||||
return
|
||||
|
||||
if apply and confirm != CONFIRM_TOKEN:
|
||||
raise CommandError(
|
||||
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||
f' python manage.py copy_lxs_shenhe_catalog_to_xx '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||
)
|
||||
|
||||
src_z_total, src_p_total = counts(SOURCE_CLUB)
|
||||
dst_z_total, dst_p_total = counts(TARGET_CLUB)
|
||||
|
||||
src_zones = list(
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=SOURCE_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).order_by('leixing_id', 'paixu', 'id')
|
||||
)
|
||||
src_products = list(
|
||||
Shangpin.query.filter(
|
||||
club_id=SOURCE_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).order_by('leixing_id', 'paixu', 'id')
|
||||
)
|
||||
|
||||
src_zone_ids = {z.id for z in src_zones}
|
||||
orphan = [p for p in src_products if p.zhuanqu_id not in src_zone_ids]
|
||||
if orphan:
|
||||
sample = ', '.join(f'{p.id}:{p.biaoqian}' for p in orphan[:5])
|
||||
raise CommandError(
|
||||
f'源商品有 {len(orphan)} 条专区不在本批审核专区内,已中止。样例: {sample}'
|
||||
)
|
||||
|
||||
if src_z_total == 0 and src_p_total == 0:
|
||||
raise CommandError('龙先生(lxs)下没有可复制的审核专区/商品')
|
||||
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} 小溪审核目录 ← 龙先生 =====\n'
|
||||
f'源: {SOURCE_CLUB} ({src_club.name}) '
|
||||
f'专区={src_z_total} 商品={src_p_total}\n'
|
||||
f'目标: {TARGET_CLUB} ({dst_club.name}) '
|
||||
f'现有 专区={dst_z_total} 商品={dst_p_total}\n'
|
||||
f'clear_target={clear_target}\n'
|
||||
f'审核类型数={len(audit_types)} ids={leixing_ids}\n'
|
||||
f'OSS: 不删文件;复制时复用龙先生相对路径(同一 COS 域名拼接)\n'
|
||||
))
|
||||
|
||||
self.stdout.write('—— 分类型存量 ——')
|
||||
for lx, sz, sp in per_type(SOURCE_CLUB):
|
||||
self.stdout.write(f' [lxs] id={lx.id}「{lx.jieshao}」 zones={sz} products={sp}')
|
||||
for lx, dz, dp in per_type(TARGET_CLUB):
|
||||
self.stdout.write(f' [xx] id={lx.id}「{lx.jieshao}」 zones={dz} products={dp}')
|
||||
|
||||
self.stdout.write('—— 将从 lxs 复制的专区样例 ——')
|
||||
for z in src_zones[:40]:
|
||||
self.stdout.write(
|
||||
f' zq#{z.id} leixing={z.leixing_id} {z.mingzi!r}'
|
||||
)
|
||||
if len(src_zones) > 40:
|
||||
self.stdout.write(f' ... 其余 {len(src_zones) - 40} 个省略')
|
||||
|
||||
self.stdout.write('—— 将从 lxs 复制的商品样例 ——')
|
||||
for p in src_products[:15]:
|
||||
self.stdout.write(
|
||||
f' sp#{p.id} leixing={p.leixing_id} {p.biaoqian!r} ¥{p.jiage}'
|
||||
)
|
||||
if len(src_products) > 15:
|
||||
self.stdout.write(f' ... 其余 {len(src_products) - 15} 个省略')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划:未删库、未写库。确认无误后:\n'
|
||||
f' python manage.py copy_lxs_shenhe_catalog_to_xx '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||
f'若目标已有旧审核目录需先清空,再加 --clear-target\n'
|
||||
))
|
||||
return
|
||||
|
||||
stats = {
|
||||
'deleted_products': 0,
|
||||
'deleted_zones': 0,
|
||||
'zones_created': 0,
|
||||
'products_created': 0,
|
||||
'cfg_created': 0,
|
||||
'cfg_enabled': 0,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
if clear_target:
|
||||
n_prod, _ = Shangpin.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
stats['deleted_products'] = n_prod
|
||||
stats['deleted_zones'] = n_zone
|
||||
|
||||
zone_id_map = {}
|
||||
for z in src_zones:
|
||||
if z.leixing_id not in leixing_ids:
|
||||
raise CommandError(f'专区 {z.id} 类型 {z.leixing_id} 不在审核列表')
|
||||
# 未 clear 时若同名专区已存在则复用,避免重复堆叠
|
||||
exist = None
|
||||
if not clear_target:
|
||||
exist = ShangpinZhuanqu.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id=z.leixing_id,
|
||||
mingzi=z.mingzi,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist:
|
||||
zone_id_map[z.id] = exist.id
|
||||
continue
|
||||
row = ShangpinZhuanqu.query.create(
|
||||
mingzi=z.mingzi,
|
||||
leixing_id=z.leixing_id,
|
||||
club_id=TARGET_CLUB,
|
||||
tupian_url=z.tupian_url or '',
|
||||
shenhezhuangtai=SHENHE,
|
||||
paixu=int(z.paixu or 0),
|
||||
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
zone_id_map[z.id] = row.id
|
||||
stats['zones_created'] += 1
|
||||
|
||||
for p in src_products:
|
||||
if p.leixing_id not in leixing_ids:
|
||||
raise CommandError(f'商品 {p.id} 类型 {p.leixing_id} 不在审核列表')
|
||||
dst_zq = zone_id_map.get(p.zhuanqu_id)
|
||||
if not dst_zq:
|
||||
raise CommandError(
|
||||
f'商品「{p.biaoqian}」id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
|
||||
)
|
||||
if not clear_target:
|
||||
exist_p = Shangpin.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id=p.leixing_id,
|
||||
zhuanqu_id=dst_zq,
|
||||
biaoqian=p.biaoqian,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_p:
|
||||
continue
|
||||
Shangpin.query.create(
|
||||
biaoqian=p.biaoqian,
|
||||
jiage=p.jiage,
|
||||
kucun=p.kucun,
|
||||
leixing_id=p.leixing_id,
|
||||
zhuanqu_id=dst_zq,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=p.jieshao,
|
||||
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||
guize_tupian=p.guize_tupian or '',
|
||||
tupian_url=p.tupian_url or '',
|
||||
yaoqiuleixing=p.yaoqiuleixing,
|
||||
huiyuan_id=p.huiyuan_id,
|
||||
yongjin=p.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||
paixu=int(p.paixu or 0),
|
||||
shenhezhuangtai=SHENHE,
|
||||
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
|
||||
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
|
||||
club_id=TARGET_CLUB,
|
||||
shi_zhuanpan=False,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
# 启用审核类型上架配置
|
||||
for lx in audit_types:
|
||||
src_cfg = ClubShangpinLeixingConfig.query.filter(
|
||||
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||
).first()
|
||||
dst_cfg = ClubShangpinLeixingConfig.query.filter(
|
||||
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||
).first()
|
||||
icon = ''
|
||||
paixu = int(getattr(lx, 'paixu', 0) or 0)
|
||||
if src_cfg:
|
||||
icon = src_cfg.tupian_url or ''
|
||||
paixu = int(src_cfg.paixu or paixu)
|
||||
if dst_cfg is None:
|
||||
ClubShangpinLeixingConfig.query.create(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id=lx.id,
|
||||
is_enabled=True,
|
||||
tupian_url=icon,
|
||||
paixu=paixu,
|
||||
)
|
||||
stats['cfg_created'] += 1
|
||||
else:
|
||||
changed = False
|
||||
if not dst_cfg.is_enabled:
|
||||
dst_cfg.is_enabled = True
|
||||
changed = True
|
||||
if icon and not (dst_cfg.tupian_url or '').strip():
|
||||
dst_cfg.tupian_url = icon
|
||||
changed = True
|
||||
if changed:
|
||||
dst_cfg.save()
|
||||
stats['cfg_enabled'] += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n完成: {stats}\n'
|
||||
f'龙先生(lxs)未改动;小溪(xx)已具备审核目录。\n'
|
||||
f'请用小溪小程序(CLUB_ID=xx)重新编译验证。'
|
||||
))
|
||||
@@ -0,0 +1,391 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
将龙先生(lxs)审核类型「娱乐护航」下的专区+商品,复制到其它审核专用商品类型。
|
||||
|
||||
背景:全局通常有 3 个 shenhezhuangtai=2 的商品类型;其中「娱乐护航」已有专区/商品,
|
||||
另外两个往往为空。本命令只增不减,把源类型目录镜像到目标类型。
|
||||
|
||||
范围:
|
||||
- 仅 club_id=lxs(Club.name 须含「龙先生」)
|
||||
- 仅 shenhezhuangtai=2(审核专用)
|
||||
- 仅公共目录 dianpu IS NULL
|
||||
- 源类型:名称精确「娱乐护航」(可用 --source-name 覆盖)
|
||||
- 目标:除源以外的全部审核类型(或 --target-id 指定)
|
||||
|
||||
不碰:正常类型/商品、其它俱乐部、店铺商品、源数据、全局类型表本身。
|
||||
|
||||
图片:复用源相对路径(同俱乐部审核素材,不另拷 OSS)。
|
||||
|
||||
用法:
|
||||
python manage.py copy_lxs_shenhe_huhang_to_other_types
|
||||
python manage.py copy_lxs_shenhe_huhang_to_other_types --list-leixing
|
||||
python manage.py copy_lxs_shenhe_huhang_to_other_types --apply --i-confirm-club=lxs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
ALLOWED_CLUB_ID = 'lxs'
|
||||
NAME_HINT = '龙先生'
|
||||
SHENHE = 2
|
||||
DEFAULT_SOURCE_NAME = '娱乐护航'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '龙先生:把审核类型「娱乐护航」的专区/商品复制到其它审核类型(默认 dry-run)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||
parser.add_argument(
|
||||
'--i-confirm-club',
|
||||
type=str,
|
||||
default='',
|
||||
help='必须等于 lxs 才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--club-id',
|
||||
type=str,
|
||||
default=ALLOWED_CLUB_ID,
|
||||
help='仅允许 lxs',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--source-name',
|
||||
type=str,
|
||||
default=DEFAULT_SOURCE_NAME,
|
||||
help=f'源审核类型全名精确匹配,默认「{DEFAULT_SOURCE_NAME}」',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--source-id',
|
||||
type=int,
|
||||
default=0,
|
||||
help='可选:直接指定源 ShangpinLeixing.id(须为审核专用且名称校验)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--target-id',
|
||||
type=int,
|
||||
action='append',
|
||||
default=[],
|
||||
help='可选:只复制到指定审核类型 id,可多次传入;默认=除源外全部审核类型',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list-leixing',
|
||||
action='store_true',
|
||||
help='只列出审核类型及 lxs 下专区/商品数量',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club
|
||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm_club') or '').strip()
|
||||
source_name = (options.get('source_name') or DEFAULT_SOURCE_NAME).strip()
|
||||
|
||||
if club_id != ALLOWED_CLUB_ID:
|
||||
raise CommandError(
|
||||
f'安全拦截:只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
|
||||
)
|
||||
|
||||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||
if not club:
|
||||
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||
if NAME_HINT not in (club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{NAME_HINT}」'
|
||||
)
|
||||
|
||||
audit_types = list(
|
||||
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||
)
|
||||
if not audit_types:
|
||||
raise CommandError('库中没有任何审核专用商品类型(shenhezhuangtai=2)')
|
||||
|
||||
def count_for(leixing_id):
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=leixing_id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=leixing_id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
return z, p
|
||||
|
||||
if options.get('list_leixing'):
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'审核类型清单(club={ALLOWED_CLUB_ID} 下专区/商品数):'
|
||||
))
|
||||
for lx in audit_types:
|
||||
zc, pc = count_for(lx.id)
|
||||
mark = ' <<-- 源候选' if (lx.jieshao or '') == source_name else ''
|
||||
self.stdout.write(
|
||||
f' id={lx.id} name={lx.jieshao!r} zones={zc} products={pc}{mark}'
|
||||
)
|
||||
return
|
||||
|
||||
if apply and confirm != ALLOWED_CLUB_ID:
|
||||
raise CommandError(
|
||||
'安全拦截:--apply 必须带 --i-confirm-club=lxs\n'
|
||||
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
|
||||
'--apply --i-confirm-club=lxs'
|
||||
)
|
||||
|
||||
# 解析源类型
|
||||
source_id = int(options.get('source_id') or 0)
|
||||
if source_id:
|
||||
source = ShangpinLeixing.query.filter(id=source_id).first()
|
||||
if not source:
|
||||
raise CommandError(f'找不到类型 id={source_id}')
|
||||
if source.shenhezhuangtai != SHENHE:
|
||||
raise CommandError(f'类型 id={source_id} 不是审核专用')
|
||||
if (source.jieshao or '') != source_name:
|
||||
raise CommandError(
|
||||
f'安全拦截:id={source_id} 名称是「{source.jieshao}」,'
|
||||
f'与要求的「{source_name}」不一致'
|
||||
)
|
||||
else:
|
||||
matches = [lx for lx in audit_types if (lx.jieshao or '') == source_name]
|
||||
if not matches:
|
||||
# 宽松:名称包含「娱乐护」且含「航」或等于常见别名
|
||||
soft = [
|
||||
lx for lx in audit_types
|
||||
if '娱乐护' in (lx.jieshao or '')
|
||||
]
|
||||
hint = '\n'.join(
|
||||
f' id={lx.id} name={lx.jieshao!r}' for lx in audit_types
|
||||
)
|
||||
raise CommandError(
|
||||
f'找不到审核类型全名「{source_name}」。当前审核类型:\n{hint}\n'
|
||||
f'可用 --source-id=... 或 --source-name=... 指定。'
|
||||
+ (f'\n名称含「娱乐护」的有: {[s.jieshao for s in soft]}' if soft else '')
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise CommandError(
|
||||
f'「{source_name}」匹配到多条 {[m.id for m in matches]},请 --source-id='
|
||||
)
|
||||
source = matches[0]
|
||||
|
||||
src_zones_n, src_prods_n = count_for(source.id)
|
||||
if src_zones_n == 0 and src_prods_n == 0:
|
||||
raise CommandError(
|
||||
f'源类型 id={source.id}「{source.jieshao}」在 {ALLOWED_CLUB_ID} 下'
|
||||
f'没有任何审核专区/商品,无需复制'
|
||||
)
|
||||
|
||||
# 目标类型
|
||||
target_ids = [int(x) for x in (options.get('target_id') or [])]
|
||||
if target_ids:
|
||||
targets = []
|
||||
for tid in target_ids:
|
||||
lx = ShangpinLeixing.query.filter(id=tid).first()
|
||||
if not lx:
|
||||
raise CommandError(f'目标类型 id={tid} 不存在')
|
||||
if lx.shenhezhuangtai != SHENHE:
|
||||
raise CommandError(f'目标类型 id={tid} 不是审核专用,拒绝')
|
||||
if lx.id == source.id:
|
||||
raise CommandError('目标不能与源相同')
|
||||
targets.append(lx)
|
||||
else:
|
||||
targets = [lx for lx in audit_types if lx.id != source.id]
|
||||
|
||||
if not targets:
|
||||
raise CommandError('没有可复制的目标审核类型(除源外为空)')
|
||||
|
||||
src_zones = list(
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=source.id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).order_by('paixu', 'id')
|
||||
)
|
||||
src_products = list(
|
||||
Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=source.id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).order_by('paixu', 'id')
|
||||
)
|
||||
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} 复制审核目录 =====\n'
|
||||
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||
f'源类型: id={source.id}「{source.jieshao}」 '
|
||||
f'专区={len(src_zones)} 商品={len(src_products)}\n'
|
||||
f'目标类型({len(targets)}): '
|
||||
+ ', '.join(f'id={t.id}「{t.jieshao}」' for t in targets)
|
||||
+ '\n'
|
||||
))
|
||||
|
||||
for t in targets:
|
||||
zc, pc = count_for(t.id)
|
||||
self.stdout.write(f' 目标当前存量 id={t.id}「{t.jieshao}」 zones={zc} products={pc}')
|
||||
|
||||
self.stdout.write('—— 源专区 ——')
|
||||
for z in src_zones:
|
||||
self.stdout.write(f' id={z.id} {z.mingzi!r} paixu={z.paixu}')
|
||||
self.stdout.write('—— 源商品样例 ——')
|
||||
for p in src_products[:8]:
|
||||
self.stdout.write(f' id={p.id} {p.biaoqian!r} ¥{p.jiage} zq={p.zhuanqu_id}')
|
||||
if len(src_products) > 8:
|
||||
self.stdout.write(f' ... 其余 {len(src_products) - 8} 个省略')
|
||||
|
||||
# 预计算每个目标的 create/skip
|
||||
preview = []
|
||||
for t in targets:
|
||||
z_create = z_skip = p_create = p_skip = 0
|
||||
for z in src_zones:
|
||||
exist_z = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=t.id,
|
||||
mingzi=z.mingzi,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_z:
|
||||
z_skip += 1
|
||||
else:
|
||||
z_create += 1
|
||||
# 商品去重按:目标类型下 同标题+同专区名(专区可能尚未建,用名推断)
|
||||
for p in src_products:
|
||||
src_z = next((x for x in src_zones if x.id == p.zhuanqu_id), None)
|
||||
zname = src_z.mingzi if src_z else None
|
||||
exist_zq = None
|
||||
if zname:
|
||||
exist_zq = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=t.id,
|
||||
mingzi=zname,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_zq:
|
||||
exist_p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=t.id,
|
||||
zhuanqu_id=exist_zq.id,
|
||||
biaoqian=p.biaoqian,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_p:
|
||||
p_skip += 1
|
||||
else:
|
||||
p_create += 1
|
||||
else:
|
||||
p_create += 1
|
||||
preview.append((t, z_create, z_skip, p_create, p_skip))
|
||||
self.stdout.write(
|
||||
f' 计划 →「{t.jieshao}」: zones +{z_create}/skip{z_skip}, '
|
||||
f'products +{p_create}/skip{p_skip}'
|
||||
)
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划,未写库。确认后:\n'
|
||||
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
|
||||
'--apply --i-confirm-club=lxs\n'
|
||||
))
|
||||
return
|
||||
|
||||
stats = {
|
||||
'targets': 0,
|
||||
'zones_created': 0,
|
||||
'zones_skipped': 0,
|
||||
'products_created': 0,
|
||||
'products_skipped': 0,
|
||||
'products_skip_no_zone': 0,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
for t in targets:
|
||||
stats['targets'] += 1
|
||||
zone_map = {} # src_zone_id -> dst_zone_id
|
||||
|
||||
for z in src_zones:
|
||||
exist_z = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=t.id,
|
||||
mingzi=z.mingzi,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_z:
|
||||
zone_map[z.id] = exist_z.id
|
||||
stats['zones_skipped'] += 1
|
||||
continue
|
||||
row = ShangpinZhuanqu.query.create(
|
||||
mingzi=z.mingzi,
|
||||
leixing_id=t.id,
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
tupian_url=z.tupian_url or '',
|
||||
shenhezhuangtai=SHENHE,
|
||||
paixu=int(z.paixu or 0),
|
||||
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
zone_map[z.id] = row.id
|
||||
stats['zones_created'] += 1
|
||||
|
||||
for p in src_products:
|
||||
dst_zq = zone_map.get(p.zhuanqu_id)
|
||||
if not dst_zq:
|
||||
stats['products_skip_no_zone'] += 1
|
||||
self.stderr.write(
|
||||
f'跳过无专区映射商品: {p.biaoqian!r} src_zq={p.zhuanqu_id} →「{t.jieshao}」'
|
||||
)
|
||||
continue
|
||||
exist_p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id=t.id,
|
||||
zhuanqu_id=dst_zq,
|
||||
biaoqian=p.biaoqian,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_p:
|
||||
stats['products_skipped'] += 1
|
||||
continue
|
||||
Shangpin.query.create(
|
||||
biaoqian=p.biaoqian,
|
||||
jiage=p.jiage,
|
||||
kucun=p.kucun,
|
||||
leixing_id=t.id,
|
||||
zhuanqu_id=dst_zq,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=p.jieshao,
|
||||
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||
guize_tupian=p.guize_tupian or '',
|
||||
tupian_url=p.tupian_url or '',
|
||||
yaoqiuleixing=p.yaoqiuleixing,
|
||||
huiyuan_id=p.huiyuan_id,
|
||||
yongjin=p.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||
paixu=int(p.paixu or 0),
|
||||
shenhezhuangtai=SHENHE,
|
||||
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
|
||||
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
shi_zhuanpan=False,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n完成: {stats}\n'
|
||||
f'源「{source.jieshao}」未改动;目标均为审核专用且仅 lxs。'
|
||||
))
|
||||
502
jituan/management/commands/copy_ufo_sanjiaozhou_catalog.py
Normal file
502
jituan/management/commands/copy_ufo_sanjiaozhou_catalog.py
Normal file
@@ -0,0 +1,502 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
将 UFO 俱乐部已上架的「三角洲」公共商品目录复制到龙先生俱乐部(只增不减)。
|
||||
|
||||
涉及表(只写目标俱乐部,不改源、不删任何行):
|
||||
- club_shangpin_leixing_config ClubShangpinLeixingConfig 上架类型
|
||||
- shangpin_zhuanqu ShangpinZhuanqu 公共专区
|
||||
- shangpin Shangpin 公共商品
|
||||
- OSS copy_in_oss 图物理复制到新相对路径
|
||||
|
||||
不碰:
|
||||
- shangpin_leixing(全局类型只复用,不新建)
|
||||
- 店铺商品(dianpu 非空)
|
||||
- 订单/统计/轮播/转盘活动表
|
||||
- UFO 及其它俱乐部任何行
|
||||
|
||||
用法(服务器):
|
||||
|
||||
cd /path/to/django
|
||||
./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs
|
||||
# 确认清单后:
|
||||
./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs --apply --i-confirm-to-club=lxs
|
||||
|
||||
注意:龙先生 club_id 可能是 lxs 或 lsx,脚本不猜测,必须显式传 --to-club,
|
||||
且 Club.name 必须包含「龙先生」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
DEFAULT_FROM_CLUB = 'ufo'
|
||||
DEFAULT_LEIXING_NAME = '三角洲'
|
||||
NAME_HINT = '龙先生'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
'复制 UFO「三角洲」公共上架商品目录到龙先生(默认 dry-run;'
|
||||
'只 insert / 启用类型,不删不改源)'
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--from-club',
|
||||
type=str,
|
||||
default=DEFAULT_FROM_CLUB,
|
||||
help=f'源俱乐部,默认 {DEFAULT_FROM_CLUB}',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--to-club',
|
||||
type=str,
|
||||
default='',
|
||||
help='目标俱乐部 club_id(必须显式指定,如 lxs;--list-clubs 时可省略)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--leixing-name',
|
||||
type=str,
|
||||
default=DEFAULT_LEIXING_NAME,
|
||||
help=f'全局类型名全名精确匹配,默认「{DEFAULT_LEIXING_NAME}」',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--leixing-id',
|
||||
type=int,
|
||||
default=0,
|
||||
help='可选:直接指定全局 ShangpinLeixing.id(仍校验名称)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--apply',
|
||||
action='store_true',
|
||||
help='真正写库 + OSS 复制(默认仅 dry-run)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--i-confirm-to-club',
|
||||
type=str,
|
||||
default='',
|
||||
help='与 --to-club 完全一致时才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list-clubs',
|
||||
action='store_true',
|
||||
help='只列出 Club 表(含名称含「龙先生」的候选),不复制',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club, ClubShangpinLeixingConfig
|
||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||
from utils.oss_utils import copy_in_oss
|
||||
|
||||
if options.get('list_clubs'):
|
||||
self._list_clubs(Club)
|
||||
return
|
||||
|
||||
from_club = (options.get('from_club') or DEFAULT_FROM_CLUB).strip()
|
||||
to_club = (options.get('to_club') or '').strip()
|
||||
leixing_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip()
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm_to_club') or '').strip()
|
||||
|
||||
if not to_club:
|
||||
raise CommandError(
|
||||
'必须传 --to-club=...(龙先生当前库为 lxs)。'
|
||||
'仅查看俱乐部列表时用:python manage.py copy_ufo_sanjiaozhou_catalog --list-clubs'
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.NOTICE('—— Club 表候选(名称含「龙先生」)——'))
|
||||
self._list_clubs(Club, only_hint=True)
|
||||
|
||||
src_club = Club.query.filter(club_id=from_club).first()
|
||||
if not src_club:
|
||||
raise CommandError(f'源俱乐部不存在: club_id={from_club!r}')
|
||||
|
||||
dst_club = Club.query.filter(club_id=to_club).first()
|
||||
if not dst_club:
|
||||
raise CommandError(
|
||||
f'目标俱乐部不存在: club_id={to_club!r}。'
|
||||
f'请先 --list-clubs 核对(常见:lxs 与 lsx 写反)。'
|
||||
)
|
||||
if NAME_HINT not in (dst_club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:目标 club_id={to_club!r} 的 name={dst_club.name!r} '
|
||||
f'不含「{NAME_HINT}」,拒绝执行。'
|
||||
)
|
||||
if from_club == to_club:
|
||||
raise CommandError('源与目标 club_id 不能相同')
|
||||
|
||||
if apply:
|
||||
if confirm != to_club:
|
||||
raise CommandError(
|
||||
'安全拦截:--apply 必须同时传 --i-confirm-to-club,且与 --to-club 完全一致。\n'
|
||||
f' 例:--to-club={to_club} --apply --i-confirm-to-club={to_club}'
|
||||
)
|
||||
|
||||
leixing = self._resolve_leixing(
|
||||
ShangpinLeixing,
|
||||
leixing_id=int(options.get('leixing_id') or 0),
|
||||
leixing_name=leixing_name,
|
||||
)
|
||||
if leixing.shenhezhuangtai == 2:
|
||||
raise CommandError(
|
||||
f'类型 id={leixing.id}「{leixing.jieshao}」是审核专用,拒绝挂载'
|
||||
)
|
||||
|
||||
# 源:公共专区 + 上架商品(dianpu 为空 = 俱乐部公共目录)
|
||||
src_zones = list(
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=from_club,
|
||||
leixing_id=leixing.id,
|
||||
shenhezhuangtai=1,
|
||||
dianpu__isnull=True,
|
||||
).order_by('paixu', 'id')
|
||||
)
|
||||
|
||||
zone_ids = [z.id for z in src_zones]
|
||||
src_products = []
|
||||
if zone_ids:
|
||||
src_products = list(
|
||||
Shangpin.query.filter(
|
||||
club_id=from_club,
|
||||
leixing_id=leixing.id,
|
||||
zhuanqu_id__in=zone_ids,
|
||||
shenhezhuangtai=1,
|
||||
shangjia_zhuangtai=True,
|
||||
dianpu__isnull=True,
|
||||
).order_by('paixu', 'id')
|
||||
)
|
||||
|
||||
# 计划:Config
|
||||
cfg_existing = ClubShangpinLeixingConfig.query.filter(
|
||||
club_id=to_club, leixing_id=leixing.id,
|
||||
).first()
|
||||
if cfg_existing is None:
|
||||
cfg_action = 'CREATE_ENABLE'
|
||||
elif not cfg_existing.is_enabled:
|
||||
cfg_action = 'ENABLE_EXISTING'
|
||||
else:
|
||||
cfg_action = 'SKIP_ALREADY_ENABLED'
|
||||
|
||||
# 计划:专区
|
||||
zone_plans = []
|
||||
for z in src_zones:
|
||||
exist = ShangpinZhuanqu.query.filter(
|
||||
club_id=to_club,
|
||||
leixing_id=leixing.id,
|
||||
mingzi=z.mingzi,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist:
|
||||
zone_plans.append({
|
||||
'source': z,
|
||||
'action': 'SKIP',
|
||||
'target_id': exist.id,
|
||||
'new_tupian_url': exist.tupian_url or '',
|
||||
})
|
||||
else:
|
||||
zone_plans.append({
|
||||
'source': z,
|
||||
'action': 'CREATE',
|
||||
'target_id': None,
|
||||
'new_tupian_url': None,
|
||||
})
|
||||
|
||||
zone_by_src_id = {p['source'].id: p for p in zone_plans}
|
||||
|
||||
product_plans = []
|
||||
for p in src_products:
|
||||
zp = zone_by_src_id.get(p.zhuanqu_id)
|
||||
if not zp:
|
||||
product_plans.append({
|
||||
'source': p,
|
||||
'action': 'SKIP_NO_ZONE',
|
||||
'reason': f'zhuanqu_id={p.zhuanqu_id} 不在本批三角洲专区',
|
||||
})
|
||||
continue
|
||||
planned_zq_id = zp.get('target_id')
|
||||
if planned_zq_id:
|
||||
exist_p = Shangpin.query.filter(
|
||||
club_id=to_club,
|
||||
zhuanqu_id=planned_zq_id,
|
||||
biaoqian=p.biaoqian,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_p:
|
||||
product_plans.append({
|
||||
'source': p,
|
||||
'action': 'SKIP',
|
||||
'zone_plan': zp,
|
||||
'target_zhuanqu_id': planned_zq_id,
|
||||
})
|
||||
continue
|
||||
product_plans.append({
|
||||
'source': p,
|
||||
'action': 'CREATE',
|
||||
'zone_plan': zp,
|
||||
'target_zhuanqu_id': planned_zq_id,
|
||||
})
|
||||
|
||||
# —— 打印确认清单 ——
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} =====\n'
|
||||
f'源: {from_club} ({src_club.name}) → 目标: {to_club} ({dst_club.name})\n'
|
||||
f'类型: jieshao={leixing.jieshao!r} id={leixing.id}(全局复用,不新建)\n'
|
||||
f'ClubShangpinLeixingConfig: {cfg_action}\n'
|
||||
f'源专区={len(src_zones)} 源上架商品={len(src_products)}\n'
|
||||
))
|
||||
|
||||
self.stdout.write('—— 专区计划 ——')
|
||||
for i, zp in enumerate(zone_plans, 1):
|
||||
z = zp['source']
|
||||
self.stdout.write(
|
||||
f' [{i}] {zp["action"]} src_id={z.id} mingzi={z.mingzi!r} '
|
||||
f'paixu={z.paixu} tupian={z.tupian_url or "-"}'
|
||||
+ (f' → existing_id={zp["target_id"]}' if zp['target_id'] else '')
|
||||
)
|
||||
|
||||
self.stdout.write('—— 商品计划 ——')
|
||||
for i, pp in enumerate(product_plans, 1):
|
||||
p = pp['source']
|
||||
zname = ''
|
||||
zp = pp.get('zone_plan')
|
||||
if zp:
|
||||
zname = zp['source'].mingzi
|
||||
self.stdout.write(
|
||||
f' [{i}] {pp["action"]} src_id={p.id} {p.biaoqian!r} '
|
||||
f'¥{p.jiage} zone={zname!r} '
|
||||
f'tupian={p.tupian_url or "-"} guize={p.guize_tupian or "-"}'
|
||||
+ (f' reason={pp.get("reason")}' if pp.get('reason') else '')
|
||||
)
|
||||
|
||||
stats_preview = {
|
||||
'cfg': cfg_action,
|
||||
'zones_create': sum(1 for x in zone_plans if x['action'] == 'CREATE'),
|
||||
'zones_skip': sum(1 for x in zone_plans if x['action'] == 'SKIP'),
|
||||
'products_create': sum(1 for x in product_plans if x['action'] == 'CREATE'),
|
||||
'products_skip': sum(1 for x in product_plans if x['action'] == 'SKIP'),
|
||||
'products_skip_no_zone': sum(1 for x in product_plans if x['action'] == 'SKIP_NO_ZONE'),
|
||||
}
|
||||
self.stdout.write(self.style.NOTICE(f'\n统计预览: {stats_preview}'))
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划,未写库、未拷 OSS。\n'
|
||||
'确认无误后执行:\n'
|
||||
f' python manage.py copy_ufo_sanjiaozhou_catalog '
|
||||
f'--to-club={to_club} --apply --i-confirm-to-club={to_club}\n'
|
||||
))
|
||||
return
|
||||
|
||||
# —— APPLY:先 OSS,再 DB 事务 ——
|
||||
def dest_key(category: str, src_url: str) -> str:
|
||||
ext = 'png'
|
||||
raw = (src_url or '').split('?', 1)[0]
|
||||
if '.' in raw.rsplit('/', 1)[-1]:
|
||||
ext = raw.rsplit('.', 1)[-1].lower()[:8] or 'png'
|
||||
ts = int(time.time() * 1000)
|
||||
rand = uuid.uuid4().hex[:8]
|
||||
return f'club/{to_club}/catalog/{category}/{ts}_{rand}.{ext}'
|
||||
|
||||
def copy_img(src_url: str, category: str) -> str:
|
||||
if not src_url:
|
||||
return ''
|
||||
# 已是相对路径或 URL:物理复制到新 key
|
||||
key = dest_key(category, src_url)
|
||||
if not copy_in_oss(src_url, key):
|
||||
raise CommandError(f'OSS 复制失败,已中止(未写库): {src_url} → {key}')
|
||||
return key
|
||||
|
||||
# 预复制专区/商品图(仅 CREATE)
|
||||
oss_ok = 0
|
||||
for zp in zone_plans:
|
||||
if zp['action'] != 'CREATE':
|
||||
continue
|
||||
z = zp['source']
|
||||
if z.tupian_url:
|
||||
zp['new_tupian_url'] = copy_img(z.tupian_url, 'zhuanqu')
|
||||
oss_ok += 1
|
||||
else:
|
||||
zp['new_tupian_url'] = ''
|
||||
|
||||
for pp in product_plans:
|
||||
if pp['action'] != 'CREATE':
|
||||
continue
|
||||
p = pp['source']
|
||||
pp['new_tupian_url'] = copy_img(p.tupian_url, 'shangpin') if p.tupian_url else ''
|
||||
if p.tupian_url:
|
||||
oss_ok += 1
|
||||
if p.guize_tupian:
|
||||
pp['new_guize_tupian'] = copy_img(p.guize_tupian, 'guize')
|
||||
oss_ok += 1
|
||||
else:
|
||||
pp['new_guize_tupian'] = ''
|
||||
|
||||
stats = {
|
||||
'cfg_created': 0,
|
||||
'cfg_enabled': 0,
|
||||
'zones_created': 0,
|
||||
'zones_skipped': 0,
|
||||
'products_created': 0,
|
||||
'products_skipped': 0,
|
||||
'products_skipped_no_zone': 0,
|
||||
'oss_copied': oss_ok,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
|
||||
club_id=to_club,
|
||||
leixing_id=leixing.id,
|
||||
defaults={
|
||||
'is_enabled': True,
|
||||
'paixu': int(leixing.paixu or 0),
|
||||
'tupian_url': '',
|
||||
},
|
||||
)
|
||||
if created:
|
||||
stats['cfg_created'] = 1
|
||||
elif not cfg.is_enabled:
|
||||
cfg.is_enabled = True
|
||||
cfg.save(update_fields=['is_enabled', 'UpdateTime'])
|
||||
stats['cfg_enabled'] = 1
|
||||
|
||||
# 专区 insert + 映射
|
||||
zone_id_map = {} # src_id -> dst_id
|
||||
for zp in zone_plans:
|
||||
z = zp['source']
|
||||
if zp['action'] == 'SKIP':
|
||||
zone_id_map[z.id] = zp['target_id']
|
||||
stats['zones_skipped'] += 1
|
||||
continue
|
||||
# 事务内再查一次,防并发重复
|
||||
exist = ShangpinZhuanqu.query.filter(
|
||||
club_id=to_club,
|
||||
leixing_id=leixing.id,
|
||||
mingzi=z.mingzi,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist:
|
||||
zone_id_map[z.id] = exist.id
|
||||
stats['zones_skipped'] += 1
|
||||
continue
|
||||
row = ShangpinZhuanqu.query.create(
|
||||
mingzi=z.mingzi,
|
||||
leixing_id=leixing.id,
|
||||
club_id=to_club,
|
||||
tupian_url=zp.get('new_tupian_url') or '',
|
||||
shenhezhuangtai=1,
|
||||
paixu=int(z.paixu or 0),
|
||||
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
zone_id_map[z.id] = row.id
|
||||
zp['target_id'] = row.id
|
||||
stats['zones_created'] += 1
|
||||
|
||||
for pp in product_plans:
|
||||
p = pp['source']
|
||||
if pp['action'] == 'SKIP_NO_ZONE':
|
||||
stats['products_skipped_no_zone'] += 1
|
||||
continue
|
||||
if pp['action'] == 'SKIP':
|
||||
stats['products_skipped'] += 1
|
||||
continue
|
||||
|
||||
dst_zq = zone_id_map.get(p.zhuanqu_id)
|
||||
if not dst_zq:
|
||||
raise CommandError(
|
||||
f'商品「{p.biaoqian}」src_id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
|
||||
)
|
||||
|
||||
exist_p = Shangpin.query.filter(
|
||||
club_id=to_club,
|
||||
zhuanqu_id=dst_zq,
|
||||
biaoqian=p.biaoqian,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_p:
|
||||
stats['products_skipped'] += 1
|
||||
continue
|
||||
|
||||
Shangpin.query.create(
|
||||
biaoqian=p.biaoqian,
|
||||
jiage=p.jiage,
|
||||
kucun=p.kucun,
|
||||
leixing_id=leixing.id,
|
||||
zhuanqu_id=dst_zq,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=p.jieshao,
|
||||
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||
guize_tupian=pp.get('new_guize_tupian') or '',
|
||||
yaoqiuleixing=p.yaoqiuleixing,
|
||||
huiyuan_id=p.huiyuan_id,
|
||||
yongjin=p.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||
tupian_url=pp.get('new_tupian_url') or '',
|
||||
paixu=int(p.paixu or 0),
|
||||
shenhezhuangtai=1,
|
||||
shangjia_zhuangtai=True,
|
||||
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', False)),
|
||||
club_id=to_club,
|
||||
shi_zhuanpan=bool(getattr(p, 'shi_zhuanpan', False)),
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\n写入完成: {stats}'))
|
||||
self.stdout.write(
|
||||
'说明:未复制 Zhuanpan*;全局 ShangpinLeixing 未改;源 UFO 数据未动。'
|
||||
)
|
||||
|
||||
def _list_clubs(self, Club, only_hint=False):
|
||||
qs = Club.query.order_by('club_id')
|
||||
for c in qs:
|
||||
name = c.name or ''
|
||||
if only_hint and NAME_HINT not in name:
|
||||
continue
|
||||
mark = ' <<-- 龙先生候选' if NAME_HINT in name else ''
|
||||
self.stdout.write(
|
||||
f' club_id={c.club_id!r} name={name!r} status={c.status}{mark}'
|
||||
)
|
||||
if only_hint:
|
||||
hits = [c for c in Club.query.order_by('club_id') if NAME_HINT in (c.name or '')]
|
||||
if not hits:
|
||||
self.stdout.write(self.style.WARNING(' (无名称含「龙先生」的俱乐部)'))
|
||||
|
||||
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
|
||||
if leixing_id:
|
||||
row = ShangpinLeixing.query.filter(id=leixing_id).first()
|
||||
if not row:
|
||||
raise CommandError(f'找不到 ShangpinLeixing id={leixing_id}')
|
||||
if (row.jieshao or '') != leixing_name:
|
||||
raise CommandError(
|
||||
f'安全拦截:--leixing-id={leixing_id} 的名称是「{row.jieshao}」,'
|
||||
f'与要求的精确名「{leixing_name}」不一致。'
|
||||
)
|
||||
return row
|
||||
|
||||
matches = list(ShangpinLeixing.query.filter(jieshao=leixing_name))
|
||||
if not matches:
|
||||
# 列出相近提示
|
||||
self.stdout.write('全局商品类型清单:')
|
||||
for lx in ShangpinLeixing.query.order_by('id'):
|
||||
self.stdout.write(f' id={lx.id} name={lx.jieshao!r}')
|
||||
raise CommandError(
|
||||
f'未找到全局类型全名精确匹配「{leixing_name}」。'
|
||||
f'请改后台类型名或 --leixing-id=... 指定。'
|
||||
)
|
||||
if len(matches) > 1:
|
||||
ids = [m.id for m in matches]
|
||||
raise CommandError(
|
||||
f'全局类型「{leixing_name}」匹配到多条 id={ids},拒绝猜测。'
|
||||
f'请用 --leixing-id=... 指定唯一一条。'
|
||||
)
|
||||
return matches[0]
|
||||
427
jituan/management/commands/delete_club_by_mch_id.py
Normal file
427
jituan/management/commands/delete_club_by_mch_id.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""仅清理「龙先生电竞」(club_id=lsx) 的配置;绝对不碰其他俱乐部。
|
||||
|
||||
安全规则(硬约束):
|
||||
1. 只允许操作 club_id=lsx(或显式传入且必须等于 lsx)
|
||||
2. 若商户号命中了非 lsx 俱乐部:只打印警告,绝不删除
|
||||
3. 默认 dry-run / --precheck:只列出将删内容与条数,不写库
|
||||
4. --apply 必须再带 --i-understand-lsx-only 才真正删除
|
||||
|
||||
用法:
|
||||
# 预检(推荐先跑这个,把输出发给你确认)
|
||||
python manage.py delete_club_by_mch_id 17480975501 --precheck
|
||||
|
||||
# 确认无误后再删(双确认)
|
||||
python manage.py delete_club_by_mch_id 17480975501 --apply --i-understand-lsx-only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import connection, transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from jituan.models import (
|
||||
AdminAssignment,
|
||||
Club,
|
||||
ClubDashouExamConfig,
|
||||
ClubDashouExamPass,
|
||||
ClubDashouExamQuestion,
|
||||
ClubDashouExamQuestionImage,
|
||||
ClubFadanFenhongLilv,
|
||||
ClubHuiyuanBundleInclude,
|
||||
ClubHuiyuanPrice,
|
||||
ClubKaoheChenghaoConfig,
|
||||
ClubLilubiao,
|
||||
ClubPaymentChannel,
|
||||
ClubShangpinLeixingConfig,
|
||||
ClubTixianQuota,
|
||||
ClubWithdrawConfig,
|
||||
IdentityTag,
|
||||
IdentityTagBind,
|
||||
PayChannelLedger,
|
||||
UserWxOpenid,
|
||||
)
|
||||
from jituan.services.club_cert_upload import club_wx_cert_root
|
||||
|
||||
try:
|
||||
from jituan.models import AdminAuditLog
|
||||
except Exception:
|
||||
AdminAuditLog = None
|
||||
|
||||
try:
|
||||
from config.models import (
|
||||
ClubMiniappIcon,
|
||||
ClubPindao,
|
||||
ClubPindaoImage,
|
||||
Gonggao,
|
||||
Lunbo,
|
||||
MiniappScriptAutoReply,
|
||||
MiniappScriptScene,
|
||||
PopupPage,
|
||||
Szjilu,
|
||||
Tupianpeizhi,
|
||||
)
|
||||
except Exception:
|
||||
Lunbo = Gonggao = Tupianpeizhi = PopupPage = Szjilu = None
|
||||
ClubMiniappIcon = ClubPindao = ClubPindaoImage = None
|
||||
MiniappScriptScene = MiniappScriptAutoReply = None
|
||||
|
||||
# 本命令唯一允许删除的俱乐部
|
||||
ALLOWED_CLUB_ID = 'lsx'
|
||||
ALLOWED_CLUB_NAME_HINT = '龙先生'
|
||||
|
||||
# ORM 配置模型(均按 club_id 过滤)
|
||||
ORM_MODELS = [
|
||||
('支付通道 ClubPaymentChannel', ClubPaymentChannel),
|
||||
('会员价 ClubHuiyuanPrice', ClubHuiyuanPrice),
|
||||
('会员套餐 ClubHuiyuanBundleInclude', ClubHuiyuanBundleInclude),
|
||||
('利率 ClubLilubiao', ClubLilubiao),
|
||||
('提现配置 ClubWithdrawConfig', ClubWithdrawConfig),
|
||||
('提现配额 ClubTixianQuota', ClubTixianQuota),
|
||||
('商品类型上架 ClubShangpinLeixingConfig', ClubShangpinLeixingConfig),
|
||||
('考核称号 ClubKaoheChenghaoConfig', ClubKaoheChenghaoConfig),
|
||||
('发单分红利率 ClubFadanFenhongLilv', ClubFadanFenhongLilv),
|
||||
('接单考试配置 ClubDashouExamConfig', ClubDashouExamConfig),
|
||||
('接单考试题图 ClubDashouExamQuestionImage', ClubDashouExamQuestionImage),
|
||||
('接单考试题 ClubDashouExamQuestion', ClubDashouExamQuestion),
|
||||
('接单考试通过 ClubDashouExamPass', ClubDashouExamPass),
|
||||
('身份标签绑定 IdentityTagBind', IdentityTagBind),
|
||||
('身份标签 IdentityTag', IdentityTag),
|
||||
('支付通道流水 PayChannelLedger', PayChannelLedger),
|
||||
('OpenID绑定 UserWxOpenid', UserWxOpenid),
|
||||
('客服任职 AdminAssignment', AdminAssignment),
|
||||
('审计日志 AdminAuditLog', AdminAuditLog),
|
||||
('轮播 Lunbo', Lunbo),
|
||||
('公告 Gonggao', Gonggao),
|
||||
('图片配置 Tupianpeizhi', Tupianpeizhi),
|
||||
('弹窗页 PopupPage', PopupPage),
|
||||
('收支记录配置 Szjilu', Szjilu),
|
||||
('小程序图标 ClubMiniappIcon', ClubMiniappIcon),
|
||||
('频道图 ClubPindaoImage', ClubPindaoImage),
|
||||
('频道 ClubPindao', ClubPindao),
|
||||
('话术场景 MiniappScriptScene', MiniappScriptScene),
|
||||
('话术自动回复 MiniappScriptAutoReply', MiniappScriptAutoReply),
|
||||
]
|
||||
|
||||
# SQL 兜底表(仅 DELETE WHERE club_id=%s,绝不会无 club_id 条件)
|
||||
SQL_TABLES = [
|
||||
'club_payment_channel',
|
||||
'club_huiyuan_price',
|
||||
'club_huiyuan_bundle_include',
|
||||
'club_lilubiao',
|
||||
'club_withdraw_config',
|
||||
'club_tixian_quota',
|
||||
'club_shangpin_leixing_config',
|
||||
'club_kaohe_chenghao_config',
|
||||
'club_fadan_fenhong_lilv',
|
||||
'club_dashou_exam_config',
|
||||
'club_dashou_exam_question',
|
||||
'club_dashou_exam_question_image',
|
||||
'club_dashou_exam_pass',
|
||||
'identity_tag_bind',
|
||||
'identity_tag',
|
||||
'pay_channel_ledger',
|
||||
'user_wx_openid',
|
||||
'admin_assignment',
|
||||
'admin_audit_log',
|
||||
'lunbo',
|
||||
'gonggao',
|
||||
'tupianpeizhi',
|
||||
'popup_page',
|
||||
'szjilu',
|
||||
'club_miniapp_icon',
|
||||
'club_pindao',
|
||||
'club_pindao_image',
|
||||
'miniapp_script_scene',
|
||||
'miniapp_script_auto_reply',
|
||||
'daily_income_stat',
|
||||
'daily_payout_stat',
|
||||
'platform_income_log',
|
||||
]
|
||||
|
||||
# 明确不删(业务数据)
|
||||
NEVER_DELETE_NOTE = [
|
||||
'User 用户主表 / 各身份扩展表(打手商家等)',
|
||||
'订单 Order / 退款 / 处罚等业务流水',
|
||||
'商品 Shangpin(若 club_id=lsx 有商品也不会在本命令删;如需另开命令)',
|
||||
'其他俱乐部 xq / xzj / ufo 等任何数据',
|
||||
]
|
||||
|
||||
|
||||
def _count_model(model, club_id):
|
||||
if model is None:
|
||||
return None
|
||||
try:
|
||||
return model.query.filter(club_id=club_id).count()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _assert_lsx_only(club_id: str):
|
||||
cid = (club_id or '').strip()
|
||||
if cid != ALLOWED_CLUB_ID:
|
||||
raise CommandError(
|
||||
f'安全拦截:本命令只允许操作 club_id={ALLOWED_CLUB_ID}(龙先生电竞),'
|
||||
f'拒绝 club_id={cid!r}'
|
||||
)
|
||||
|
||||
|
||||
def _club_snapshot(club: Club):
|
||||
return {
|
||||
'club_id': club.club_id,
|
||||
'name': club.name,
|
||||
'status': club.status,
|
||||
'wx_appid': club.wx_appid or '',
|
||||
'official_appid': club.official_appid or '',
|
||||
'official_secret_set': bool((club.official_secret or '').strip()),
|
||||
'official_token_set': bool((club.official_token or '').strip()),
|
||||
'encoding_aes_key_set': bool((club.encoding_aes_key or '').strip()),
|
||||
'template_id': club.template_id or '',
|
||||
'mch_id': club.mch_id or '',
|
||||
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
|
||||
'pay_app_id': club.pay_app_id or '',
|
||||
'pay_key_path': club.pay_key_path or '',
|
||||
'pay_cert_path': club.pay_cert_path or '',
|
||||
'private_key_path': club.private_key_path or '',
|
||||
'platform_cert_dir': club.platform_cert_dir or '',
|
||||
'goeasy_appkey_set': bool((club.goeasy_appkey or '').strip()),
|
||||
'h5_domain': club.h5_domain or '',
|
||||
'oss_prefix': club.oss_prefix or '',
|
||||
'mini_pay_channel': getattr(club, 'mini_pay_channel', '') or '',
|
||||
}
|
||||
|
||||
|
||||
def _find_other_clubs_with_mch(mch_id: str, exclude_club_id: str):
|
||||
"""商户号撞到其他俱乐部时列出,绝不删除它们。"""
|
||||
others = list(
|
||||
Club.query.filter(
|
||||
Q(mch_id=mch_id) | Q(withdraw_mch_id=mch_id)
|
||||
).exclude(club_id=exclude_club_id)
|
||||
)
|
||||
ch_club_ids = (
|
||||
ClubPaymentChannel.query.filter(mch_id=mch_id)
|
||||
.exclude(club_id=exclude_club_id)
|
||||
.values_list('club_id', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
for cid in ch_club_ids:
|
||||
if not any(c.club_id == cid for c in others):
|
||||
c = Club.query.filter(club_id=cid).first()
|
||||
if c:
|
||||
others.append(c)
|
||||
return others
|
||||
|
||||
|
||||
def _precheck_report(stdout, style, club: Club, mch_id: str):
|
||||
snap = _club_snapshot(club)
|
||||
stdout.write(style.SUCCESS('=' * 64))
|
||||
stdout.write(style.SUCCESS('【预检】仅针对龙先生电竞 club_id=lsx'))
|
||||
stdout.write(style.SUCCESS('=' * 64))
|
||||
stdout.write(f'请求商户号: {mch_id or "(未指定,仅按 lsx 清理)"}')
|
||||
stdout.write('')
|
||||
stdout.write('—— 俱乐部主表字段 ——')
|
||||
for k, v in snap.items():
|
||||
stdout.write(f' {k}: {v if v != "" else "(空)"}')
|
||||
|
||||
if mch_id:
|
||||
club_mch = (club.mch_id or '').strip()
|
||||
club_wm = (getattr(club, 'withdraw_mch_id', '') or '').strip()
|
||||
ch_hit = ClubPaymentChannel.query.filter(club_id=ALLOWED_CLUB_ID, mch_id=mch_id).count()
|
||||
matched = club_mch == mch_id or club_wm == mch_id or ch_hit > 0
|
||||
if not matched:
|
||||
stdout.write(style.WARNING(
|
||||
f'\n注意:lsx 当前 mch_id={club_mch or "(空)"} / withdraw={club_wm or "(空)"} / '
|
||||
f'通道命中={ch_hit},与请求商户号 {mch_id} 不完全一致。'
|
||||
f'本命令仍只清理 lsx,不会动其他俱乐部。'
|
||||
))
|
||||
else:
|
||||
stdout.write(style.SUCCESS(f'\n商户号匹配确认:lsx 绑定了 {mch_id}'))
|
||||
|
||||
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID)
|
||||
if others:
|
||||
stdout.write(style.ERROR(
|
||||
f'\n!! 其他俱乐部也配置了商户号 {mch_id}(只警告,绝对不删):'
|
||||
))
|
||||
for o in others:
|
||||
stdout.write(
|
||||
f' - KEEP {o.club_id} ({o.name}) mch={o.mch_id} withdraw={getattr(o, "withdraw_mch_id", "")}'
|
||||
)
|
||||
else:
|
||||
stdout.write('其他俱乐部未发现同商户号(安全)。')
|
||||
|
||||
stdout.write('\n—— 将删除的配置表(仅 club_id=lsx)——')
|
||||
total = 0
|
||||
for label, model in ORM_MODELS:
|
||||
n = _count_model(model, ALLOWED_CLUB_ID)
|
||||
if n is None:
|
||||
stdout.write(f' [跳过不可用] {label}')
|
||||
continue
|
||||
mark = '将删' if n else '空'
|
||||
stdout.write(f' [{mark}] {label}: {n} 条')
|
||||
total += n or 0
|
||||
|
||||
stdout.write('\n—— SQL 兜底表计数(仅 WHERE club_id=lsx)——')
|
||||
with connection.cursor() as cursor:
|
||||
for table in SQL_TABLES:
|
||||
try:
|
||||
cursor.execute(
|
||||
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
|
||||
[ALLOWED_CLUB_ID],
|
||||
)
|
||||
n = cursor.fetchone()[0]
|
||||
stdout.write(f' [{("将删" if n else "空")}] {table}: {n}')
|
||||
total += n
|
||||
except Exception as exc:
|
||||
stdout.write(f' [无此表/跳过] {table}: {exc}')
|
||||
|
||||
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
|
||||
stdout.write('\n—— 证书目录 ——')
|
||||
if os.path.isdir(cert_dir):
|
||||
file_n = 0
|
||||
for _root, _dirs, files in os.walk(cert_dir):
|
||||
file_n += len(files)
|
||||
stdout.write(f' [将删目录] {cert_dir} (约 {file_n} 个文件)')
|
||||
else:
|
||||
stdout.write(f' [无目录] {cert_dir}')
|
||||
|
||||
stdout.write('\n—— 明确不删 ——')
|
||||
for tip in NEVER_DELETE_NOTE:
|
||||
stdout.write(f' · {tip}')
|
||||
|
||||
stdout.write('')
|
||||
stdout.write(style.SUCCESS(f'预检合计(配置行粗略合计,含重复计数可能):约 {total}'))
|
||||
stdout.write(style.WARNING(
|
||||
'确认无误后再执行:\n'
|
||||
f' python manage.py delete_club_by_mch_id {mch_id or "17480975501"} '
|
||||
'--apply --i-understand-lsx-only'
|
||||
))
|
||||
stdout.write(style.SUCCESS('=' * 64))
|
||||
|
||||
|
||||
def _delete_lsx(stdout, style, remove_cert_dir: bool):
|
||||
_assert_lsx_only(ALLOWED_CLUB_ID)
|
||||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||
if not club:
|
||||
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除')
|
||||
|
||||
# 名称再确认一层(非强制阻断,只警告)
|
||||
if ALLOWED_CLUB_NAME_HINT not in (club.name or ''):
|
||||
stdout.write(style.WARNING(
|
||||
f'警告:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{ALLOWED_CLUB_NAME_HINT}」,'
|
||||
f'但仍按 club_id=lsx 删除(主键约束)。'
|
||||
))
|
||||
|
||||
with transaction.atomic():
|
||||
for label, model in ORM_MODELS:
|
||||
if model is None:
|
||||
continue
|
||||
n = model.query.filter(club_id=ALLOWED_CLUB_ID).count()
|
||||
if n:
|
||||
model.query.filter(club_id=ALLOWED_CLUB_ID).delete()
|
||||
stdout.write(f' - 已删 {label}: {n}')
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
for table in SQL_TABLES:
|
||||
try:
|
||||
cursor.execute(
|
||||
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
|
||||
[ALLOWED_CLUB_ID],
|
||||
)
|
||||
n = cursor.fetchone()[0]
|
||||
if n:
|
||||
cursor.execute(
|
||||
f'DELETE FROM `{table}` WHERE club_id=%s',
|
||||
[ALLOWED_CLUB_ID],
|
||||
)
|
||||
stdout.write(f' - SQL 已删 {table}: {n}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 最后删主表(再次校验主键)
|
||||
deleted, _ = Club.query.filter(club_id=ALLOWED_CLUB_ID).delete()
|
||||
stdout.write(style.SUCCESS(f' - 已删 Club 主表 lsx (affected={deleted})'))
|
||||
|
||||
if remove_cert_dir:
|
||||
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
|
||||
if os.path.isdir(cert_dir):
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
stdout.write(style.SUCCESS(f' - 已删证书目录 {cert_dir}'))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '【仅 lsx/龙先生】按商户号预检/删除俱乐部配置;绝不碰其他俱乐部'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'mch_id',
|
||||
nargs='?',
|
||||
default='17480975501',
|
||||
help='用于核对的商户号(默认 17480975501);删除范围仍固定为 lsx',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--precheck',
|
||||
action='store_true',
|
||||
help='只预检列出将删内容(默认行为也是预检)',
|
||||
)
|
||||
parser.add_argument('--apply', action='store_true', help='真正删除(需配合确认开关)')
|
||||
parser.add_argument(
|
||||
'--i-understand-lsx-only',
|
||||
action='store_true',
|
||||
help='确认仅删除龙先生 lsx,不涉及其他俱乐部',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--keep-cert-dir',
|
||||
action='store_true',
|
||||
help='保留磁盘 wx_certs/lsx 目录',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--club-id',
|
||||
type=str,
|
||||
default=ALLOWED_CLUB_ID,
|
||||
help='必须为 lsx;传其他值会直接拒绝',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
club_id = (options.get('club_id') or ALLOWED_CLUB_ID).strip()
|
||||
_assert_lsx_only(club_id)
|
||||
|
||||
mch_id = str(options.get('mch_id') or '').strip()
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = bool(options.get('i_understand_lsx_only'))
|
||||
# 默认/显式 precheck 都走预检;只有 apply+确认才删除
|
||||
do_delete = apply and confirm
|
||||
|
||||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||
if not club:
|
||||
raise CommandError(
|
||||
f'未找到 club_id={ALLOWED_CLUB_ID}。不会删除任何其他俱乐部。'
|
||||
)
|
||||
|
||||
_precheck_report(self.stdout, self.style, club, mch_id)
|
||||
|
||||
if not do_delete:
|
||||
if apply and not confirm:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'\n已拒绝删除:缺少 --i-understand-lsx-only(防止误删)。'
|
||||
))
|
||||
self.stdout.write(self.style.WARNING('\n当前仅预检,数据库未改动。'))
|
||||
return
|
||||
|
||||
# 删除前再拦一道:其他俱乐部同商户号只警告
|
||||
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID) if mch_id else []
|
||||
if others:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'其他俱乐部存在同商户号配置,本命令仍只删 lsx,下列俱乐部保持不动:'
|
||||
))
|
||||
for o in others:
|
||||
self.stdout.write(f' KEEP {o.club_id}')
|
||||
|
||||
self.stdout.write(self.style.WARNING('\n开始删除(仅 lsx)...'))
|
||||
_delete_lsx(
|
||||
self.stdout,
|
||||
self.style,
|
||||
remove_cert_dir=not bool(options.get('keep_cert_dir')),
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS('\n完成:仅龙先生 lsx 配置已清理。'))
|
||||
178
jituan/management/commands/delete_ufo_public_catalog.py
Normal file
178
jituan/management/commands/delete_ufo_public_catalog.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
删除 UFO 俱乐部「正常/公共」商品目录(专区 + 商品)及对应 OSS 图片。
|
||||
|
||||
范围(必须全部满足,缺一不删):
|
||||
- club_id 固定为 ufo(不可改目标)
|
||||
- dianpu IS NULL(公共目录;店铺商品一律不碰)
|
||||
- 表:shangpin、shangpin_zhuanqu
|
||||
- OSS:商品 tupian_url / guize_tupian、专区 tupian_url
|
||||
|
||||
不碰:
|
||||
- 其它俱乐部(xq / lxs / xzj 等)
|
||||
- 店铺商品(dianpu 非空)
|
||||
- 全局 ShangpinLeixing
|
||||
- ClubShangpinLeixingConfig、订单、轮播、转盘、统计等
|
||||
|
||||
用法:
|
||||
|
||||
python manage.py delete_ufo_public_catalog
|
||||
python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
ALLOWED_CLUB_ID = 'ufo'
|
||||
NAME_HINT = 'UFO' # Club.name 须含此字样(大小写不敏感)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '删除 UFO 公共商品+专区及 OSS 图(默认 dry-run;仅 ufo)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--apply',
|
||||
action='store_true',
|
||||
help='真正删库 + 删 OSS(默认仅打印清单)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--i-confirm-club',
|
||||
type=str,
|
||||
default='',
|
||||
help='必须等于 ufo,才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--club-id',
|
||||
type=str,
|
||||
default=ALLOWED_CLUB_ID,
|
||||
help='仅允许 ufo;传其它值会直接拒绝',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club
|
||||
from products.models import Shangpin, ShangpinZhuanqu
|
||||
from utils.oss_utils import delete_from_oss
|
||||
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm_club') or '').strip()
|
||||
|
||||
if club_id != ALLOWED_CLUB_ID:
|
||||
raise CommandError(
|
||||
f'安全拦截:本命令只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
|
||||
)
|
||||
|
||||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||
if not club:
|
||||
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除')
|
||||
if NAME_HINT.lower() not in (club.name or '').lower():
|
||||
raise CommandError(
|
||||
f'安全拦截:club_id={ALLOWED_CLUB_ID!r} 的 name={club.name!r} '
|
||||
f'不含「{NAME_HINT}」,拒绝执行。'
|
||||
)
|
||||
|
||||
if apply and confirm != ALLOWED_CLUB_ID:
|
||||
raise CommandError(
|
||||
'安全拦截:--apply 必须同时传 --i-confirm-club=ufo\n'
|
||||
' 例:python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo'
|
||||
)
|
||||
|
||||
zones = list(
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
dianpu__isnull=True,
|
||||
).order_by('id')
|
||||
)
|
||||
products = list(
|
||||
Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
dianpu__isnull=True,
|
||||
).order_by('id')
|
||||
)
|
||||
|
||||
oss_urls = []
|
||||
for z in zones:
|
||||
if z.tupian_url:
|
||||
oss_urls.append(('zone', z.id, z.tupian_url))
|
||||
for p in products:
|
||||
if p.tupian_url:
|
||||
oss_urls.append(('product', p.id, p.tupian_url))
|
||||
if p.guize_tupian:
|
||||
oss_urls.append(('product_guize', p.id, p.guize_tupian))
|
||||
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} 删除 UFO 公共商品目录 =====\n'
|
||||
f'club_id={ALLOWED_CLUB_ID} name={club.name!r}\n'
|
||||
f'公共专区={len(zones)} 公共商品={len(products)} '
|
||||
f'待删 OSS 路径={len(oss_urls)}\n'
|
||||
))
|
||||
|
||||
self.stdout.write('—— 专区 ——')
|
||||
for z in zones[:50]:
|
||||
self.stdout.write(
|
||||
f' id={z.id} mingzi={z.mingzi!r} leixing_id={z.leixing_id} '
|
||||
f'tupian={z.tupian_url or "-"}'
|
||||
)
|
||||
if len(zones) > 50:
|
||||
self.stdout.write(f' ... 其余 {len(zones) - 50} 个专区省略')
|
||||
|
||||
self.stdout.write('—— 商品 ——')
|
||||
for p in products[:80]:
|
||||
self.stdout.write(
|
||||
f' id={p.id} {p.biaoqian!r} ¥{p.jiage} '
|
||||
f'zhuanqu_id={p.zhuanqu_id} tupian={p.tupian_url or "-"}'
|
||||
)
|
||||
if len(products) > 80:
|
||||
self.stdout.write(f' ... 其余 {len(products) - 80} 个商品省略')
|
||||
|
||||
self.stdout.write(f'—— OSS 将删 {len(oss_urls)} 个对象(相对路径)——')
|
||||
for kind, oid, url in oss_urls[:30]:
|
||||
self.stdout.write(f' [{kind}#{oid}] {url}')
|
||||
if len(oss_urls) > 30:
|
||||
self.stdout.write(f' ... 其余 {len(oss_urls) - 30} 个省略')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划,未删库、未删 OSS。\n'
|
||||
'确认无误后:\n'
|
||||
' python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo\n'
|
||||
))
|
||||
return
|
||||
|
||||
if not zones and not products:
|
||||
self.stdout.write(self.style.SUCCESS('无公共专区/商品,无需删除。'))
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# 再按条件删一遍,避免误伤其它 club / 店铺商品
|
||||
n_prod, _ = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
|
||||
oss_ok = 0
|
||||
oss_fail = 0
|
||||
seen = set()
|
||||
for _, _, url in oss_urls:
|
||||
key = (url or '').strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if delete_from_oss(key):
|
||||
oss_ok += 1
|
||||
else:
|
||||
oss_fail += 1
|
||||
self.stderr.write(f'OSS 删除失败(已记日志,继续): {key}')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n删除完成: products={n_prod} zones={n_zone} '
|
||||
f'oss_ok={oss_ok} oss_fail={oss_fail}\n'
|
||||
f'未动:其它俱乐部、店铺商品、全局类型、订单/转盘等。'
|
||||
))
|
||||
127
jituan/management/commands/diagnose_lsx_invite.py
Normal file
127
jituan/management/commands/diagnose_lsx_invite.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""诊断 / 对齐龙先生邀请种子与小程序 club_id。
|
||||
|
||||
用法:
|
||||
python manage.py diagnose_lsx_invite
|
||||
python manage.py diagnose_lsx_invite --align-to lsx
|
||||
python manage.py diagnose_lsx_invite --align-to-appid
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from jituan.models import Club
|
||||
from users.business_models import User
|
||||
from users.models import UserGuanshi, UserZuzhang
|
||||
|
||||
LSX_APPID = 'wx7ff90e9d024fcdb8'
|
||||
SEED_UIDS = ('9910001', '9910002')
|
||||
FRONTEND_EXPECT = 'lsx'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '诊断龙先生种子邀请人 ClubID 与小程序是否一致,并可对齐'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--align-to',
|
||||
type=str,
|
||||
default='',
|
||||
help='把种子用户 ClubID 改成指定值(如 lsx)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--align-to-appid',
|
||||
action='store_true',
|
||||
help='按 wx_appid=龙先生 找到俱乐部,把种子 ClubID 对齐过去',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write('前端龙先生小程序默认 X-Club-Id / CLUB_ID = lsx')
|
||||
self.stdout.write(f'龙先生 AppID = {LSX_APPID}')
|
||||
self.stdout.write('')
|
||||
|
||||
clubs = list(Club.query.order_by('club_id'))
|
||||
self.stdout.write(f'库里俱乐部 {len(clubs)} 个:')
|
||||
target_by_app = None
|
||||
for c in clubs:
|
||||
mark = ''
|
||||
if c.club_id == FRONTEND_EXPECT:
|
||||
mark = ' ← 前端期望'
|
||||
if (c.wx_appid or '') == LSX_APPID:
|
||||
mark += ' ← AppID命中'
|
||||
target_by_app = c
|
||||
if '龙先生' in (c.name or ''):
|
||||
mark += ' ← 名称命中'
|
||||
self.stdout.write(
|
||||
f' club_id={c.club_id!r} name={c.name!r} wx_appid={c.wx_appid or "(空)"}{mark}'
|
||||
)
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write('种子用户:')
|
||||
for uid in SEED_UIDS:
|
||||
user = User.query.filter(UserUID=uid).first()
|
||||
if not user:
|
||||
self.stdout.write(self.style.WARNING(f' {uid}: 不存在(还没跑 seed_lsx_invite_data?)'))
|
||||
continue
|
||||
role = []
|
||||
try:
|
||||
z = user.ZuzhangProfile
|
||||
role.append(f'组长码={z.yaoqingma}')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
g = user.GuanshiProfile
|
||||
role.append(f'管事码={g.yaoqingma}')
|
||||
except Exception:
|
||||
pass
|
||||
club = (user.ClubID or '').strip()
|
||||
ok = club == FRONTEND_EXPECT
|
||||
line = f' {uid}: ClubID={club or "(空)"} {" | ".join(role) or "无身份"}'
|
||||
if ok:
|
||||
self.stdout.write(self.style.SUCCESS(line + ' ✓ 与前端 lsx 一致'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
line + f' ✗ 与前端 lsx 不一致 → 会报「邀请码不属于当前小程序俱乐部」'
|
||||
))
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'结论:这是后端校验(invite_guard),不是前端邀请码输错。\n'
|
||||
'小程序发 X-Club-Id=lsx,邀请人 User.ClubID 必须也是 lsx。'
|
||||
))
|
||||
|
||||
align_to = (options.get('align_to') or '').strip()
|
||||
if options.get('align_to_appid'):
|
||||
if not target_by_app:
|
||||
raise CommandError(f'没有 wx_appid={LSX_APPID} 的俱乐部,无法对齐')
|
||||
align_to = target_by_app.club_id
|
||||
self.stdout.write(f'将按 AppID 对齐到 club_id={align_to!r}')
|
||||
|
||||
if not align_to:
|
||||
self.stdout.write('')
|
||||
self.stdout.write('若要修复,任选其一:')
|
||||
self.stdout.write(' python manage.py diagnose_lsx_invite --align-to lsx')
|
||||
self.stdout.write(' python manage.py diagnose_lsx_invite --align-to-appid')
|
||||
self.stdout.write('然后前端 CLUB_ID 必须与种子 ClubID 相同。')
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
return
|
||||
|
||||
if not Club.query.filter(club_id=align_to).exists():
|
||||
raise CommandError(f'目标俱乐部 {align_to!r} 不存在,请先在后台创建')
|
||||
|
||||
with transaction.atomic():
|
||||
for uid in SEED_UIDS:
|
||||
user = User.query.filter(UserUID=uid).first()
|
||||
if not user:
|
||||
self.stdout.write(self.style.WARNING(f'跳过不存在用户 {uid}'))
|
||||
continue
|
||||
old = user.ClubID
|
||||
user.ClubID = align_to
|
||||
user.save(update_fields=['ClubID'])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'已更新 {uid}: ClubID {old!r} → {align_to!r}'
|
||||
))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n对齐完成。请确认小程序 config/club-config.js 的 CLUB_ID = {align_to!r}'
|
||||
))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
177
jituan/management/commands/import_ufo_banners.py
Normal file
177
jituan/management/commands/import_ufo_banners.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
仅导入 UFO 展示图到 Lunbo(不碰商品)。只写 club_id=ufo。
|
||||
|
||||
覆盖:
|
||||
- accept_order 首页主轮播
|
||||
- accept_order_banner2 首页第二横幅
|
||||
- boss_center 「我的」顶区背景(原站 bg_img)
|
||||
|
||||
默认 dry-run;--apply 才写库。只删/写 ufo 对应 page_key,不影响 xq/xzj。
|
||||
|
||||
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
|
||||
git pull origin main
|
||||
./venv/bin/python manage.py import_ufo_banners
|
||||
./venv/bin/python manage.py import_ufo_banners --apply
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
TARGET_CLUB = 'ufo'
|
||||
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
|
||||
|
||||
# 原站「我的」顶区背景(getUserCenterInfo.bg_img);catalog 里没有时用这个兜底
|
||||
BOSS_CENTER_FALLBACK = (
|
||||
'https://ufo.qn.17v.tech/uploads/20251001/Fls0cQjtfsV7IUXl4cwqO6BFvPyh.jpg'
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '导入 UFO 轮播 + 我的背景到 Lunbo(仅 club=ufo)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
parser.add_argument('--catalog', type=str, default='')
|
||||
parser.add_argument('--skip-oss', action='store_true')
|
||||
parser.add_argument('--club-id', type=str, default=TARGET_CLUB)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
if club_id != TARGET_CLUB:
|
||||
raise CommandError(f'只允许 club_id={TARGET_CLUB}')
|
||||
|
||||
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
|
||||
if not catalog_path.is_file():
|
||||
raise CommandError(f'找不到 {catalog_path}')
|
||||
|
||||
data = json.loads(catalog_path.read_text(encoding='utf-8'))
|
||||
banners = list(data.get('banners') or [])
|
||||
banners2 = list(data.get('banners2') or [])
|
||||
boss = list(data.get('boss_center') or [])
|
||||
if not boss:
|
||||
# catalog 内嵌或 misc
|
||||
misc = data.get('misc') or {}
|
||||
bg = misc.get('bg_img') or BOSS_CENTER_FALLBACK
|
||||
boss = [{'url': bg, 'kind': 'boss_center'}]
|
||||
|
||||
apply = bool(options['apply'])
|
||||
skip_oss = bool(options['skip_oss'])
|
||||
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'club={TARGET_CLUB} accept_order={len(banners)} '
|
||||
f'banner2={len(banners2)} boss_center={len(boss)} '
|
||||
f'mode={"APPLY" if apply else "DRY-RUN"}'
|
||||
))
|
||||
for b in banners:
|
||||
self.stdout.write(f' [主轮播] {b.get("url")}')
|
||||
for b in banners2:
|
||||
self.stdout.write(f' [第二横幅] {b.get("url")}')
|
||||
for b in boss:
|
||||
self.stdout.write(f' [我的背景] {b.get("url")}')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'dry-run。确认后:\n ./venv/bin/python manage.py import_ufo_banners --apply'
|
||||
))
|
||||
return
|
||||
|
||||
from config.models import Lunbo
|
||||
from jituan.models import Club
|
||||
from jituan.services.display_config import (
|
||||
LUNBO_PAGE_ACCEPT_BANNER2,
|
||||
LUNBO_PAGE_ACCEPT_ORDER,
|
||||
LUNBO_PAGE_BOSS_CENTER,
|
||||
lunbo_image_type_for_page,
|
||||
)
|
||||
|
||||
if not Club.query.filter(club_id=TARGET_CLUB).exists():
|
||||
raise CommandError('俱乐部 ufo 不存在')
|
||||
|
||||
stats = {'ok': 0, 'fail': 0}
|
||||
|
||||
def to_oss(url: str) -> str:
|
||||
if not url:
|
||||
return ''
|
||||
if skip_oss:
|
||||
return url
|
||||
if not str(url).startswith('http'):
|
||||
return url
|
||||
path = self._upload_remote(url)
|
||||
if not path:
|
||||
stats['fail'] += 1
|
||||
raise CommandError(f'OSS 上传失败: {url}')
|
||||
stats['ok'] += 1
|
||||
return path
|
||||
|
||||
def replace_page(page_key, items):
|
||||
image_type = lunbo_image_type_for_page(page_key)
|
||||
# 只删本俱乐部本 page_key
|
||||
deleted, _ = Lunbo.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
page_key=page_key,
|
||||
ImageType=image_type,
|
||||
).delete()
|
||||
self.stdout.write(f' clear {page_key} deleted={deleted}')
|
||||
n = 0
|
||||
for b in items:
|
||||
url = to_oss(b.get('url') or b.get('path') or '')
|
||||
if not url:
|
||||
continue
|
||||
Lunbo.query.create(
|
||||
club_id=TARGET_CLUB,
|
||||
page_key=page_key,
|
||||
ImageURL=url,
|
||||
ImageType=image_type,
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
with transaction.atomic():
|
||||
n1 = replace_page(LUNBO_PAGE_ACCEPT_ORDER, banners)
|
||||
n2 = replace_page(LUNBO_PAGE_ACCEPT_BANNER2, banners2)
|
||||
n3 = replace_page(LUNBO_PAGE_BOSS_CENTER, boss)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'完成仅 ufo:主轮播={n1} 第二横幅={n2} 我的背景={n3} '
|
||||
f'OSS成功={stats["ok"]} 失败={stats["fail"]}'
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _upload_remote(url: str) -> str:
|
||||
from utils.oss_utils import upload_to_oss
|
||||
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
req = Request(url, headers={'User-Agent': 'UFO-Import/1.0'})
|
||||
with urlopen(req, timeout=60, context=ctx) as resp:
|
||||
raw = resp.read()
|
||||
ctype = resp.headers.get('Content-Type') or ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
ext = mimetypes.guess_extension((ctype or '').split(';')[0].strip()) or ''
|
||||
if not ext or ext == '.jpe':
|
||||
m = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)(?:\?|$)', url, re.I)
|
||||
ext = ('.' + m.group(1).lower()) if m else '.jpg'
|
||||
if ext == '.jpeg':
|
||||
ext = '.jpg'
|
||||
|
||||
ts = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
rel = f"a_long/lunbo/{ts}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
bio = BytesIO(raw)
|
||||
bio.name = os.path.basename(rel)
|
||||
ok = upload_to_oss(bio, rel)
|
||||
return rel if ok else ''
|
||||
415
jituan/management/commands/import_ufo_catalog.py
Normal file
415
jituan/management/commands/import_ufo_catalog.py
Normal file
@@ -0,0 +1,415 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
将原 UFO 站点商品目录灌入星阙,且只写 club_id=ufo。
|
||||
|
||||
默认 dry-run(只打印计划,不写库)。真正写入必须加 --apply。
|
||||
默认按全局类型名「三角洲」**全名精确匹配**找 id(不含这三字的一律不要)。
|
||||
找到唯一一条后:
|
||||
1) ClubShangpinLeixingConfig 给 club=ufo 上架该类型
|
||||
2) ShangpinZhuanqu / Shangpin 的 leixing_id 挂这个全局类型 id,且 club_id=ufo
|
||||
|
||||
服务器上(复制整段):
|
||||
|
||||
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
|
||||
git pull origin main
|
||||
./venv/bin/python manage.py import_ufo_catalog
|
||||
./venv/bin/python manage.py import_ufo_catalog --apply
|
||||
./venv/bin/python manage.py import_ufo_catalog --apply --with-banners
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import uuid
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
TARGET_CLUB = 'ufo'
|
||||
DEFAULT_LEIXING_NAME = '三角洲' # 全名精确匹配,不模糊
|
||||
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '导入 UFO 商品/专区到 club_id=ufo(默认 dry-run;类型名默认精确匹配「三角洲」)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正写库(仅 ufo)')
|
||||
parser.add_argument('--catalog', type=str, default='', help='catalog.json 路径')
|
||||
parser.add_argument(
|
||||
'--list-leixing',
|
||||
action='store_true',
|
||||
help='只列出全局商品类型 id/名称',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--leixing-id',
|
||||
type=int,
|
||||
default=0,
|
||||
help='可选:直接指定全局类型主键;不传则按 --leixing-name 精确匹配',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--leixing-name',
|
||||
type=str,
|
||||
default=DEFAULT_LEIXING_NAME,
|
||||
help='全局 ShangpinLeixing.jieshao 全名精确匹配,默认:三角洲',
|
||||
)
|
||||
parser.add_argument('--with-banners', action='store_true', help='同时导入点单主轮播+第二横幅')
|
||||
parser.add_argument('--skip-oss', action='store_true', help='不上传 OSS,保留原 URL(应急)')
|
||||
parser.add_argument('--club-id', type=str, default=TARGET_CLUB, help='仅允许 ufo')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from products.models import ShangpinLeixing
|
||||
|
||||
if options.get('list_leixing'):
|
||||
self.stdout.write('全局商品类型清单(ShangpinLeixing):')
|
||||
for lx in ShangpinLeixing.query.order_by('id'):
|
||||
mark = ' <<-- 精确名=三角洲' if (lx.jieshao or '') == DEFAULT_LEIXING_NAME else ''
|
||||
self.stdout.write(
|
||||
f' id={lx.id} name={lx.jieshao!r} '
|
||||
f'shenhezhuangtai={lx.shenhezhuangtai} paixu={lx.paixu}{mark}'
|
||||
)
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
'默认会精确匹配 name==「三角洲」;也可 --leixing-id=... 覆盖。\n'
|
||||
'俱乐部上架表 ClubShangpinLeixingConfig 会在 --apply 时给 ufo 自动开启该类型。'
|
||||
))
|
||||
return
|
||||
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
if club_id != TARGET_CLUB:
|
||||
raise CommandError(
|
||||
f'拒绝执行:只允许 club_id={TARGET_CLUB},你传的是 {club_id!r}'
|
||||
)
|
||||
|
||||
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
|
||||
if not catalog_path.is_file():
|
||||
raise CommandError(f'找不到目录文件: {catalog_path}')
|
||||
|
||||
data = json.loads(catalog_path.read_text(encoding='utf-8'))
|
||||
products = data.get('products') or []
|
||||
zones = data.get('zones') or []
|
||||
banners = data.get('banners') or []
|
||||
banners2 = data.get('banners2') or []
|
||||
apply = bool(options['apply'])
|
||||
with_banners = bool(options['with_banners'])
|
||||
skip_oss = bool(options['skip_oss'])
|
||||
|
||||
from jituan.models import Club, ClubShangpinLeixingConfig
|
||||
from products.models import Shangpin, ShangpinZhuanqu
|
||||
|
||||
if not Club.query.filter(club_id=TARGET_CLUB).exists():
|
||||
raise CommandError(f'俱乐部 {TARGET_CLUB} 不存在,请先 create_club ufo')
|
||||
|
||||
leixing = self._resolve_leixing(
|
||||
ShangpinLeixing,
|
||||
leixing_id=int(options.get('leixing_id') or 0),
|
||||
leixing_name=(options.get('leixing_name') or '').strip(),
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'目标俱乐部={TARGET_CLUB} 类型={leixing.jieshao!r}(id={leixing.id}) '
|
||||
f'shenhezhuangtai={leixing.shenhezhuangtai} '
|
||||
f'专区={len(zones)} 商品={len(products)} banners={len(banners)}+{len(banners2)} '
|
||||
f'mode={"APPLY" if apply else "DRY-RUN"}'
|
||||
))
|
||||
if leixing.shenhezhuangtai == 2:
|
||||
raise CommandError(
|
||||
f'类型 id={leixing.id}「{leixing.jieshao}」是审核专用,拒绝挂载'
|
||||
)
|
||||
expect_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip()
|
||||
if (leixing.jieshao or '') != expect_name and not int(options.get('leixing_id') or 0):
|
||||
raise CommandError(
|
||||
f'安全拦截:解析到的类型名是「{leixing.jieshao}」,'
|
||||
f'与要求的精确名「{expect_name}」不一致。'
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f'将写入:ClubShangpinLeixingConfig(club=ufo, leixing_id={leixing.id}, is_enabled=True);'
|
||||
f'专区/商品 leixing_id={leixing.id}, club_id=ufo'
|
||||
)
|
||||
|
||||
for z in zones:
|
||||
self.stdout.write(f' 专区: {z.get("name")} goods={len(z.get("goods_ids") or [])}')
|
||||
for p in products[:5]:
|
||||
self.stdout.write(
|
||||
f' 商品样例: [{p.get("source_id")}] {p.get("title")} ¥{p.get("price")} → {p.get("zone_name")}'
|
||||
)
|
||||
if len(products) > 5:
|
||||
self.stdout.write(f' ... 其余 {len(products) - 5} 个商品省略')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'当前 dry-run,未写库。确认无误后:\n'
|
||||
' ./venv/bin/python manage.py import_ufo_catalog --apply\n'
|
||||
' ./venv/bin/python manage.py import_ufo_catalog --apply --with-banners'
|
||||
))
|
||||
return
|
||||
|
||||
stats = {
|
||||
'leixing_enabled': 0,
|
||||
'zones_created': 0,
|
||||
'zones_updated': 0,
|
||||
'products_created': 0,
|
||||
'products_updated': 0,
|
||||
'banners': 0,
|
||||
'oss_ok': 0,
|
||||
'oss_fail': 0,
|
||||
}
|
||||
|
||||
def to_oss(url: str, prefix: str) -> str:
|
||||
"""下载原图 → 上传到本站 OSS → 返回相对路径(与后台 AddProduct 一致)。"""
|
||||
if not url:
|
||||
return ''
|
||||
if skip_oss:
|
||||
# 应急:允许写完整 URL;正式导入不要加 --skip-oss
|
||||
return url
|
||||
if not str(url).startswith('http'):
|
||||
return url
|
||||
path = self._upload_remote(url, prefix)
|
||||
if not path:
|
||||
stats['oss_fail'] += 1
|
||||
raise CommandError(f'OSS 上传失败,已中止以免库里写入外站链接: {url}')
|
||||
stats['oss_ok'] += 1
|
||||
return path
|
||||
|
||||
with transaction.atomic():
|
||||
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id=leixing.id,
|
||||
defaults={
|
||||
'is_enabled': True,
|
||||
'paixu': int(leixing.paixu or 0),
|
||||
'tupian_url': '',
|
||||
},
|
||||
)
|
||||
if not cfg.is_enabled:
|
||||
cfg.is_enabled = True
|
||||
cfg.save(update_fields=['is_enabled', 'UpdateTime'])
|
||||
stats['leixing_enabled'] += 1
|
||||
elif created:
|
||||
stats['leixing_enabled'] += 1
|
||||
|
||||
zone_id_map = {}
|
||||
for idx, z in enumerate(zones):
|
||||
name = (z.get('name') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
src_id = int(z.get('source_id') or 0)
|
||||
thumb = to_oss(z.get('thumb_url') or '', 'shangpin/zhuanqu')
|
||||
row = ShangpinZhuanqu.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
mingzi=name,
|
||||
leixing_id=leixing.id,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if row:
|
||||
row.tupian_url = thumb or row.tupian_url
|
||||
row.shenhezhuangtai = 1
|
||||
row.paixu = max(len(zones) - idx, 0)
|
||||
row.save(update_fields=['tupian_url', 'shenhezhuangtai', 'paixu', 'UpdateTime'])
|
||||
stats['zones_updated'] += 1
|
||||
else:
|
||||
row = ShangpinZhuanqu.query.create(
|
||||
mingzi=name,
|
||||
leixing_id=leixing.id,
|
||||
club_id=TARGET_CLUB,
|
||||
tupian_url=thumb or '',
|
||||
shenhezhuangtai=1,
|
||||
paixu=max(len(zones) - idx, 0),
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['zones_created'] += 1
|
||||
if src_id:
|
||||
zone_id_map[src_id] = row.id
|
||||
zone_id_map[name] = row.id
|
||||
|
||||
for p in products:
|
||||
title = (p.get('title') or '').strip()
|
||||
if not title:
|
||||
continue
|
||||
zone_name = (p.get('zone_name') or p.get('target_zhuanqu_name') or '').strip()
|
||||
cat2 = int(p.get('cat2_id') or 0)
|
||||
zhuanqu_id = zone_id_map.get(cat2) or zone_id_map.get(zone_name)
|
||||
if not zhuanqu_id:
|
||||
self.stderr.write(f'跳过无专区商品: {title} zone={zone_name}')
|
||||
continue
|
||||
|
||||
price = self._decimal(p.get('price'))
|
||||
intro = (p.get('intro') or '').strip()
|
||||
xuzhi = (p.get('buy_tips_text') or '').strip()
|
||||
if p.get('rise_reason'):
|
||||
xuzhi = ((xuzhi + '\n\n') if xuzhi else '') + f"加价说明:{p.get('rise_reason')}"
|
||||
|
||||
pics = list(p.get('pics_url') or [])
|
||||
thumb = p.get('thumb_url') or (pics[0] if pics else '')
|
||||
tupian = to_oss(thumb, 'shangpin/shangpintupian')
|
||||
rule_imgs = list(p.get('rule_images_url') or [])
|
||||
guize = to_oss(rule_imgs[0], 'shangpin/guize') if rule_imgs else ''
|
||||
|
||||
weigh = int(p.get('weigh') or p.get('home_weigh') or 0)
|
||||
on_shelf = int(p.get('status') or 1) == 1
|
||||
is_raffle = int(p.get('raffle_id') or 0) > 0
|
||||
|
||||
row = Shangpin.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
biaoqian=title,
|
||||
zhuanqu_id=zhuanqu_id,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
|
||||
fields = dict(
|
||||
biaoqian=title,
|
||||
jiage=price,
|
||||
kucun=99999,
|
||||
leixing_id=leixing.id,
|
||||
zhuanqu_id=zhuanqu_id,
|
||||
jieshao=intro,
|
||||
xiadan_xuzhi=xuzhi[:5000] if xuzhi else '',
|
||||
guize_tupian=guize or '',
|
||||
tupian_url=tupian or '',
|
||||
yaoqiuleixing=leixing.yaoqiuleixing,
|
||||
huiyuan_id=leixing.huiyuan_id,
|
||||
yongjin=leixing.yongjin,
|
||||
paixu=weigh,
|
||||
shenhezhuangtai=1,
|
||||
shangjia_zhuangtai=on_shelf,
|
||||
club_id=TARGET_CLUB,
|
||||
shi_zhuanpan=bool(is_raffle),
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
|
||||
if row:
|
||||
for k, v in fields.items():
|
||||
setattr(row, k, v)
|
||||
row.save()
|
||||
stats['products_updated'] += 1
|
||||
else:
|
||||
Shangpin.query.create(
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
**fields,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
if with_banners:
|
||||
from config.models import Lunbo
|
||||
from jituan.services.display_config import (
|
||||
LUNBO_PAGE_ACCEPT_BANNER2,
|
||||
LUNBO_PAGE_ACCEPT_ORDER,
|
||||
lunbo_image_type_for_page,
|
||||
)
|
||||
|
||||
def upsert_lunbo(page_key, items):
|
||||
image_type = lunbo_image_type_for_page(page_key)
|
||||
Lunbo.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
page_key=page_key,
|
||||
ImageType=image_type,
|
||||
).delete()
|
||||
for b in items:
|
||||
url = to_oss(b.get('url') or '', 'a_long/lunbo')
|
||||
if not url:
|
||||
continue
|
||||
Lunbo.query.create(
|
||||
club_id=TARGET_CLUB,
|
||||
page_key=page_key,
|
||||
ImageURL=url,
|
||||
ImageType=image_type,
|
||||
)
|
||||
stats['banners'] += 1
|
||||
|
||||
upsert_lunbo(LUNBO_PAGE_ACCEPT_ORDER, banners)
|
||||
upsert_lunbo(LUNBO_PAGE_ACCEPT_BANNER2, banners2)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'完成(仅 club=ufo): '
|
||||
f"类型上架={stats['leixing_enabled']} "
|
||||
f"专区+={stats['zones_created']}/改={stats['zones_updated']} "
|
||||
f"商品+={stats['products_created']}/改={stats['products_updated']} "
|
||||
f"轮播={stats['banners']} "
|
||||
f"OSS成功={stats['oss_ok']} 失败={stats['oss_fail']}"
|
||||
))
|
||||
|
||||
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
|
||||
"""优先 id;否则 jieshao 全名精确匹配且必须唯一。不做 icontains。"""
|
||||
if leixing_id:
|
||||
try:
|
||||
lx = ShangpinLeixing.query.get(id=leixing_id)
|
||||
except ShangpinLeixing.DoesNotExist as exc:
|
||||
raise CommandError(f'类型 id={leixing_id} 不存在') from exc
|
||||
return lx
|
||||
|
||||
name = (leixing_name or DEFAULT_LEIXING_NAME).strip() or DEFAULT_LEIXING_NAME
|
||||
rows = list(
|
||||
ShangpinLeixing.query.filter(jieshao=name)
|
||||
.exclude(shenhezhuangtai=2)
|
||||
.order_by('id')
|
||||
)
|
||||
if not rows:
|
||||
near = list(
|
||||
ShangpinLeixing.query.filter(jieshao__icontains=name).order_by('id')[:20]
|
||||
)
|
||||
hint = '\n'.join(
|
||||
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
|
||||
for x in near
|
||||
) or ' (无近似)'
|
||||
raise CommandError(
|
||||
f'找不到 jieshao 精确等于「{name}」且非审核专用的类型。库中近似:\n{hint}\n'
|
||||
'请先在后台把类型名改成正好「三角洲」,或 --leixing-id=... 指定。'
|
||||
)
|
||||
if len(rows) > 1:
|
||||
detail = '\n'.join(
|
||||
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
|
||||
for x in rows
|
||||
)
|
||||
raise CommandError(
|
||||
f'「{name}」精确匹配到多条,拒绝自动选,避免挂错:\n{detail}'
|
||||
)
|
||||
return rows[0]
|
||||
|
||||
@staticmethod
|
||||
def _decimal(v) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(v if v is not None else '0'))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return Decimal('0')
|
||||
|
||||
@staticmethod
|
||||
def _upload_remote(url: str, prefix: str) -> str:
|
||||
"""返回 OSS 相对 key;失败返回空串。与后台一致:DB 只存相对路径。"""
|
||||
from datetime import datetime
|
||||
|
||||
from utils.oss_utils import upload_to_oss
|
||||
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
req = Request(url, headers={'User-Agent': 'UFO-Import/1.0'})
|
||||
with urlopen(req, timeout=60, context=ctx) as resp:
|
||||
raw = resp.read()
|
||||
ctype = resp.headers.get('Content-Type') or ''
|
||||
except Exception: # noqa: BLE001
|
||||
return ''
|
||||
|
||||
ext = mimetypes.guess_extension((ctype or '').split(';')[0].strip()) or ''
|
||||
if not ext or ext == '.jpe':
|
||||
m = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)(?:\?|$)', url, re.I)
|
||||
ext = ('.' + m.group(1).lower()) if m else '.jpg'
|
||||
if ext == '.jpeg':
|
||||
ext = '.jpg'
|
||||
|
||||
ts = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
rel = f"{prefix.strip('/')}/{ts}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
bio = BytesIO(raw)
|
||||
bio.name = os.path.basename(rel)
|
||||
ok = upload_to_oss(bio, rel)
|
||||
if not ok:
|
||||
return ''
|
||||
return rel # 关键:只返回相对路径,不返回完整 CDN URL
|
||||
76
jituan/management/commands/nuke_kefu_admin.py
Normal file
76
jituan/management/commands/nuke_kefu_admin.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""紧急封杀后台账号:硬删任职、摘超管、禁用客服、改密、清角色。
|
||||
|
||||
用法:
|
||||
python manage.py nuke_kefu_admin 13837714110
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '紧急封杀指定手机号的后台权限(硬删除任职)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('phone', type=str, help='要封杀的手机号')
|
||||
parser.add_argument(
|
||||
'--also-whitelist',
|
||||
action='store_true',
|
||||
help='连代码白名单手机号也允许封',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
phone = (options['phone'] or '').strip()
|
||||
if not phone:
|
||||
self.stderr.write('缺少手机号')
|
||||
return
|
||||
|
||||
from jituan.constants import SUPER_ADMIN_PHONES
|
||||
if phone in SUPER_ADMIN_PHONES and not options['also_whitelist']:
|
||||
self.stderr.write(
|
||||
f'{phone} 在白名单,拒绝。确要封加 --also-whitelist'
|
||||
)
|
||||
return
|
||||
|
||||
from users.business_models import User
|
||||
from jituan.models import AdminAssignment
|
||||
from gvsdsdk.models import UserRole as SdkUserRole
|
||||
import bcrypt
|
||||
|
||||
users = list(User.objects.filter(Phone=phone))
|
||||
if not users:
|
||||
self.stderr.write(f'未找到 {phone}')
|
||||
return
|
||||
|
||||
new_pwd = f'LOCKED_{phone}_REVOKED'
|
||||
for u in users:
|
||||
self.stdout.write(f'HIT uid={u.UserUID} super={u.IsSuperuser}')
|
||||
u.IsSuperuser = False
|
||||
u.IsStaff = False
|
||||
u.SetPassword(new_pwd)
|
||||
u.save()
|
||||
|
||||
n, _ = AdminAssignment.objects.filter(yonghuid=u.UserUID).delete()
|
||||
self.stdout.write(f' 硬删任职={n}')
|
||||
|
||||
n_role = SdkUserRole.objects.filter(UserUUID=u.UserUUID).delete()
|
||||
self.stdout.write(f' 删功能角色={n_role}')
|
||||
|
||||
try:
|
||||
kefu = u.KefuProfile
|
||||
kefu.zhuangtai = 0
|
||||
kefu.erjimima = bcrypt.hashpw(
|
||||
b'LOCKED_REVOKED', bcrypt.gensalt(rounds=12)
|
||||
).decode('utf-8')
|
||||
kefu.save(update_fields=['zhuangtai', 'erjimima', 'UpdateTime'])
|
||||
self.stdout.write(' 客服已禁用')
|
||||
except Exception as e:
|
||||
self.stdout.write(f' 无客服扩展: {e}')
|
||||
|
||||
# 顺手清全库 NULL 俱乐部垃圾重复
|
||||
from jituan.services.admin_assignments import dedupe_active_assignments
|
||||
d = dedupe_active_assignments()
|
||||
self.stdout.write(f'全库去重硬删重复={d}')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'已封杀 {phone}。密码={new_pwd}。请 restart gunicorn。'
|
||||
))
|
||||
312
jituan/management/commands/reset_lxs_shenhe_catalog_from_xq.py
Normal file
312
jituan/management/commands/reset_lxs_shenhe_catalog_from_xq.py
Normal file
@@ -0,0 +1,312 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
龙先生(lxs)审核版目录重置:清空本俱乐部三个审核类型下的专区/商品,再从星阙(xq)原样复制。
|
||||
|
||||
范围(必须全部满足):
|
||||
- 目标俱乐部固定 lxs(name 含「龙先生」)
|
||||
- 源俱乐部固定 xq(name 含「星阙」)
|
||||
- 仅全局 ShangpinLeixing.shenhezhuangtai=2 的类型(通常 3 个)
|
||||
- 仅公共目录 dianpu IS NULL
|
||||
- 专区/商品均要求 shenhezhuangtai=2
|
||||
|
||||
步骤:
|
||||
1) 删除 lxs 下上述类型的专区+商品(只删 DB,不删 OSS,避免误伤星阙共用图)
|
||||
2) 从 xq 复制同类型专区+商品到 lxs(图片复用相对路径;销量归零)
|
||||
|
||||
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、全局类型表、订单等。
|
||||
|
||||
用法:
|
||||
python manage.py reset_lxs_shenhe_catalog_from_xq
|
||||
python manage.py reset_lxs_shenhe_catalog_from_xq --list
|
||||
python manage.py reset_lxs_shenhe_catalog_from_xq --apply --i-confirm=lxs-from-xq
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
TARGET_CLUB = 'lxs'
|
||||
TARGET_NAME_HINT = '龙先生'
|
||||
SOURCE_CLUB = 'xq'
|
||||
SOURCE_NAME_HINT = '星阙'
|
||||
SHENHE = 2
|
||||
CONFIRM_TOKEN = 'lxs-from-xq'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '清空龙先生审核专区/商品,并从星阙复制三个审核类型目录(默认 dry-run)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正删+写')
|
||||
parser.add_argument(
|
||||
'--i-confirm',
|
||||
type=str,
|
||||
default='',
|
||||
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list',
|
||||
action='store_true',
|
||||
help='只列出审核类型及 xq/lxs 下专区商品数量',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club
|
||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm') or '').strip()
|
||||
|
||||
src_club = Club.query.filter(club_id=SOURCE_CLUB).first()
|
||||
if not src_club:
|
||||
raise CommandError(f'源俱乐部 {SOURCE_CLUB} 不存在')
|
||||
if SOURCE_NAME_HINT not in (src_club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:{SOURCE_CLUB} 的 name={src_club.name!r} 不含「{SOURCE_NAME_HINT}」'
|
||||
)
|
||||
|
||||
dst_club = Club.query.filter(club_id=TARGET_CLUB).first()
|
||||
if not dst_club:
|
||||
raise CommandError(f'目标俱乐部 {TARGET_CLUB} 不存在')
|
||||
if TARGET_NAME_HINT not in (dst_club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:{TARGET_CLUB} 的 name={dst_club.name!r} 不含「{TARGET_NAME_HINT}」'
|
||||
)
|
||||
|
||||
audit_types = list(
|
||||
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||
)
|
||||
if not audit_types:
|
||||
raise CommandError('没有任何审核专用商品类型(shenhezhuangtai=2)')
|
||||
if len(audit_types) != 3:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'注意:当前审核类型数量={len(audit_types)}(预期常为 3),将按实际全部处理。'
|
||||
))
|
||||
|
||||
leixing_ids = [lx.id for lx in audit_types]
|
||||
|
||||
def counts(club_id):
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
return z, p
|
||||
|
||||
def per_type(club_id):
|
||||
rows = []
|
||||
for lx in audit_types:
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=club_id,
|
||||
leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
rows.append((lx, z, p))
|
||||
return rows
|
||||
|
||||
if options.get('list'):
|
||||
self.stdout.write(self.style.NOTICE('审核类型及两侧存量:'))
|
||||
for lx in audit_types:
|
||||
sz, sp = (
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
Shangpin.query.filter(
|
||||
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
)
|
||||
dz, dp = (
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
Shangpin.query.filter(
|
||||
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count(),
|
||||
)
|
||||
self.stdout.write(
|
||||
f' id={lx.id}「{lx.jieshao}」 '
|
||||
f'xq: zones={sz} products={sp} | '
|
||||
f'lxs: zones={dz} products={dp}'
|
||||
)
|
||||
return
|
||||
|
||||
if apply and confirm != CONFIRM_TOKEN:
|
||||
raise CommandError(
|
||||
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||
f' python manage.py reset_lxs_shenhe_catalog_from_xq '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||
)
|
||||
|
||||
src_z_total, src_p_total = counts(SOURCE_CLUB)
|
||||
dst_z_total, dst_p_total = counts(TARGET_CLUB)
|
||||
|
||||
src_zones = list(
|
||||
ShangpinZhuanqu.query.filter(
|
||||
club_id=SOURCE_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).order_by('leixing_id', 'paixu', 'id')
|
||||
)
|
||||
src_products = list(
|
||||
Shangpin.query.filter(
|
||||
club_id=SOURCE_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).order_by('leixing_id', 'paixu', 'id')
|
||||
)
|
||||
|
||||
# 源商品的专区必须落在本批源专区内,否则拒绝(防串数据)
|
||||
src_zone_ids = {z.id for z in src_zones}
|
||||
orphan = [p for p in src_products if p.zhuanqu_id not in src_zone_ids]
|
||||
if orphan:
|
||||
sample = ', '.join(f'{p.id}:{p.biaoqian}' for p in orphan[:5])
|
||||
raise CommandError(
|
||||
f'源商品有 {len(orphan)} 条专区不在本批审核专区内,已中止。样例: {sample}'
|
||||
)
|
||||
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} 龙先生审核目录 ← 星阙 =====\n'
|
||||
f'源: {SOURCE_CLUB} ({src_club.name}) '
|
||||
f'专区={src_z_total} 商品={src_p_total}\n'
|
||||
f'目标: {TARGET_CLUB} ({dst_club.name}) '
|
||||
f'将先删除 专区={dst_z_total} 商品={dst_p_total},再复制源数据\n'
|
||||
f'审核类型数={len(audit_types)} ids={leixing_ids}\n'
|
||||
f'OSS: 不删文件;复制时复用星阙相对路径\n'
|
||||
))
|
||||
|
||||
self.stdout.write('—— 分类型存量 ——')
|
||||
for lx, sz, sp in per_type(SOURCE_CLUB):
|
||||
self.stdout.write(f' [xq] id={lx.id}「{lx.jieshao}」 zones={sz} products={sp}')
|
||||
for lx, dz, dp in per_type(TARGET_CLUB):
|
||||
self.stdout.write(f' [lxs] id={lx.id}「{lx.jieshao}」 zones={dz} products={dp}')
|
||||
|
||||
self.stdout.write('—— 将从 xq 复制的专区 ——')
|
||||
for z in src_zones[:40]:
|
||||
self.stdout.write(
|
||||
f' zq#{z.id} leixing={z.leixing_id} {z.mingzi!r}'
|
||||
)
|
||||
if len(src_zones) > 40:
|
||||
self.stdout.write(f' ... 其余 {len(src_zones) - 40} 个省略')
|
||||
|
||||
self.stdout.write('—— 将从 xq 复制的商品样例 ——')
|
||||
for p in src_products[:15]:
|
||||
self.stdout.write(
|
||||
f' sp#{p.id} leixing={p.leixing_id} {p.biaoqian!r} ¥{p.jiage}'
|
||||
)
|
||||
if len(src_products) > 15:
|
||||
self.stdout.write(f' ... 其余 {len(src_products) - 15} 个省略')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划:未删库、未写库。确认无误后:\n'
|
||||
f' python manage.py reset_lxs_shenhe_catalog_from_xq '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||
))
|
||||
return
|
||||
|
||||
stats = {
|
||||
'deleted_products': 0,
|
||||
'deleted_zones': 0,
|
||||
'zones_created': 0,
|
||||
'products_created': 0,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
# 1) 只删 lxs + 审核类型 + 公共
|
||||
n_prod, _ = Shangpin.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||
club_id=TARGET_CLUB,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
stats['deleted_products'] = n_prod
|
||||
stats['deleted_zones'] = n_zone
|
||||
|
||||
# 2) 按源专区插入,建立 id 映射
|
||||
zone_id_map = {}
|
||||
for z in src_zones:
|
||||
if z.leixing_id not in leixing_ids:
|
||||
raise CommandError(f'专区 {z.id} 类型 {z.leixing_id} 不在审核列表')
|
||||
row = ShangpinZhuanqu.query.create(
|
||||
mingzi=z.mingzi,
|
||||
leixing_id=z.leixing_id,
|
||||
club_id=TARGET_CLUB,
|
||||
tupian_url=z.tupian_url or '',
|
||||
shenhezhuangtai=SHENHE,
|
||||
paixu=int(z.paixu or 0),
|
||||
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
zone_id_map[z.id] = row.id
|
||||
stats['zones_created'] += 1
|
||||
|
||||
for p in src_products:
|
||||
if p.leixing_id not in leixing_ids:
|
||||
raise CommandError(f'商品 {p.id} 类型 {p.leixing_id} 不在审核列表')
|
||||
dst_zq = zone_id_map.get(p.zhuanqu_id)
|
||||
if not dst_zq:
|
||||
raise CommandError(
|
||||
f'商品「{p.biaoqian}」id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
|
||||
)
|
||||
Shangpin.query.create(
|
||||
biaoqian=p.biaoqian,
|
||||
jiage=p.jiage,
|
||||
kucun=p.kucun,
|
||||
leixing_id=p.leixing_id,
|
||||
zhuanqu_id=dst_zq,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=p.jieshao,
|
||||
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||
guize_tupian=p.guize_tupian or '',
|
||||
tupian_url=p.tupian_url or '',
|
||||
yaoqiuleixing=p.yaoqiuleixing,
|
||||
huiyuan_id=p.huiyuan_id,
|
||||
yongjin=p.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||
paixu=int(p.paixu or 0),
|
||||
shenhezhuangtai=SHENHE,
|
||||
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
|
||||
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
|
||||
club_id=TARGET_CLUB,
|
||||
shi_zhuanpan=False,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n完成: {stats}\n'
|
||||
f'星阙(xq)未改动;龙先生(lxs)审核目录已与星阙对齐。'
|
||||
))
|
||||
342
jituan/management/commands/reset_lxs_shenhe_esports_coach.py
Normal file
342
jituan/management/commands/reset_lxs_shenhe_esports_coach.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
龙先生(lxs)审核版目录重置:对齐小程序简介的「专业电竞护航 / 技巧支持 / 策略指导」。
|
||||
|
||||
目标(审核口径):
|
||||
- 强调:线上电竞技巧讲解、对局策略交流、团队协作指导、正向技能提升
|
||||
- 绝不出现:代练、代打、上分、线下陪玩、陪玩约玩等
|
||||
- 仅改 club_id=lxs + 全局审核类型(shenhezhuangtai=2) 下的专区/商品
|
||||
- 不碰正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、订单
|
||||
|
||||
动作:
|
||||
1) 将 3 个审核类型名改为:电竞技巧指导 / 对局策略交流 / 线上技能提升
|
||||
2) 清空 lxs 下这些类型的审核专区+商品(只删 DB,不删 OSS)
|
||||
3) 每个类型 2 个专区;每个专区 10 个商品(共 60)
|
||||
4) 图标统一 a_long/morentouxiang.jpg
|
||||
5) 下单须知写明:仅线上指导交流,禁止代练代打与线下服务
|
||||
|
||||
用法:
|
||||
python manage.py reset_lxs_shenhe_esports_coach
|
||||
python manage.py reset_lxs_shenhe_esports_coach --list
|
||||
python manage.py reset_lxs_shenhe_esports_coach --apply --i-confirm=lxs-esports-coach
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
ALLOWED_CLUB_ID = 'lxs'
|
||||
NAME_HINT = '龙先生'
|
||||
SHENHE = 2
|
||||
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
|
||||
CONFIRM_TOKEN = 'lxs-esports-coach'
|
||||
EXPECTED_TYPE_COUNT = 3
|
||||
|
||||
TYPE_NAMES = ['电竞技巧指导', '对局策略交流', '线上技能提升']
|
||||
|
||||
ZONE_NAMES_BY_TYPE = [
|
||||
['技巧讲解', '操作指导'],
|
||||
['策略复盘', '思路交流'],
|
||||
['协作训练', '正向成长'],
|
||||
]
|
||||
|
||||
PRODUCTS_PER_ZONE = [
|
||||
[
|
||||
('基础操作技巧讲解', '线上讲解基础操作与键位思路,帮助建立正确习惯,重在理解与练习。', '19.90'),
|
||||
('走位思路实时指导', '对局中讲解走位与观察习惯,提升意识,由学员本人操作完成。', '25.90'),
|
||||
('技能连招要点拆解', '拆解技能释放节奏与连招要点,循序渐进,强调自学自练。', '22.90'),
|
||||
('地图资源意识教学', '讲解地图信息与资源节奏,培养全局观,纯技巧交流。', '22.90'),
|
||||
('瞄准与预判要点分享', '分享瞄准预判的练习方法,给出训练建议,学员自行练习。', '25.90'),
|
||||
('开局节奏规划指导', '讲解开局规划与信息收集,帮助形成稳定开局习惯。', '19.90'),
|
||||
('道具使用思路讲解', '讲解常用道具使用时机与配合思路,强调理解而非包办。', '19.90'),
|
||||
('视野与信息读取教学', '教学如何读图、读信息,提升判断力,正向技能成长。', '22.90'),
|
||||
('残局处理要点交流', '交流残局决策思路与优先级,鼓励独立思考与复盘。', '25.90'),
|
||||
('个性化技巧答疑', '针对个人薄弱点线上答疑,给出可执行的练习清单。', '29.90'),
|
||||
],
|
||||
[
|
||||
('对局策略思路交流', '线上交流阵容与战术思路,重在理解策略,学员自主决策执行。', '25.90'),
|
||||
('团战站位策略讲解', '讲解团战站位与目标优先级,提升团队配合意识。', '25.90'),
|
||||
('资源分配策略讨论', '讨论资源分配与节奏取舍,培养理性决策能力。', '22.90'),
|
||||
('对手习惯观察复盘', '复盘如何观察对手习惯,给出分析框架,鼓励自我复盘。', '22.90'),
|
||||
('中期转线策略交流', '交流中期转线与节奏切换思路,帮助建立阶段目标。', '25.90'),
|
||||
('防守反击策略拆解', '拆解防守反击的触发条件与执行步骤,逻辑清晰可练。', '22.90'),
|
||||
('进攻时机判断指导', '讲解进攻时机判断要素,强调信息与纪律,杜绝侥幸。', '25.90'),
|
||||
('阵容克制关系科普', '科普常见克制关系与应对思路,开阔视野,正向学习。', '19.90'),
|
||||
('赛后复盘方法教学', '教学赛后复盘步骤:录像、标注、总结,养成成长闭环。', '19.90'),
|
||||
('策略问答一对一', '一对一策略问答,解答困惑,给出清晰可落地的改进方向。', '29.90'),
|
||||
],
|
||||
[
|
||||
('团队沟通协作指导', '指导语音沟通话术与分工习惯,打造文明友好的协作环境。', '19.90'),
|
||||
('良性竞争心态辅导', '交流输赢心态与压力管理,传递体育精神与正能量。', '15.90'),
|
||||
('训练计划制定协助', '协助制定可坚持的训练计划,目标清晰、循序渐进。', '19.90'),
|
||||
('团队默契练习指导', '指导信号、分工与节奏同步练习方法,提升协作效率。', '22.90'),
|
||||
('文明竞技礼仪分享', '分享线上竞技礼仪与友好互动规范,拒绝不良言行。', '12.90'),
|
||||
('专注力与节奏管理', '交流专注力与作息节奏管理小方法,支持长期进步。', '15.90'),
|
||||
('新手友好入门指导', '面向新手的入门讲解与练习指导,学员全程自主操作。', '19.90'),
|
||||
('进阶目标拆解辅导', '把长期目标拆成周计划,每周复盘一次,稳步提升。', '22.90'),
|
||||
('录像点评成长课', '基于学员录像做要点点评,指出亮点与改进点,鼓励成长。', '29.90'),
|
||||
('电竞护航技巧总复习', '汇总技巧与策略要点,形成个人笔记,巩固线上学习成果。', '25.90'),
|
||||
],
|
||||
]
|
||||
|
||||
BANNED_WORDS = (
|
||||
'代练', '代打', '上分', '上段', '包星', '定位赛', '代肝', '代刷',
|
||||
'代打手', '代练平台', '代练服务', '护航上分', '包赢',
|
||||
'陪玩', '线下陪玩', '上门', '约玩', '陪睡觉', '色情',
|
||||
'赌博', '出租号', '买号', '卖号',
|
||||
)
|
||||
|
||||
ORDER_NOTICE = (
|
||||
'本服务为龙先生电竞提供的线上电竞技巧支持、对局策略指导与正向技能交流。'
|
||||
'全程由学员本人登录并亲自操作、自行决策;服务内容仅限讲解、答疑与思路交流。'
|
||||
'请文明沟通,共建专业、健康的电竞学习环境。'
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '龙先生审核版改为电竞技巧/策略指导正能量目录(默认 dry-run)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||
parser.add_argument(
|
||||
'--i-confirm',
|
||||
type=str,
|
||||
default='',
|
||||
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list',
|
||||
action='store_true',
|
||||
help='只列出当前审核类型及 lxs 存量',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-rename-types',
|
||||
action='store_true',
|
||||
help='不修改全局审核类型名称,仅重建 lxs 专区/商品',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club
|
||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm') or '').strip()
|
||||
skip_rename = bool(options.get('skip_rename_types'))
|
||||
|
||||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||
if not club:
|
||||
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||
if NAME_HINT not in (club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:{ALLOWED_CLUB_ID} name={club.name!r} 不含「{NAME_HINT}」'
|
||||
)
|
||||
|
||||
audit_types = list(
|
||||
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||
)
|
||||
if len(audit_types) != EXPECTED_TYPE_COUNT:
|
||||
raise CommandError(
|
||||
f'安全拦截:审核类型数量应为 {EXPECTED_TYPE_COUNT},'
|
||||
f'当前为 {len(audit_types)}:'
|
||||
+ ', '.join(f'id={t.id}「{t.jieshao}」' for t in audit_types)
|
||||
)
|
||||
|
||||
leixing_ids = [t.id for t in audit_types]
|
||||
|
||||
def counts():
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
return z, p
|
||||
|
||||
if options.get('list'):
|
||||
self.stdout.write(self.style.NOTICE('审核类型 → 计划改名;lxs 当前专区/商品:'))
|
||||
for i, t in enumerate(audit_types):
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count()
|
||||
self.stdout.write(
|
||||
f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」 '
|
||||
f'lxs zones={z} products={p}'
|
||||
)
|
||||
return
|
||||
|
||||
for zone_list in PRODUCTS_PER_ZONE:
|
||||
if len(zone_list) != 10:
|
||||
raise CommandError('内部错误:每个类型必须恰好 10 个商品模板')
|
||||
for title, intro, _price in zone_list:
|
||||
# 只扫标题/介绍;须知用正向表述,不出现敏感词本身
|
||||
blob = title + intro
|
||||
for w in BANNED_WORDS:
|
||||
if w in blob:
|
||||
raise CommandError(f'文案含禁止词「{w}」: {title}')
|
||||
for w in BANNED_WORDS:
|
||||
if w in ORDER_NOTICE:
|
||||
raise CommandError(f'下单须知含禁止词「{w}」,请改成正向表述')
|
||||
|
||||
for zones in ZONE_NAMES_BY_TYPE:
|
||||
if len(zones) != 2:
|
||||
raise CommandError('内部错误:每个类型必须恰好 2 个专区')
|
||||
for zn in zones:
|
||||
for w in BANNED_WORDS:
|
||||
if w in zn:
|
||||
raise CommandError(f'专区名含禁止词「{w}」: {zn}')
|
||||
|
||||
for tn in TYPE_NAMES:
|
||||
for w in BANNED_WORDS:
|
||||
if w in tn:
|
||||
raise CommandError(f'类型名含禁止词「{w}」: {tn}')
|
||||
|
||||
if apply and confirm != CONFIRM_TOKEN:
|
||||
raise CommandError(
|
||||
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||
f' python manage.py reset_lxs_shenhe_esports_coach '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||
)
|
||||
|
||||
old_z, old_p = counts()
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} 龙先生审核 → 电竞技巧/策略指导 =====\n'
|
||||
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||
f'将清空 lxs 审核专区={old_z} 商品={old_p},再写入 '
|
||||
f'{EXPECTED_TYPE_COUNT}类型×2专区×10商品=60\n'
|
||||
f'图标: {DEFAULT_AVATAR}\n'
|
||||
f'改名全局类型: {"否" if skip_rename else "是(影响所有俱乐部审核展示名)"}\n'
|
||||
f'定位: 线上技巧支持 / 策略指导 / 正向技能提升;无代练代打、无线下陪玩\n'
|
||||
))
|
||||
|
||||
self.stdout.write('—— 类型改名计划 ——')
|
||||
for i, t in enumerate(audit_types):
|
||||
self.stdout.write(f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」')
|
||||
|
||||
self.stdout.write('—— 专区/商品结构 ——')
|
||||
for i, tname in enumerate(TYPE_NAMES):
|
||||
for zn in ZONE_NAMES_BY_TYPE[i]:
|
||||
self.stdout.write(f' 「{tname}」/「{zn}」→ 10 个指导类商品')
|
||||
|
||||
self.stdout.write('—— 商品样例 ——')
|
||||
for title, intro, price in PRODUCTS_PER_ZONE[0][:3]:
|
||||
self.stdout.write(f' {title} ¥{price} | {intro}')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划。确认后执行:\n'
|
||||
f' python manage.py reset_lxs_shenhe_esports_coach '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||
))
|
||||
return
|
||||
|
||||
stats = {
|
||||
'types_renamed': 0,
|
||||
'deleted_products': 0,
|
||||
'deleted_zones': 0,
|
||||
'zones_created': 0,
|
||||
'products_created': 0,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
if not skip_rename:
|
||||
for i, t in enumerate(audit_types):
|
||||
new_name = TYPE_NAMES[i]
|
||||
if (t.jieshao or '') != new_name:
|
||||
t.jieshao = new_name
|
||||
t.tupian_url = t.tupian_url or DEFAULT_AVATAR
|
||||
t.save()
|
||||
stats['types_renamed'] += 1
|
||||
|
||||
n_prod, _ = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
stats['deleted_products'] = n_prod
|
||||
stats['deleted_zones'] = n_zone
|
||||
|
||||
for i, lx in enumerate(audit_types):
|
||||
lx = ShangpinLeixing.query.filter(id=lx.id).first()
|
||||
zone_names = ZONE_NAMES_BY_TYPE[i]
|
||||
base_products = PRODUCTS_PER_ZONE[i]
|
||||
for zi, zname in enumerate(zone_names):
|
||||
zrow = ShangpinZhuanqu.query.create(
|
||||
mingzi=zname,
|
||||
leixing_id=lx.id,
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
tupian_url=DEFAULT_AVATAR,
|
||||
shenhezhuangtai=SHENHE,
|
||||
paixu=20 - zi * 10,
|
||||
shangjia_zhuangtai=True,
|
||||
fengjin_zhuangtai=True,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['zones_created'] += 1
|
||||
|
||||
for pi, (title, intro, price) in enumerate(base_products):
|
||||
full_title = f'{title}·{zname}'
|
||||
for w in BANNED_WORDS:
|
||||
if w in full_title or w in intro:
|
||||
raise CommandError(f'禁止词命中: {full_title}')
|
||||
Shangpin.query.create(
|
||||
biaoqian=full_title[:45],
|
||||
jiage=Decimal(price),
|
||||
kucun=99999,
|
||||
leixing_id=lx.id,
|
||||
zhuanqu_id=zrow.id,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=intro,
|
||||
xiadan_xuzhi=ORDER_NOTICE,
|
||||
guize_tupian='',
|
||||
tupian_url=DEFAULT_AVATAR,
|
||||
yaoqiuleixing=lx.yaoqiuleixing,
|
||||
huiyuan_id=lx.huiyuan_id,
|
||||
yongjin=lx.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=False,
|
||||
ewai_dashou_fencheng=None,
|
||||
paixu=100 - pi,
|
||||
shenhezhuangtai=SHENHE,
|
||||
shangjia_zhuangtai=True,
|
||||
fengjin_zhuangtai=True,
|
||||
shenhe_zhuangtai=True,
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
shi_zhuanpan=False,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
if stats['zones_created'] != 6 or stats['products_created'] != 60:
|
||||
raise CommandError(
|
||||
f'数量异常: zones={stats["zones_created"]} '
|
||||
f'products={stats["products_created"]},期望 6/60,已回滚'
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n完成: {stats}\n'
|
||||
f'均为审核专用(shenhezhuangtai=2);文案对齐「技巧支持/策略指导」,'
|
||||
f'已避开代练代打与线下陪玩。'
|
||||
))
|
||||
332
jituan/management/commands/reset_lxs_shenhe_yule_peiban.py
Normal file
332
jituan/management/commands/reset_lxs_shenhe_yule_peiban.py
Normal file
@@ -0,0 +1,332 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
龙先生(lxs)审核目录重置为「娱乐陪伴」正向内容(非代练代打、不过度游戏化)。
|
||||
|
||||
针对:全局 3 个 shenhezhuangtai=2 的商品类型 + 俱乐部 lxs 下专区/商品。
|
||||
|
||||
动作:
|
||||
1) 将 3 个审核类型名改为:娱乐陪伴 / 休闲同行 / 友好互动(改全局类型名,dry-run 会提示)
|
||||
2) 清空 lxs 下这些类型的审核专区+商品(只删 DB,不删 OSS)
|
||||
3) 每个类型建 2 个专区;每个专区 10 个商品(共 60)
|
||||
4) 图标一律 a_long/morentouxiang.jpg
|
||||
5) 文案纯正能量陪伴/交流,禁止代练代打上分等词
|
||||
|
||||
用法:
|
||||
python manage.py reset_lxs_shenhe_yule_peiban
|
||||
python manage.py reset_lxs_shenhe_yule_peiban --list
|
||||
python manage.py reset_lxs_shenhe_yule_peiban --apply --i-confirm=lxs-yule-peiban
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
ALLOWED_CLUB_ID = 'lxs'
|
||||
NAME_HINT = '龙先生'
|
||||
SHENHE = 2
|
||||
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
|
||||
CONFIRM_TOKEN = 'lxs-yule-peiban'
|
||||
EXPECTED_TYPE_COUNT = 3
|
||||
|
||||
# 三个审核类型目标名称(按现有审核类型 id 升序一一对应)
|
||||
TYPE_NAMES = ['娱乐陪伴', '休闲同行', '友好互动']
|
||||
|
||||
# 每个类型 2 个专区名
|
||||
ZONE_NAMES_BY_TYPE = [
|
||||
['温暖陪伴', '轻松畅聊'],
|
||||
['兴趣交流', '日常鼓励'],
|
||||
['正能量同行', '休闲时光'],
|
||||
]
|
||||
|
||||
# 每个专区 10 个商品:(标题, 介绍, 价格)
|
||||
# 刻意弱化具体竞技/上分,强调陪伴、交流、正向娱乐
|
||||
PRODUCTS_PER_ZONE = [
|
||||
[
|
||||
('暖心语音陪伴', '友好语音交流,倾听分享,营造轻松正能量氛围。', '19.90'),
|
||||
('轻松心情疏导', '温和聊天陪伴,帮助放松心情,传递积极态度。', '19.90'),
|
||||
('兴趣话题畅聊', '围绕健康兴趣爱好交流,互相启发,气氛友善。', '15.90'),
|
||||
('正能量晚间陪伴', '晚间轻松聊天,分享日常点滴,友好互动。', '19.90'),
|
||||
('学习打卡互勉', '互相提醒学习计划,鼓励坚持,共同进步。', '15.90'),
|
||||
('运动打卡陪伴', '交流运动习惯与打卡心得,互相鼓励坚持。', '15.90'),
|
||||
('阅读心得分享', '分享好书与阅读感受,文明交流,开阔视野。', '12.90'),
|
||||
('音乐放松同行', '一起听歌聊天放松,分享喜爱曲目,气氛轻松。', '12.90'),
|
||||
('生活小妙招交流', '交流生活经验与小技巧,互助友爱。', '12.90'),
|
||||
('周末休闲畅聊', '周末轻松闲聊,分享愉快见闻,友好陪伴。', '15.90'),
|
||||
],
|
||||
[
|
||||
('兴趣社群式交流', '按兴趣轻松交流,结识同好,氛围文明友好。', '15.90'),
|
||||
('手工创作分享', '分享手工与创作过程,互相欣赏鼓励。', '15.90'),
|
||||
('摄影见闻交流', '交流摄影与风景见闻,传递美好感受。', '12.90'),
|
||||
('厨艺心得闲聊', '聊聊家常菜与烹饪心得,轻松愉快。', '12.90'),
|
||||
('宠物日常分享', '分享萌宠日常,友好互动,温暖治愈。', '12.90'),
|
||||
('旅行见闻讲述', '讲述旅行见闻与风景,开阔眼界,正能量满满。', '19.90'),
|
||||
('电影观后闲谈', '交流观影感受与推荐,文明讨论,轻松愉快。', '12.90'),
|
||||
('桌游规则解说', '友好讲解休闲桌游玩法,一起快乐体验。', '15.90'),
|
||||
('棋类休闲对弈', '休闲棋类友好对弈,重在交流与乐趣。', '15.90'),
|
||||
('午后茶歇闲聊', '午后轻松聊天陪伴,舒缓节奏,气氛温馨。', '12.90'),
|
||||
],
|
||||
[
|
||||
('早安鼓励打卡', '清晨互相问候鼓励,开启积极一天。', '9.90'),
|
||||
('习惯养成互勉', '一起制定小目标,互相督促,温柔坚持。', '15.90'),
|
||||
('情绪友好倾听', '认真倾听烦恼,给予友善支持与正向建议。', '19.90'),
|
||||
('时间管理小谈', '交流高效作息小方法,互相提醒,积极向上。', '15.90'),
|
||||
('环保生活分享', '分享绿色生活小习惯,传递责任与温暖。', '9.90'),
|
||||
('志愿服务见闻', '交流公益与志愿服务见闻,弘扬正能量。', '12.90'),
|
||||
('亲子沟通小贴士', '交流亲子相处经验,友善互助(非医疗建议)。', '15.90'),
|
||||
('职场礼仪闲谈', '聊聊职场沟通礼仪与礼貌习惯,积极文明。', '15.90'),
|
||||
('节日祝福陪伴', '节日期间友好问候与陪伴聊天,传递祝福。', '12.90'),
|
||||
('睡前放松畅聊', '睡前轻松聊天,帮助放松,友好结束一天。', '15.90'),
|
||||
],
|
||||
]
|
||||
|
||||
BANNED_WORDS = (
|
||||
'代练', '代打', '上分', '上段', '包星', '定位赛', '代肝',
|
||||
'代刷', '代打手', '代练平台', '代练服务', '护航上分',
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '龙先生审核类型改为娱乐陪伴并向 lxs 写入正能量专区/商品(默认 dry-run)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||
parser.add_argument(
|
||||
'--i-confirm',
|
||||
type=str,
|
||||
default='',
|
||||
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list',
|
||||
action='store_true',
|
||||
help='只列出当前审核类型及 lxs 存量',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-rename-types',
|
||||
action='store_true',
|
||||
help='不修改全局审核类型名称,仅重建 lxs 专区/商品',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club
|
||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm') or '').strip()
|
||||
skip_rename = bool(options.get('skip_rename_types'))
|
||||
|
||||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||
if not club:
|
||||
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||
if NAME_HINT not in (club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:{ALLOWED_CLUB_ID} name={club.name!r} 不含「{NAME_HINT}」'
|
||||
)
|
||||
|
||||
audit_types = list(
|
||||
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||
)
|
||||
if len(audit_types) != EXPECTED_TYPE_COUNT:
|
||||
raise CommandError(
|
||||
f'安全拦截:审核类型数量应为 {EXPECTED_TYPE_COUNT},'
|
||||
f'当前为 {len(audit_types)}:'
|
||||
+ ', '.join(f'id={t.id}「{t.jieshao}」' for t in audit_types)
|
||||
)
|
||||
|
||||
leixing_ids = [t.id for t in audit_types]
|
||||
|
||||
def counts():
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).count()
|
||||
return z, p
|
||||
|
||||
if options.get('list'):
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'审核类型 → 计划改名;lxs 当前专区/商品:'
|
||||
))
|
||||
for i, t in enumerate(audit_types):
|
||||
z = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count()
|
||||
p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||
).count()
|
||||
self.stdout.write(
|
||||
f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」 '
|
||||
f'lxs zones={z} products={p}'
|
||||
)
|
||||
return
|
||||
|
||||
# 文案自检
|
||||
for zone_list in PRODUCTS_PER_ZONE:
|
||||
if len(zone_list) != 10:
|
||||
raise CommandError('内部错误:每个专区必须恰好 10 个商品')
|
||||
for title, intro, _price in zone_list:
|
||||
blob = title + intro
|
||||
for w in BANNED_WORDS:
|
||||
if w in blob:
|
||||
raise CommandError(f'文案含禁止词「{w}」: {title}')
|
||||
|
||||
for zones in ZONE_NAMES_BY_TYPE:
|
||||
if len(zones) != 2:
|
||||
raise CommandError('内部错误:每个类型必须恰好 2 个专区')
|
||||
|
||||
if apply and confirm != CONFIRM_TOKEN:
|
||||
raise CommandError(
|
||||
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||
f' python manage.py reset_lxs_shenhe_yule_peiban '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||
)
|
||||
|
||||
old_z, old_p = counts()
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} 龙先生审核 → 娱乐陪伴 =====\n'
|
||||
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||
f'将清空 lxs 审核专区={old_z} 商品={old_p},再写入 '
|
||||
f'{EXPECTED_TYPE_COUNT}类型×2专区×10商品=60\n'
|
||||
f'图标: {DEFAULT_AVATAR}\n'
|
||||
f'改名全局类型: {"否" if skip_rename else "是(影响所有俱乐部审核展示名)"}\n'
|
||||
))
|
||||
|
||||
self.stdout.write('—— 类型改名计划 ——')
|
||||
for i, t in enumerate(audit_types):
|
||||
self.stdout.write(f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」')
|
||||
|
||||
self.stdout.write('—— 专区/商品结构 ——')
|
||||
for i, tname in enumerate(TYPE_NAMES):
|
||||
for zn in ZONE_NAMES_BY_TYPE[i]:
|
||||
self.stdout.write(f' 「{tname}」/「{zn}」→ 10 个正能量商品')
|
||||
|
||||
self.stdout.write('—— 商品样例 ——')
|
||||
for title, intro, price in PRODUCTS_PER_ZONE[0][:3]:
|
||||
self.stdout.write(f' {title} ¥{price} | {intro}')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划。确认后:\n'
|
||||
f' python manage.py reset_lxs_shenhe_yule_peiban '
|
||||
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||
))
|
||||
return
|
||||
|
||||
stats = {
|
||||
'types_renamed': 0,
|
||||
'deleted_products': 0,
|
||||
'deleted_zones': 0,
|
||||
'zones_created': 0,
|
||||
'products_created': 0,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
if not skip_rename:
|
||||
for i, t in enumerate(audit_types):
|
||||
new_name = TYPE_NAMES[i]
|
||||
if (t.jieshao or '') != new_name:
|
||||
t.jieshao = new_name
|
||||
t.tupian_url = t.tupian_url or DEFAULT_AVATAR
|
||||
t.save()
|
||||
stats['types_renamed'] += 1
|
||||
|
||||
n_prod, _ = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
leixing_id__in=leixing_ids,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).delete()
|
||||
stats['deleted_products'] = n_prod
|
||||
stats['deleted_zones'] = n_zone
|
||||
|
||||
# 按类型顺序重建
|
||||
for i, lx in enumerate(audit_types):
|
||||
# 刷新名称(若已 rename)
|
||||
lx = ShangpinLeixing.query.filter(id=lx.id).first()
|
||||
zone_names = ZONE_NAMES_BY_TYPE[i]
|
||||
# 两个专区共用同一组 10 商品模板?用户要每个专区 10 个 —
|
||||
# 使用 PRODUCTS_PER_ZONE[i] 作为该类型两专区的商品池,
|
||||
# 第一专区用全部 10 个;第二专区标题加专区后缀避免同类型下标题撞车,
|
||||
# 或两专区用同一文案但标题带专区名区分。
|
||||
base_products = PRODUCTS_PER_ZONE[i]
|
||||
for zi, zname in enumerate(zone_names):
|
||||
zrow = ShangpinZhuanqu.query.create(
|
||||
mingzi=zname,
|
||||
leixing_id=lx.id,
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
tupian_url=DEFAULT_AVATAR,
|
||||
shenhezhuangtai=SHENHE,
|
||||
paixu=20 - zi * 10,
|
||||
shangjia_zhuangtai=True,
|
||||
fengjin_zhuangtai=True,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['zones_created'] += 1
|
||||
|
||||
for pi, (title, intro, price) in enumerate(base_products):
|
||||
# 同类型不同专区:标题加专区名,避免去重冲突且语义清晰
|
||||
full_title = f'{title}·{zname}'
|
||||
for w in BANNED_WORDS:
|
||||
if w in full_title or w in intro:
|
||||
raise CommandError(f'禁止词命中: {full_title}')
|
||||
Shangpin.query.create(
|
||||
biaoqian=full_title[:45],
|
||||
jiage=Decimal(price),
|
||||
kucun=99999,
|
||||
leixing_id=lx.id,
|
||||
zhuanqu_id=zrow.id,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=intro,
|
||||
xiadan_xuzhi=(
|
||||
'本服务为正向休闲陪伴与交流,请文明沟通。'
|
||||
'禁止任何代练、代打或不正当行为。'
|
||||
),
|
||||
guize_tupian='',
|
||||
tupian_url=DEFAULT_AVATAR,
|
||||
yaoqiuleixing=lx.yaoqiuleixing,
|
||||
huiyuan_id=lx.huiyuan_id,
|
||||
yongjin=lx.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=False,
|
||||
ewai_dashou_fencheng=None,
|
||||
paixu=100 - pi,
|
||||
shenhezhuangtai=SHENHE,
|
||||
shangjia_zhuangtai=True,
|
||||
fengjin_zhuangtai=True,
|
||||
shenhe_zhuangtai=True,
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
shi_zhuanpan=False,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
if stats['zones_created'] != 6 or stats['products_created'] != 60:
|
||||
raise CommandError(
|
||||
f'数量异常: zones={stats["zones_created"]} products={stats["products_created"]},'
|
||||
f'期望 6/60,已回滚'
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n完成: {stats}\n'
|
||||
f'均为审核专用(shenhezhuangtai=2),仅审核模式可见;文案已避开代练代打。'
|
||||
))
|
||||
80
jituan/management/commands/seed_club_lsx.py
Normal file
80
jituan/management/commands/seed_club_lsx.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""一键初始化龙先生电竞(lsx)俱乐部(默认不批量给所有客服加任职)。"""
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from jituan.models import Club
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '创建龙先生电竞俱乐部(lsx);任职请单独 seed_admin_assignments --phone ...'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--wx-appid', default='wx7ff90e9d024fcdb8', help='龙先生电竞小程序 appid',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--assignments',
|
||||
action='store_true',
|
||||
help='同时给 --phone 指定客服加 lsx 任职(需配合 --phone)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--phone', type=str, default='', help='与 --assignments 联用:客服手机号',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
wx_appid = (options.get('wx_appid') or '').strip()
|
||||
club_id = 'lsx'
|
||||
name = '龙先生电竞'
|
||||
|
||||
# 俱乐部应由后台「俱乐部配置」创建并上传证书;本命令不再自动 create_club
|
||||
club = Club.query.filter(club_id=club_id).first()
|
||||
if club is None:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f'俱乐部 {club_id} 不存在。请先在后台创建并上传支付 key,'
|
||||
f'不要用脚本自动建俱乐部(避免生成无证书空配置)。'
|
||||
))
|
||||
return
|
||||
|
||||
if wx_appid and (club.wx_appid or '') != wx_appid:
|
||||
club.wx_appid = wx_appid
|
||||
club.save(update_fields=['wx_appid'])
|
||||
self.stdout.write(f'已更新 {club_id}.wx_appid = {wx_appid}')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'俱乐部 {club_id} 已存在(name={club.name}),跳过创建。'
|
||||
))
|
||||
|
||||
if options.get('assignments'):
|
||||
phone = (options.get('phone') or '').strip()
|
||||
if not phone:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'未指定 --phone,跳过任职。示例:'
|
||||
'python manage.py seed_club_lsx --assignments --phone 你的手机号'
|
||||
))
|
||||
else:
|
||||
call_command(
|
||||
'seed_admin_assignments',
|
||||
club_id=club_id,
|
||||
phone=phone,
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
'未加任职。需要时在后台「数据范围」配置,或:'
|
||||
'python manage.py seed_admin_assignments --phone 手机号 --club-id lsx'
|
||||
)
|
||||
|
||||
keyed = any([
|
||||
(club.pay_key_path or '').strip(),
|
||||
(club.pay_cert_path or '').strip(),
|
||||
(club.private_key_path or '').strip(),
|
||||
(club.platform_cert_dir or '').strip(),
|
||||
])
|
||||
if not keyed:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'{club_id} 当前无证书路径,请在后台上传 key;'
|
||||
f'若存在重复空俱乐部可用:python manage.py cleanup_lsx_empty_club'
|
||||
))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'龙先生电竞 {club_id} 检查完成。'
|
||||
))
|
||||
153
jituan/management/commands/seed_club_withdraw_audit_perm.py
Normal file
153
jituan/management/commands/seed_club_withdraw_audit_perm.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""确保提现审核权限码 005bb,并按俱乐部挂到指定角色(可重复执行)。
|
||||
|
||||
权限码全局共用;隔离靠 Role.ClubID + 请求头俱乐部。
|
||||
集团高管仍可跨俱乐部;俱乐部管理员仅本俱乐部。
|
||||
|
||||
用法示例:
|
||||
python manage.py seed_club_withdraw_audit_perm
|
||||
python manage.py seed_club_withdraw_audit_perm --club-id lxs --role-name 管理员
|
||||
python manage.py seed_club_withdraw_audit_perm --all-clubs --role-name 管理员
|
||||
python manage.py seed_club_withdraw_audit_perm --all-clubs --role-name 管理员 --dry-run
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from gvsdsdk.models import Permission, Role, RolePermission
|
||||
|
||||
from jituan.models import Club
|
||||
|
||||
WITHDRAW_AUDIT_PERM = '005bb'
|
||||
WITHDRAW_AUDIT_NAME = '提现审核(按俱乐部)'
|
||||
WITHDRAW_AUDIT_DESC = (
|
||||
'审核本俱乐部提现申请(同意/拒绝/自动打款待收款)。'
|
||||
'跨俱乐部仅集团高管可操作;须配合后台角色 ClubID 与数据范围任职。'
|
||||
)
|
||||
WITHDRAW_AUDIT_CATEGORY = '提现管理'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '写入 005bb 提现审核权限码,并按俱乐部挂到角色'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--club-id', default='', help='仅处理该俱乐部角色(如 lxs / xq)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all-clubs', action='store_true', help='处理全部启用中的俱乐部',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--role-name', default='',
|
||||
help='角色名(如「管理员」);为空则只确保权限码存在,不挂角色',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run', action='store_true', help='只打印不写入',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
all_clubs = options.get('all_clubs')
|
||||
role_name = (options.get('role_name') or '').strip()
|
||||
|
||||
sample = Permission.objects.filter(PermStatus=1).first()
|
||||
tenant_uuid = sample.TenantUUID if sample else uuid.UUID(
|
||||
'00000000-0000-0000-0000-000000000001'
|
||||
).bytes
|
||||
|
||||
perm = Permission.objects.filter(PermCode=WITHDRAW_AUDIT_PERM).first()
|
||||
if perm:
|
||||
self.stdout.write(f' 权限码已存在: {WITHDRAW_AUDIT_PERM}')
|
||||
if not dry_run and (perm.PermName != WITHDRAW_AUDIT_NAME or not perm.PermDesc):
|
||||
perm.PermName = WITHDRAW_AUDIT_NAME
|
||||
perm.PermDesc = WITHDRAW_AUDIT_DESC
|
||||
perm.PermCategory = WITHDRAW_AUDIT_CATEGORY
|
||||
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
|
||||
self.stdout.write(self.style.SUCCESS(' 已更新 005bb 名称/说明'))
|
||||
else:
|
||||
if dry_run:
|
||||
self.stdout.write(f'would create perm {WITHDRAW_AUDIT_PERM}')
|
||||
else:
|
||||
perm = Permission.objects.create(
|
||||
PermUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=tenant_uuid,
|
||||
PermCode=WITHDRAW_AUDIT_PERM,
|
||||
PermName=WITHDRAW_AUDIT_NAME,
|
||||
PermDesc=WITHDRAW_AUDIT_DESC,
|
||||
PermCategory=WITHDRAW_AUDIT_CATEGORY,
|
||||
PermStatus=1,
|
||||
CreateTime=timezone.now(),
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f' 新增权限: {WITHDRAW_AUDIT_PERM}'))
|
||||
|
||||
if not role_name:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
'未指定 --role-name:仅确保权限码。'
|
||||
'给俱乐部挂权请加:--club-id lxs --role-name 管理员'
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if not club_id and not all_clubs:
|
||||
raise CommandError('挂角色时请指定 --club-id 或 --all-clubs')
|
||||
|
||||
if all_clubs:
|
||||
club_ids = list(
|
||||
Club.query.filter(status=1).values_list('club_id', flat=True)
|
||||
)
|
||||
else:
|
||||
if not Club.query.filter(club_id=club_id, status=1).exists():
|
||||
raise CommandError(f'俱乐部不存在或未启用: {club_id}')
|
||||
club_ids = [club_id]
|
||||
|
||||
if not perm and dry_run:
|
||||
self.stdout.write('dry-run: skip role binding (perm not loaded)')
|
||||
return
|
||||
|
||||
linked = 0
|
||||
missing_roles = 0
|
||||
with transaction.atomic():
|
||||
for cid in club_ids:
|
||||
role = Role.objects.filter(
|
||||
RoleName=role_name, ClubID=cid, RoleStatus=1,
|
||||
).first()
|
||||
if not role:
|
||||
missing_roles += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f' [{cid}] 无角色「{role_name}」,跳过')
|
||||
)
|
||||
continue
|
||||
exists = RolePermission.objects.filter(
|
||||
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
|
||||
).exists()
|
||||
if exists:
|
||||
self.stdout.write(f' [{cid}] 角色「{role_name}」已有 {WITHDRAW_AUDIT_PERM}')
|
||||
continue
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f'would link {WITHDRAW_AUDIT_PERM} -> [{cid}] {role_name}'
|
||||
)
|
||||
linked += 1
|
||||
continue
|
||||
RolePermission.objects.create(
|
||||
RolePermUUID=uuid.uuid4().bytes,
|
||||
RoleUUID=role.RoleUUID,
|
||||
PermUUID=perm.PermUUID,
|
||||
CreateTime=timezone.now(),
|
||||
)
|
||||
linked += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f' [{cid}] 已给角色「{role_name}」挂上 {WITHDRAW_AUDIT_PERM}'
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'完成:挂权 {linked},缺角色 {missing_roles}。'
|
||||
f'另请确认该账号在「数据范围配置」有对应俱乐部 SINGLE_CLUB 任职。'
|
||||
)
|
||||
)
|
||||
@@ -7,6 +7,7 @@ from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
from jituan.services.wechat_pay import XQ_WX_PAY, WX_V3_CERT_SERIAL_NO_OVERRIDE
|
||||
from jituan.models import (
|
||||
Club,
|
||||
ClubHuiyuanPrice,
|
||||
@@ -41,10 +42,11 @@ class Command(BaseCommand):
|
||||
'status': 1,
|
||||
'wx_appid': getattr(settings, 'WEIXIN_APPID', ''),
|
||||
'wx_secret': getattr(settings, 'WEIXIN_SECRET', ''),
|
||||
'mch_id': getattr(settings, 'WEIXIN_MCHID', '') or wx_v3.get('MCHID', ''),
|
||||
'pay_app_id': wx_v3.get('APPID', '') or getattr(settings, 'WEIXIN_APPID', ''),
|
||||
'api_v3_key': wx_v3.get('API_V3_KEY', ''),
|
||||
'cert_serial_no': wx_v3.get('CERT_SERIAL_NO', ''),
|
||||
'mch_id': XQ_WX_PAY['mch_id'],
|
||||
'pay_app_id': XQ_WX_PAY['wx_appid'],
|
||||
'mch_key': XQ_WX_PAY['mch_key'],
|
||||
'api_v3_key': XQ_WX_PAY['api_v3_key'],
|
||||
'cert_serial_no': WX_V3_CERT_SERIAL_NO_OVERRIDE,
|
||||
'private_key_path': wx_v3.get('PRIVATE_KEY_PATH', ''),
|
||||
'platform_cert_dir': wx_v3.get('PLATFORM_CERT_DIR', ''),
|
||||
'config_json': {
|
||||
@@ -75,7 +77,7 @@ class Command(BaseCommand):
|
||||
if v and not getattr(club, k, None):
|
||||
setattr(club, k, v)
|
||||
# V3 字段允许用 settings 补全(证书路径等)
|
||||
for field in ('api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'pay_app_id'):
|
||||
for field in ('mch_key', 'api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'pay_app_id'):
|
||||
val = defaults.get(field)
|
||||
if val and not getattr(club, field, None):
|
||||
setattr(club, field, val)
|
||||
|
||||
125
jituan/management/commands/seed_fund_freeze_perm.py
Normal file
125
jituan/management/commands/seed_fund_freeze_perm.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""写入资金冻结相关权限码,并可按俱乐部挂到角色(可重复执行)。
|
||||
|
||||
权限码:
|
||||
djcfg001 资金冻结规则配置
|
||||
djled001 资金冻结明细/手动解冻
|
||||
djrisk001 罚款投诉风险指标
|
||||
|
||||
用法:
|
||||
python manage.py seed_fund_freeze_perm
|
||||
python manage.py seed_fund_freeze_perm --club-id lxs --role-name 管理员
|
||||
python manage.py seed_fund_freeze_perm --all-clubs --role-name 管理员 --dry-run
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from gvsdsdk.models import Permission, Role, RolePermission
|
||||
|
||||
from jituan.models import Club
|
||||
|
||||
PERMS = [
|
||||
('djcfg001', '资金冻结规则配置', '配置本俱乐部各角色冻结比例/分期/解冻条件', '资金冻结'),
|
||||
('djled001', '资金冻结明细', '查看冻结流水并手动解冻', '资金冻结'),
|
||||
('djrisk001', '罚款投诉风险指标', '查看罚款率/投诉率等解冻相关指标', '资金冻结'),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '写入资金冻结权限码,并按俱乐部挂到角色'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--club-id', default='', help='仅处理该俱乐部')
|
||||
parser.add_argument('--all-clubs', action='store_true')
|
||||
parser.add_argument('--role-name', default='', help='角色名;空则只确保权限码')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
all_clubs = options.get('all_clubs')
|
||||
role_name = (options.get('role_name') or '').strip()
|
||||
|
||||
sample = Permission.objects.filter(PermStatus=1).first()
|
||||
tenant_uuid = sample.TenantUUID if sample else uuid.UUID(
|
||||
'00000000-0000-0000-0000-000000000001'
|
||||
).bytes
|
||||
|
||||
perm_objs = []
|
||||
for code, name, desc, cat in PERMS:
|
||||
perm = Permission.objects.filter(PermCode=code).first()
|
||||
if perm:
|
||||
self.stdout.write(f' 权限码已存在: {code}')
|
||||
if not dry_run:
|
||||
changed = False
|
||||
if perm.PermName != name:
|
||||
perm.PermName = name
|
||||
changed = True
|
||||
if (perm.PermDesc or '') != desc:
|
||||
perm.PermDesc = desc
|
||||
changed = True
|
||||
if (perm.PermCategory or '') != cat:
|
||||
perm.PermCategory = cat
|
||||
changed = True
|
||||
if changed:
|
||||
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
|
||||
else:
|
||||
if dry_run:
|
||||
self.stdout.write(f'would create perm {code}')
|
||||
else:
|
||||
perm = Permission.objects.create(
|
||||
PermUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=tenant_uuid,
|
||||
PermCode=code,
|
||||
PermName=name,
|
||||
PermDesc=desc,
|
||||
PermCategory=cat,
|
||||
PermStatus=1,
|
||||
CreateTime=timezone.now(),
|
||||
UpdateTime=timezone.now(),
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f' 已创建权限码: {code}'))
|
||||
if perm:
|
||||
perm_objs.append(perm)
|
||||
|
||||
if not role_name:
|
||||
self.stdout.write('未指定 --role-name,跳过挂角色')
|
||||
return
|
||||
|
||||
clubs = []
|
||||
if all_clubs:
|
||||
clubs = list(Club.query.filter(status=1).values_list('club_id', flat=True))
|
||||
elif club_id:
|
||||
clubs = [club_id]
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('请指定 --club-id 或 --all-clubs'))
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
for cid in clubs:
|
||||
roles = Role.objects.filter(RoleName=role_name, ClubID=cid, RoleStatus=1)
|
||||
if not roles.exists():
|
||||
# 兼容无 ClubID 字段旧数据:仅精确 club 时再试全局同名
|
||||
roles = Role.objects.filter(RoleName=role_name, RoleStatus=1)
|
||||
roles = [r for r in roles if (getattr(r, 'ClubID', None) or '') == cid]
|
||||
for role in roles:
|
||||
for perm in perm_objs:
|
||||
exists = RolePermission.objects.filter(
|
||||
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
|
||||
).exists()
|
||||
if exists:
|
||||
self.stdout.write(f' 已挂: {cid} {role_name} <- {perm.PermCode}')
|
||||
continue
|
||||
if dry_run:
|
||||
self.stdout.write(f'would link {cid} {role_name} <- {perm.PermCode}')
|
||||
else:
|
||||
RolePermission.objects.create(
|
||||
RoleUUID=role.RoleUUID,
|
||||
PermUUID=perm.PermUUID,
|
||||
CreateTime=timezone.now(),
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f' 已挂: {cid} {role_name} <- {perm.PermCode}'
|
||||
))
|
||||
@@ -26,12 +26,60 @@ EXTRA_PERMISSIONS = [
|
||||
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
|
||||
'会话管理',
|
||||
),
|
||||
(
|
||||
'gdck666',
|
||||
'投诉工单查看',
|
||||
'查看用户投诉工单列表与详情',
|
||||
'投诉工单',
|
||||
),
|
||||
(
|
||||
'gdcl666',
|
||||
'投诉工单处理',
|
||||
'处理/关闭用户投诉工单',
|
||||
'投诉工单',
|
||||
),
|
||||
(
|
||||
'sfbq666',
|
||||
'身份装饰标签管理',
|
||||
'管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)',
|
||||
'用户管理',
|
||||
),
|
||||
(
|
||||
'005bb',
|
||||
'提现审核(按俱乐部)',
|
||||
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
|
||||
'提现管理',
|
||||
),
|
||||
(
|
||||
'djcfg001',
|
||||
'资金冻结规则配置',
|
||||
'配置本俱乐部各角色冻结比例/分期/解冻条件',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'djled001',
|
||||
'资金冻结明细',
|
||||
'查看冻结流水并手动解冻',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'djrisk001',
|
||||
'罚款投诉风险指标',
|
||||
'查看罚款率/投诉率等解冻相关指标',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'xycfg001',
|
||||
'角色协议配置',
|
||||
'配置打手/管事/组长用户协议并发布版本',
|
||||
'小程序配置',
|
||||
),
|
||||
(
|
||||
'002ad',
|
||||
'修改抢单要求',
|
||||
'修改平台订单/本店商品的抢单要求类型(会员/押金门槛)。须显式绑定;本店超管 000001 不会自动拥有。',
|
||||
'订单管理',
|
||||
),
|
||||
# ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ----------
|
||||
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
|
||||
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),
|
||||
|
||||
@@ -37,3 +37,12 @@ class Command(BaseCommand):
|
||||
call_command('seed_gvsdsdk_permissions')
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f'seed_gvsdsdk_permissions 跳过: {e}'))
|
||||
try:
|
||||
from django.core.management import call_command
|
||||
call_command('seed_fund_freeze_perm')
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f'seed_fund_freeze_perm 跳过: {e}'))
|
||||
try:
|
||||
call_command('seed_role_agreement_perm')
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f'seed_role_agreement_perm 跳过: {e}'))
|
||||
|
||||
218
jituan/management/commands/seed_lsx_invite_data.py
Normal file
218
jituan/management/commands/seed_lsx_invite_data.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""龙先生电竞邀请链种子:插入组长+管事(含邀请码)。
|
||||
|
||||
默认 club_id=lsx;若找不到会列出库里全部俱乐部,便于核对真实 club_id。
|
||||
可用:
|
||||
python manage.py seed_lsx_invite_data
|
||||
python manage.py seed_lsx_invite_data --club-id 你的真实id
|
||||
python manage.py seed_lsx_invite_data --list-clubs
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from jituan.models import Club, ClubHuiyuanPrice
|
||||
from users.business_models import User
|
||||
from users.models import UserBoss, UserGuanshi, UserZuzhang
|
||||
from utils.invitationcode_utils import CreateInvitationCode
|
||||
|
||||
DEFAULT_CLUB_ID = 'lsx'
|
||||
WX_APPID = 'wx7ff90e9d024fcdb8'
|
||||
|
||||
# 固定种子账号(可重复执行,幂等;UID/手机避开 xzj 的 9900001/2)
|
||||
SEED_ZUZHANG = {
|
||||
'UserUID': '9910001',
|
||||
'OpenID': 'lsx_seed_openid_zuzhang_v1',
|
||||
'Phone': '19910000001',
|
||||
'UserName': 'lsx_seed_zuzhang',
|
||||
'nickname': '龙先生种子组长',
|
||||
}
|
||||
SEED_GUANSHI = {
|
||||
'UserUID': '9910002',
|
||||
'OpenID': 'lsx_seed_openid_guanshi_v1',
|
||||
'Phone': '19910000002',
|
||||
'UserName': 'lsx_seed_guanshi',
|
||||
'nickname': '龙先生种子管事',
|
||||
}
|
||||
|
||||
|
||||
def _list_clubs(stdout, style):
|
||||
clubs = list(Club.query.order_by('sort_order', 'club_id'))
|
||||
stdout.write(style.SUCCESS('=' * 60))
|
||||
stdout.write(f'库里俱乐部共 {len(clubs)} 个:')
|
||||
for c in clubs:
|
||||
stdout.write('-' * 40)
|
||||
stdout.write(f' club_id={c.club_id!r} name={c.name!r} status={c.status}')
|
||||
stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
|
||||
stdout.write(f' mch_id={c.mch_id or "(空)"} withdraw_mch_id={getattr(c, "withdraw_mch_id", "") or "(空)"}')
|
||||
stdout.write(
|
||||
f' pay_key_path={c.pay_key_path or "(空)"}'
|
||||
)
|
||||
stdout.write(style.SUCCESS('=' * 60))
|
||||
return clubs
|
||||
|
||||
|
||||
def _resolve_club(club_id: str, stdout, style):
|
||||
"""优先精确 club_id;其次 wx_appid;再名称含「龙先生」。"""
|
||||
cid = (club_id or DEFAULT_CLUB_ID).strip()
|
||||
club = Club.query.filter(club_id=cid).first()
|
||||
if club:
|
||||
return club
|
||||
|
||||
stdout.write(style.WARNING(f'精确 club_id={cid!r} 不存在,尝试按 AppID/名称匹配…'))
|
||||
|
||||
by_app = Club.query.filter(wx_appid=WX_APPID).first()
|
||||
if by_app:
|
||||
stdout.write(style.WARNING(
|
||||
f'按 wx_appid={WX_APPID} 命中: club_id={by_app.club_id!r} name={by_app.name!r}'
|
||||
))
|
||||
return by_app
|
||||
|
||||
by_name = Club.query.filter(name__icontains='龙先生').order_by('club_id')
|
||||
names = list(by_name)
|
||||
if len(names) == 1:
|
||||
c = names[0]
|
||||
stdout.write(style.WARNING(
|
||||
f'按名称「龙先生」命中唯一: club_id={c.club_id!r} name={c.name!r}'
|
||||
))
|
||||
return c
|
||||
if len(names) > 1:
|
||||
stdout.write(style.ERROR('名称含「龙先生」的俱乐部有多条,请用 --club-id 指定:'))
|
||||
for c in names:
|
||||
stdout.write(f' - {c.club_id!r} / {c.name!r}')
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_user(meta, club_id):
|
||||
user = User.query.filter(UserUID=meta['UserUID']).first()
|
||||
if not user:
|
||||
user = User.query.filter(OpenID=meta['OpenID']).first()
|
||||
if user:
|
||||
if (user.ClubID or '') != club_id:
|
||||
user.ClubID = club_id
|
||||
user.save(update_fields=['ClubID'])
|
||||
return user, False
|
||||
|
||||
user = User.query.create(
|
||||
UserUID=meta['UserUID'],
|
||||
UserName=meta['UserName'],
|
||||
OpenID=meta['OpenID'],
|
||||
Phone=meta['Phone'],
|
||||
ClubID=club_id,
|
||||
)
|
||||
UserBoss.query.create(user=user, nickname=meta['nickname'])
|
||||
return user, True
|
||||
|
||||
|
||||
def _ensure_zuzhang(user):
|
||||
try:
|
||||
return user.ZuzhangProfile, False
|
||||
except UserZuzhang.DoesNotExist:
|
||||
pass
|
||||
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||
z = UserZuzhang.query.create(
|
||||
user=user,
|
||||
yaoqingma=yaoqingma,
|
||||
zhuangtai=1,
|
||||
)
|
||||
return z, True
|
||||
|
||||
|
||||
def _ensure_guanshi(user, zuzhang_uid):
|
||||
try:
|
||||
return user.GuanshiProfile, False
|
||||
except UserGuanshi.DoesNotExist:
|
||||
pass
|
||||
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||
g = UserGuanshi.query.create(
|
||||
user=user,
|
||||
yaoqingma=yaoqingma,
|
||||
yaoqingren=zuzhang_uid,
|
||||
zhuangtai=1,
|
||||
)
|
||||
return g, True
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '插入龙先生电竞种子组长+管事(含邀请码);找不到俱乐部时会列出全部 club'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--club-id',
|
||||
type=str,
|
||||
default=DEFAULT_CLUB_ID,
|
||||
help='目标俱乐部 ID(默认 lsx;若后台建的不是 lsx 请改这里)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list-clubs',
|
||||
action='store_true',
|
||||
help='只列出库里所有俱乐部,不写入种子',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force-new-codes',
|
||||
action='store_true',
|
||||
help='已存在时重新生成邀请码(慎用)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options.get('list_clubs'):
|
||||
_list_clubs(self.stdout, self.style)
|
||||
return
|
||||
|
||||
want_id = (options.get('club_id') or DEFAULT_CLUB_ID).strip()
|
||||
club = _resolve_club(want_id, self.stdout, self.style)
|
||||
if club is None:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f'找不到可用俱乐部(请求 club_id={want_id!r})。'
|
||||
))
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'可能原因:\n'
|
||||
' 1) 后台创建时 club_id 不是 lsx(例如写成了别的拼音)\n'
|
||||
' 2) 之前执行过 delete_club_by_mch_id --apply,把 lsx 主表删掉了\n'
|
||||
'请先看下面列表,用真实 club_id 再跑:\n'
|
||||
' python manage.py seed_lsx_invite_data --list-clubs\n'
|
||||
' python manage.py seed_lsx_invite_data --club-id 真实id'
|
||||
))
|
||||
_list_clubs(self.stdout, self.style)
|
||||
raise CommandError('俱乐部不存在,已中止(未写入任何种子)')
|
||||
|
||||
club_id = club.club_id
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'将写入种子到俱乐部: club_id={club_id!r} name={club.name!r} wx_appid={club.wx_appid or "(空)"}'
|
||||
))
|
||||
|
||||
with transaction.atomic():
|
||||
zz_user, zz_new = _ensure_user(SEED_ZUZHANG, club_id)
|
||||
zz_profile, zz_profile_new = _ensure_zuzhang(zz_user)
|
||||
if options.get('force_new_codes') and not zz_profile_new:
|
||||
zz_profile.yaoqingma = CreateInvitationCode(str(zz_user.UserUID))
|
||||
zz_profile.save(update_fields=['yaoqingma'])
|
||||
|
||||
gs_user, gs_new = _ensure_user(SEED_GUANSHI, club_id)
|
||||
gs_profile, gs_profile_new = _ensure_guanshi(gs_user, zz_user.UserUID)
|
||||
if options.get('force_new_codes') and not gs_profile_new:
|
||||
gs_profile.yaoqingma = CreateInvitationCode(str(gs_user.UserUID))
|
||||
gs_profile.save(update_fields=['yaoqingma'])
|
||||
|
||||
price_cnt = ClubHuiyuanPrice.query.filter(club_id=club_id).count()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(self.style.SUCCESS(f'龙先生电竞邀请链种子已就绪(club_id={club_id})'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(f'club_huiyuan_price 条数: {price_cnt}')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('【组长】管事注册用这个邀请码(guanshizhuce):')
|
||||
self.stdout.write(f' UserUID={zz_user.UserUID} Phone={SEED_ZUZHANG["Phone"]}')
|
||||
self.stdout.write(f' 组长邀请码 inviteCode = {zz_profile.yaoqingma}')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('【管事】打手/接单员注册用这个邀请码(dashouzhuce):')
|
||||
self.stdout.write(f' UserUID={gs_user.UserUID} Phone={SEED_GUANSHI["Phone"]}')
|
||||
self.stdout.write(f' 管事邀请码 inviteCode = {gs_profile.yaoqingma}')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('商家:后台「商家管理 → 添加商家」按用户UID开通')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('新建: 组长=%s 管事=%s' % (
|
||||
'是' if zz_new or zz_profile_new else '否(已存在)',
|
||||
'是' if gs_new or gs_profile_new else '否(已存在)',
|
||||
))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
311
jituan/management/commands/seed_lxs_shenhe_huhang_catalog.py
Normal file
311
jituan/management/commands/seed_lxs_shenhe_huhang_catalog.py
Normal file
@@ -0,0 +1,311 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
为龙先生电竞(lxs)写入审核专用(shenhezhuangtai=2)「游戏护航/陪伴」公共商品目录。
|
||||
|
||||
范围:
|
||||
- 仅 club_id=lxs(名称须含「龙先生」)
|
||||
- 全局审核类型:取 ShangpinLeixing 中 shenhezhuangtai=2 的第一条;若无则创建「娱乐护航」
|
||||
- 创建若干审核专区;30 个商品主要挂在第一个专区
|
||||
- 商品图统一用默认头像相对路径 a_long/morentouxiang.jpg
|
||||
- 文案仅正向「护航/陪伴/开黑」,绝不出现代练、代打、上分等
|
||||
|
||||
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品。
|
||||
|
||||
用法:
|
||||
python manage.py seed_lxs_shenhe_huhang_catalog
|
||||
python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
ALLOWED_CLUB_ID = 'lxs'
|
||||
NAME_HINT = '龙先生'
|
||||
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
|
||||
SHENHE = 2 # 审核专用
|
||||
|
||||
# 专区(审核版)
|
||||
ZONE_DEFS = [
|
||||
{'mingzi': '休闲开黑', 'paixu': 30},
|
||||
{'mingzi': '语音陪伴', 'paixu': 20},
|
||||
{'mingzi': '团队协作', 'paixu': 10},
|
||||
]
|
||||
|
||||
# 30 个正向护航/陪伴向商品(标题 + 介绍 + 价格),禁止代练代打语义
|
||||
PRODUCT_DEFS = [
|
||||
('王者荣耀·休闲开黑陪伴', '友好组队开黑,轻松娱乐,语音交流,快乐优先。', '19.90'),
|
||||
('和平精英·小队同行', '休闲模式组队畅玩,互相配合,体验团队乐趣。', '19.90'),
|
||||
('原神·探索同行', '一起探索地图与活动,聊天解谜,轻松愉快。', '29.90'),
|
||||
('星穹铁道·剧情陪伴', '陪你体验剧情与活动关卡,节奏友好,交流顺畅。', '29.90'),
|
||||
('无畏契约·娱乐五排', '娱乐局友好开黑,沟通协作,享受对局过程。', '25.90'),
|
||||
('英雄联盟·娱乐双排', '休闲娱乐局陪伴,友好沟通,重在开心。', '25.90'),
|
||||
('永劫无间·双人协作', '双人娱乐局同行,互相支援,体验动作乐趣。', '22.90'),
|
||||
('三角洲行动·小队协作', '小队任务友好配合,沟通顺畅,快乐完成目标。', '29.90'),
|
||||
('蛋仔派对·欢乐同乐', '休闲派对模式一起玩,轻松互动,气氛友好。', '15.90'),
|
||||
('逆水寒·社交同游', '一起游历风景与活动,聊天社交,轻松惬意。', '29.90'),
|
||||
('鸣潮·旅途陪伴', '结伴体验开放世界内容,节奏舒适,友好交流。', '29.90'),
|
||||
('绝区零·日常同行', '日常内容一起体验,互相提醒,轻松完成。', '22.90'),
|
||||
('崩坏3·活动陪伴', '活动关卡友好同行,沟通配合,享受剧情。', '19.90'),
|
||||
('命运冠位指定·活动同行', '活动副本休闲体验,节奏友好,互相鼓励。', '19.90'),
|
||||
('明日方舟·关卡陪伴', '关卡推图友好协助,讲解思路,轻松通关体验。', '22.90'),
|
||||
('第五人格·娱乐开黑', '娱乐模式组队,友好沟通,享受对抗乐趣。', '19.90'),
|
||||
('CS2·休闲约战', '休闲模式友好对局,语音交流,重在体验。', '25.90'),
|
||||
('Dota2·娱乐组队', '娱乐局结伴开黑,沟通协作,快乐优先。', '25.90'),
|
||||
('炉石传说·休闲对局', '休闲模式一起玩,交流构筑心得,气氛轻松。', '15.90'),
|
||||
('金铲铲之战·娱乐对局', '娱乐模式畅玩,聊天互动,轻松愉快。', '15.90'),
|
||||
('穿越火线·休闲开黑', '休闲模式组队,友好沟通,享受射击乐趣。', '19.90'),
|
||||
('CFHD·娱乐同行', '娱乐局结伴,语音交流,过程友好。', '19.90'),
|
||||
('NBA2K·友谊赛陪伴', '友谊赛模式一起打,轻松互动,享受比赛。', '22.90'),
|
||||
('FIFA·友谊赛同行', '友谊赛友好对战,交流心得,气氛轻松。', '22.90'),
|
||||
('我的世界·建造同行', '一起建造与探索,创意交流,氛围友好。', '19.90'),
|
||||
('幻兽帕鲁·合作体验', '合作模式结伴体验,互相帮助,轻松有趣。', '19.90'),
|
||||
('双人成行·合作通关', '合作解谜一起玩,沟通配合,欢乐通关。', '29.90'),
|
||||
('It Takes Two·双人协作', '双人关卡友好协作,互动解谜,享受过程。', '29.90'),
|
||||
('斯普拉遁·休闲对战', '休闲对战结伴,轻松交流,享受涂色乐趣。', '15.90'),
|
||||
('动物森友会·岛上同游', '一起逛岛、装饰与活动,聊天放松,氛围温馨。', '19.90'),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '龙先生(lxs)审核版护航商品种子(默认 dry-run)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||
parser.add_argument(
|
||||
'--i-confirm-club',
|
||||
type=str,
|
||||
default='',
|
||||
help='必须等于 lxs 才允许 --apply',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--club-id',
|
||||
type=str,
|
||||
default=ALLOWED_CLUB_ID,
|
||||
help='仅允许 lxs',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from jituan.models import Club
|
||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
apply = bool(options.get('apply'))
|
||||
confirm = (options.get('i_confirm_club') or '').strip()
|
||||
|
||||
if club_id != ALLOWED_CLUB_ID:
|
||||
raise CommandError(
|
||||
f'安全拦截:只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
|
||||
)
|
||||
|
||||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||
if not club:
|
||||
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||
if NAME_HINT not in (club.name or ''):
|
||||
raise CommandError(
|
||||
f'安全拦截:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{NAME_HINT}」'
|
||||
)
|
||||
|
||||
if apply and confirm != ALLOWED_CLUB_ID:
|
||||
raise CommandError(
|
||||
'安全拦截:--apply 必须带 --i-confirm-club=lxs\n'
|
||||
' python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs'
|
||||
)
|
||||
|
||||
# 审核类型:第一条;没有则计划创建
|
||||
leixing = (
|
||||
ShangpinLeixing.query
|
||||
.filter(shenhezhuangtai=SHENHE)
|
||||
.order_by('id')
|
||||
.first()
|
||||
)
|
||||
leixing_action = 'REUSE'
|
||||
if not leixing:
|
||||
leixing_action = 'CREATE'
|
||||
leixing = None
|
||||
|
||||
# 专区计划
|
||||
zone_plans = []
|
||||
for zdef in ZONE_DEFS:
|
||||
exist = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
mingzi=zdef['mingzi'],
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist and leixing and exist.leixing_id == leixing.id:
|
||||
zone_plans.append({'def': zdef, 'action': 'SKIP', 'row': exist})
|
||||
elif exist and not leixing:
|
||||
# 类型尚未创建,名称撞车则复用该专区(apply 时再绑类型)
|
||||
zone_plans.append({'def': zdef, 'action': 'SKIP', 'row': exist})
|
||||
else:
|
||||
zone_plans.append({'def': zdef, 'action': 'CREATE', 'row': None})
|
||||
|
||||
# 商品计划:全部挂第一个专区
|
||||
first_zone_name = ZONE_DEFS[0]['mingzi']
|
||||
product_plans = []
|
||||
for title, intro, price in PRODUCT_DEFS:
|
||||
# 预检:同 club + 同标题 + 审核态是否已有
|
||||
exist_p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
biaoqian=title,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
product_plans.append({
|
||||
'title': title,
|
||||
'intro': intro,
|
||||
'price': price,
|
||||
'action': 'SKIP' if exist_p else 'CREATE',
|
||||
'exist_id': exist_p.id if exist_p else None,
|
||||
})
|
||||
|
||||
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f'\n===== {mode} 龙先生审核版护航商品 =====\n'
|
||||
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||
f'审核类型: {leixing_action} '
|
||||
f'{("id="+str(leixing.id)+" name="+repr(leixing.jieshao)) if leixing else "将创建「娱乐护航」"}\n'
|
||||
f'默认图标: {DEFAULT_AVATAR}\n'
|
||||
f'专区计划: create={sum(1 for z in zone_plans if z["action"]=="CREATE")} '
|
||||
f'skip={sum(1 for z in zone_plans if z["action"]=="SKIP")}\n'
|
||||
f'商品计划: create={sum(1 for p in product_plans if p["action"]=="CREATE")} '
|
||||
f'skip={sum(1 for p in product_plans if p["action"]=="SKIP")} '
|
||||
f'(共{len(product_plans)},挂专区「{first_zone_name}」)\n'
|
||||
))
|
||||
|
||||
self.stdout.write('—— 专区 ——')
|
||||
for z in zone_plans:
|
||||
self.stdout.write(f' [{z["action"]}] {z["def"]["mingzi"]}')
|
||||
|
||||
self.stdout.write('—— 商品样例(前8)——')
|
||||
for p in product_plans[:8]:
|
||||
self.stdout.write(f' [{p["action"]}] {p["title"]} ¥{p["price"]}')
|
||||
if len(product_plans) > 8:
|
||||
self.stdout.write(f' ... 其余 {len(product_plans)-8} 个省略')
|
||||
|
||||
banned = ('代练', '代打', '上分', '上段', '包星', '定位赛代')
|
||||
for p in product_plans:
|
||||
blob = p['title'] + p['intro']
|
||||
for b in banned:
|
||||
if b in blob:
|
||||
raise CommandError(f'文案含禁止词「{b}」: {p["title"]}')
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n以上仅计划。确认后:\n'
|
||||
' python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs\n'
|
||||
))
|
||||
return
|
||||
|
||||
stats = {
|
||||
'leixing_created': 0,
|
||||
'zones_created': 0,
|
||||
'zones_skipped': 0,
|
||||
'products_created': 0,
|
||||
'products_skipped': 0,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
if leixing is None:
|
||||
leixing = ShangpinLeixing.query.create(
|
||||
jieshao='娱乐护航',
|
||||
tupian_url=DEFAULT_AVATAR,
|
||||
paixu=0,
|
||||
shenhezhuangtai=SHENHE,
|
||||
yaoqiuleixing=None,
|
||||
huiyuan_id=None,
|
||||
yongjin=None,
|
||||
)
|
||||
stats['leixing_created'] = 1
|
||||
elif leixing.shenhezhuangtai != SHENHE:
|
||||
raise CommandError('解析到的类型不是审核专用,已中止')
|
||||
|
||||
zone_id_by_name = {}
|
||||
for zp in zone_plans:
|
||||
zdef = zp['def']
|
||||
if zp['action'] == 'SKIP' and zp['row']:
|
||||
# 若旧专区 leixing 不对,不改(只增不改);仅复用 id
|
||||
zone_id_by_name[zdef['mingzi']] = zp['row'].id
|
||||
stats['zones_skipped'] += 1
|
||||
continue
|
||||
exist = ShangpinZhuanqu.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
mingzi=zdef['mingzi'],
|
||||
leixing_id=leixing.id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist:
|
||||
zone_id_by_name[zdef['mingzi']] = exist.id
|
||||
stats['zones_skipped'] += 1
|
||||
continue
|
||||
row = ShangpinZhuanqu.query.create(
|
||||
mingzi=zdef['mingzi'],
|
||||
leixing_id=leixing.id,
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
tupian_url=DEFAULT_AVATAR,
|
||||
shenhezhuangtai=SHENHE,
|
||||
paixu=int(zdef['paixu']),
|
||||
shangjia_zhuangtai=True,
|
||||
fengjin_zhuangtai=True,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
zone_id_by_name[zdef['mingzi']] = row.id
|
||||
stats['zones_created'] += 1
|
||||
|
||||
first_zq_id = zone_id_by_name.get(first_zone_name)
|
||||
if not first_zq_id:
|
||||
raise CommandError(f'第一个专区「{first_zone_name}」未就绪')
|
||||
|
||||
for i, pp in enumerate(product_plans):
|
||||
if pp['action'] == 'SKIP':
|
||||
stats['products_skipped'] += 1
|
||||
continue
|
||||
exist_p = Shangpin.query.filter(
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
biaoqian=pp['title'],
|
||||
zhuanqu_id=first_zq_id,
|
||||
shenhezhuangtai=SHENHE,
|
||||
dianpu__isnull=True,
|
||||
).first()
|
||||
if exist_p:
|
||||
stats['products_skipped'] += 1
|
||||
continue
|
||||
Shangpin.query.create(
|
||||
biaoqian=pp['title'],
|
||||
jiage=Decimal(pp['price']),
|
||||
kucun=99999,
|
||||
leixing_id=leixing.id,
|
||||
zhuanqu_id=first_zq_id,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=pp['intro'],
|
||||
xiadan_xuzhi='请保持友好沟通,本服务为休闲娱乐陪伴,禁止不良言行。',
|
||||
guize_tupian='',
|
||||
tupian_url=DEFAULT_AVATAR,
|
||||
yaoqiuleixing=leixing.yaoqiuleixing,
|
||||
huiyuan_id=leixing.huiyuan_id,
|
||||
yongjin=leixing.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=False,
|
||||
ewai_dashou_fencheng=None,
|
||||
paixu=1000 - i,
|
||||
shenhezhuangtai=SHENHE,
|
||||
shangjia_zhuangtai=True,
|
||||
fengjin_zhuangtai=True,
|
||||
shenhe_zhuangtai=True,
|
||||
club_id=ALLOWED_CLUB_ID,
|
||||
shi_zhuanpan=False,
|
||||
dianpu=None,
|
||||
dianpu_leixing=None,
|
||||
)
|
||||
stats['products_created'] += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n完成: {stats}\n'
|
||||
f'审核类型 id={leixing.id}「{leixing.jieshao}」;'
|
||||
f'商品均 shenhezhuangtai=2,仅审核模式可见。'
|
||||
))
|
||||
119
jituan/management/commands/seed_role_agreement_perm.py
Normal file
119
jituan/management/commands/seed_role_agreement_perm.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""写入角色协议权限码,并可按俱乐部挂到角色。
|
||||
|
||||
权限码:
|
||||
xycfg001 角色协议配置
|
||||
|
||||
用法:
|
||||
python manage.py seed_role_agreement_perm
|
||||
python manage.py seed_role_agreement_perm --club-id lxs --role-name 管理员
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from gvsdsdk.models import Permission, Role, RolePermission
|
||||
|
||||
from jituan.models import Club
|
||||
|
||||
PERMS = [
|
||||
('xycfg001', '角色协议配置', '配置打手/管事/组长用户协议并发布版本', '小程序配置'),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '写入角色协议权限码,并按俱乐部挂到角色'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--club-id', default='', help='仅处理该俱乐部')
|
||||
parser.add_argument('--all-clubs', action='store_true')
|
||||
parser.add_argument('--role-name', default='', help='角色名;空则只确保权限码')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
club_id = (options.get('club_id') or '').strip()
|
||||
all_clubs = options.get('all_clubs')
|
||||
role_name = (options.get('role_name') or '').strip()
|
||||
|
||||
sample = Permission.objects.filter(PermStatus=1).first()
|
||||
tenant_uuid = sample.TenantUUID if sample else uuid.UUID(
|
||||
'00000000-0000-0000-0000-000000000001'
|
||||
).bytes
|
||||
|
||||
perm_objs = []
|
||||
for code, name, desc, cat in PERMS:
|
||||
perm = Permission.objects.filter(PermCode=code).first()
|
||||
if perm:
|
||||
self.stdout.write(f' 权限码已存在: {code}')
|
||||
if not dry_run:
|
||||
changed = False
|
||||
if perm.PermName != name:
|
||||
perm.PermName = name
|
||||
changed = True
|
||||
if (perm.PermDesc or '') != desc:
|
||||
perm.PermDesc = desc
|
||||
changed = True
|
||||
if (perm.PermCategory or '') != cat:
|
||||
perm.PermCategory = cat
|
||||
changed = True
|
||||
if changed:
|
||||
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
|
||||
else:
|
||||
if dry_run:
|
||||
self.stdout.write(f'would create perm {code}')
|
||||
else:
|
||||
perm = Permission.objects.create(
|
||||
PermUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=tenant_uuid,
|
||||
PermCode=code,
|
||||
PermName=name,
|
||||
PermDesc=desc,
|
||||
PermCategory=cat,
|
||||
PermStatus=1,
|
||||
CreateTime=timezone.now(),
|
||||
UpdateTime=timezone.now(),
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f' 已创建权限码: {code}'))
|
||||
if perm:
|
||||
perm_objs.append(perm)
|
||||
|
||||
if not role_name:
|
||||
self.stdout.write('未指定 --role-name,跳过挂角色')
|
||||
return
|
||||
|
||||
clubs = []
|
||||
if all_clubs:
|
||||
clubs = list(Club.query.filter(status=1).values_list('club_id', flat=True))
|
||||
elif club_id:
|
||||
clubs = [club_id]
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('请指定 --club-id 或 --all-clubs'))
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
for cid in clubs:
|
||||
roles = Role.objects.filter(RoleName=role_name, ClubID=cid, RoleStatus=1)
|
||||
if not roles.exists():
|
||||
roles = Role.objects.filter(RoleName=role_name, RoleStatus=1)
|
||||
roles = [r for r in roles if (getattr(r, 'ClubID', None) or '') == cid]
|
||||
for role in roles:
|
||||
for perm in perm_objs:
|
||||
exists = RolePermission.objects.filter(
|
||||
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
|
||||
).exists()
|
||||
if exists:
|
||||
self.stdout.write(f' 已挂: {cid} {role_name} <- {perm.PermCode}')
|
||||
continue
|
||||
if dry_run:
|
||||
self.stdout.write(f'would link {cid} {role_name} <- {perm.PermCode}')
|
||||
else:
|
||||
RolePermission.objects.create(
|
||||
RoleUUID=role.RoleUUID,
|
||||
PermUUID=perm.PermUUID,
|
||||
CreateTime=timezone.now(),
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f' 已挂: {cid} {role_name} <- {perm.PermCode}'
|
||||
))
|
||||
223
jituan/management/commands/seed_xx_invite_data.py
Normal file
223
jituan/management/commands/seed_xx_invite_data.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""小汐电竞邀请链种子:插入组长+管事(含邀请码)。
|
||||
|
||||
默认 club_id=xx;找不到会列出全部俱乐部。
|
||||
可用:
|
||||
python manage.py seed_xx_invite_data
|
||||
python manage.py seed_xx_invite_data --club-id xx
|
||||
python manage.py seed_xx_invite_data --list-clubs
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from jituan.models import Club, ClubHuiyuanPrice
|
||||
from users.business_models import User
|
||||
from users.models import UserBoss, UserGuanshi, UserZuzhang
|
||||
from utils.invitationcode_utils import CreateInvitationCode
|
||||
|
||||
DEFAULT_CLUB_ID = 'xx'
|
||||
WX_APPID = 'wx0d445b76f93774fa'
|
||||
NAME_HINTS = ('小汐', '小溪', '小西')
|
||||
|
||||
# 固定种子账号(可重复执行,幂等;UID/手机避开 xzj 990000x、lsx 991000x)
|
||||
SEED_ZUZHANG = {
|
||||
'UserUID': '9920001',
|
||||
'OpenID': 'xx_seed_openid_zuzhang_v1',
|
||||
'Phone': '19920000001',
|
||||
'UserName': 'xx_seed_zuzhang',
|
||||
'nickname': '小汐种子组长',
|
||||
}
|
||||
SEED_GUANSHI = {
|
||||
'UserUID': '9920002',
|
||||
'OpenID': 'xx_seed_openid_guanshi_v1',
|
||||
'Phone': '19920000002',
|
||||
'UserName': 'xx_seed_guanshi',
|
||||
'nickname': '小汐种子管事',
|
||||
}
|
||||
|
||||
|
||||
def _list_clubs(stdout, style):
|
||||
clubs = list(Club.query.order_by('sort_order', 'club_id'))
|
||||
stdout.write(style.SUCCESS('=' * 60))
|
||||
stdout.write(f'库里俱乐部共 {len(clubs)} 个:')
|
||||
for c in clubs:
|
||||
stdout.write('-' * 40)
|
||||
stdout.write(f' club_id={c.club_id!r} name={c.name!r} status={c.status}')
|
||||
stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
|
||||
stdout.write(style.SUCCESS('=' * 60))
|
||||
return clubs
|
||||
|
||||
|
||||
def _resolve_club(club_id: str, stdout, style):
|
||||
"""优先精确 club_id;其次 wx_appid;再名称含小汐/小溪/小西。"""
|
||||
cid = (club_id or DEFAULT_CLUB_ID).strip()
|
||||
club = Club.query.filter(club_id=cid).first()
|
||||
if club:
|
||||
return club
|
||||
|
||||
stdout.write(style.WARNING(f'精确 club_id={cid!r} 不存在,尝试按 AppID/名称匹配…'))
|
||||
|
||||
by_app = Club.query.filter(wx_appid=WX_APPID).first()
|
||||
if by_app:
|
||||
stdout.write(style.WARNING(
|
||||
f'按 wx_appid={WX_APPID} 命中: club_id={by_app.club_id!r} name={by_app.name!r}'
|
||||
))
|
||||
return by_app
|
||||
|
||||
hits = []
|
||||
for hint in NAME_HINTS:
|
||||
hits.extend(list(Club.query.filter(name__icontains=hint)))
|
||||
# 去重
|
||||
seen = set()
|
||||
unique = []
|
||||
for c in hits:
|
||||
if c.club_id in seen:
|
||||
continue
|
||||
seen.add(c.club_id)
|
||||
unique.append(c)
|
||||
|
||||
if len(unique) == 1:
|
||||
c = unique[0]
|
||||
stdout.write(style.WARNING(
|
||||
f'按名称命中唯一: club_id={c.club_id!r} name={c.name!r}'
|
||||
))
|
||||
return c
|
||||
if len(unique) > 1:
|
||||
stdout.write(style.ERROR('名称匹配到多条,请用 --club-id 指定:'))
|
||||
for c in unique:
|
||||
stdout.write(f' - {c.club_id!r} / {c.name!r}')
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_user(meta, club_id):
|
||||
user = User.query.filter(UserUID=meta['UserUID']).first()
|
||||
if not user:
|
||||
user = User.query.filter(OpenID=meta['OpenID']).first()
|
||||
if user:
|
||||
if (user.ClubID or '') != club_id:
|
||||
user.ClubID = club_id
|
||||
user.save(update_fields=['ClubID'])
|
||||
return user, False
|
||||
|
||||
user = User.query.create(
|
||||
UserUID=meta['UserUID'],
|
||||
UserName=meta['UserName'],
|
||||
OpenID=meta['OpenID'],
|
||||
Phone=meta['Phone'],
|
||||
ClubID=club_id,
|
||||
)
|
||||
UserBoss.query.create(user=user, nickname=meta['nickname'])
|
||||
return user, True
|
||||
|
||||
|
||||
def _ensure_zuzhang(user):
|
||||
try:
|
||||
return user.ZuzhangProfile, False
|
||||
except UserZuzhang.DoesNotExist:
|
||||
pass
|
||||
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||
z = UserZuzhang.query.create(
|
||||
user=user,
|
||||
yaoqingma=yaoqingma,
|
||||
zhuangtai=1,
|
||||
)
|
||||
return z, True
|
||||
|
||||
|
||||
def _ensure_guanshi(user, zuzhang_uid):
|
||||
try:
|
||||
return user.GuanshiProfile, False
|
||||
except UserGuanshi.DoesNotExist:
|
||||
pass
|
||||
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||
g = UserGuanshi.query.create(
|
||||
user=user,
|
||||
yaoqingma=yaoqingma,
|
||||
yaoqingren=zuzhang_uid,
|
||||
zhuangtai=1,
|
||||
)
|
||||
return g, True
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '插入小汐电竞种子组长+管事(含邀请码);找不到俱乐部时会列出全部 club'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--club-id',
|
||||
type=str,
|
||||
default=DEFAULT_CLUB_ID,
|
||||
help='目标俱乐部 ID(默认 xx)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list-clubs',
|
||||
action='store_true',
|
||||
help='只列出库里所有俱乐部,不写入种子',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force-new-codes',
|
||||
action='store_true',
|
||||
help='已存在时重新生成邀请码(慎用)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options.get('list_clubs'):
|
||||
_list_clubs(self.stdout, self.style)
|
||||
return
|
||||
|
||||
want_id = (options.get('club_id') or DEFAULT_CLUB_ID).strip()
|
||||
club = _resolve_club(want_id, self.stdout, self.style)
|
||||
if club is None:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f'找不到可用俱乐部(请求 club_id={want_id!r})。'
|
||||
))
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'请先在后台创建小汐俱乐部(club_id=xx),或:\n'
|
||||
' python manage.py seed_xx_invite_data --list-clubs\n'
|
||||
' python manage.py seed_xx_invite_data --club-id 真实id'
|
||||
))
|
||||
_list_clubs(self.stdout, self.style)
|
||||
raise CommandError('俱乐部不存在,已中止(未写入任何种子)')
|
||||
|
||||
club_id = club.club_id
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'将写入种子到俱乐部: club_id={club_id!r} name={club.name!r} '
|
||||
f'wx_appid={club.wx_appid or "(空)"}'
|
||||
))
|
||||
|
||||
with transaction.atomic():
|
||||
zz_user, zz_new = _ensure_user(SEED_ZUZHANG, club_id)
|
||||
zz_profile, zz_profile_new = _ensure_zuzhang(zz_user)
|
||||
if options.get('force_new_codes') and not zz_profile_new:
|
||||
zz_profile.yaoqingma = CreateInvitationCode(str(zz_user.UserUID))
|
||||
zz_profile.save(update_fields=['yaoqingma'])
|
||||
|
||||
gs_user, gs_new = _ensure_user(SEED_GUANSHI, club_id)
|
||||
gs_profile, gs_profile_new = _ensure_guanshi(gs_user, zz_user.UserUID)
|
||||
if options.get('force_new_codes') and not gs_profile_new:
|
||||
gs_profile.yaoqingma = CreateInvitationCode(str(gs_user.UserUID))
|
||||
gs_profile.save(update_fields=['yaoqingma'])
|
||||
|
||||
price_cnt = ClubHuiyuanPrice.query.filter(club_id=club_id).count()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(self.style.SUCCESS(f'小汐电竞邀请链种子已就绪(club_id={club_id})'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(f'club_huiyuan_price 条数: {price_cnt}')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('【组长】管事注册用这个邀请码(guanshizhuce):')
|
||||
self.stdout.write(f' UserUID={zz_user.UserUID} Phone={SEED_ZUZHANG["Phone"]}')
|
||||
self.stdout.write(f' 组长邀请码 inviteCode = {zz_profile.yaoqingma}')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('【管事】打手/接单员注册用这个邀请码(dashouzhuce):')
|
||||
self.stdout.write(f' UserUID={gs_user.UserUID} Phone={SEED_GUANSHI["Phone"]}')
|
||||
self.stdout.write(f' 管事邀请码 inviteCode = {gs_profile.yaoqingma}')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('商家:后台「商家管理 → 添加商家」按用户UID开通')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('新建: 组长=%s 管事=%s' % (
|
||||
'是' if zz_new or zz_profile_new else '否(已存在)',
|
||||
'是' if gs_new or gs_profile_new else '否(已存在)',
|
||||
))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
38
jituan/management/commands/set_club_official.py
Normal file
38
jituan/management/commands/set_club_official.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""写入俱乐部服务号配置(H5 网页授权)。不改小程序 wx_appid。"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from jituan.models import Club
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '设置俱乐部 official_appid / official_secret / h5_domain'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--club-id', required=True)
|
||||
parser.add_argument('--official-appid', required=True)
|
||||
parser.add_argument('--official-secret', required=True)
|
||||
parser.add_argument('--h5-domain', default='')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
club_id = (options['club_id'] or '').strip()
|
||||
appid = (options['official_appid'] or '').strip()
|
||||
secret = (options['official_secret'] or '').strip()
|
||||
h5_domain = (options['h5_domain'] or '').strip()
|
||||
if not club_id or not appid or not secret:
|
||||
raise CommandError('club-id / official-appid / official-secret 必填')
|
||||
|
||||
try:
|
||||
club = Club.query.get(club_id=club_id)
|
||||
except Club.DoesNotExist:
|
||||
raise CommandError(f'俱乐部不存在: {club_id}')
|
||||
|
||||
club.official_appid = appid
|
||||
club.official_secret = secret
|
||||
if h5_domain:
|
||||
club.h5_domain = h5_domain
|
||||
club.save()
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'已更新 club={club_id} official_appid={appid} '
|
||||
f'secret_len={len(secret)} h5_domain={club.h5_domain or "(未改)"}'
|
||||
))
|
||||
114
jituan/management/commands/wx_v3_pay_diag.py
Normal file
114
jituan/management/commands/wx_v3_pay_diag.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""诊断微信 V3 商户证书/私钥/序列号是否匹配。用法: python manage.py wx_v3_pay_diag [--club xq]"""
|
||||
import os
|
||||
|
||||
import requests
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
from jituan.services.wechat_pay import (
|
||||
XQ_WX_PAY,
|
||||
get_wechat_v3_config,
|
||||
resolve_merchant_cert_serial,
|
||||
_load_pem_private_key,
|
||||
_public_keys_match,
|
||||
)
|
||||
from utils.wechat_v3 import build_authorization
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '诊断微信 V3 提现签名:私钥、证书序列号、商户号是否一致'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--club', default=CLUB_ID_DEFAULT, help='俱乐部 ID:xq 或 xzj')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
club_id = (options.get('club') or CLUB_ID_DEFAULT).strip()
|
||||
cfg = get_wechat_v3_config(club_id)
|
||||
|
||||
self.stdout.write('=== 微信 V3 配置诊断 ===')
|
||||
self.stdout.write(f"club_id : {cfg.get('club_id')}")
|
||||
self.stdout.write(f"APPID : {cfg.get('APPID')}")
|
||||
self.stdout.write(f"MCHID : {cfg.get('MCHID')} (期望 {XQ_WX_PAY['mch_id']})")
|
||||
self.stdout.write(f"CERT_SERIAL : {cfg.get('CERT_SERIAL_NO')}")
|
||||
self.stdout.write(f"API_V3_KEY : len={len(cfg.get('API_V3_KEY') or '')}")
|
||||
key_path = cfg.get('PRIVATE_KEY_PATH') or ''
|
||||
self.stdout.write(f"PRIVATE_KEY : {key_path}")
|
||||
self.stdout.write(f"key_exists : {os.path.isfile(key_path)}")
|
||||
|
||||
cert_dir = os.path.dirname(os.path.abspath(key_path)) if key_path else ''
|
||||
self.stdout.write(f"cert_dir : {cert_dir}")
|
||||
if cert_dir and os.path.isdir(cert_dir):
|
||||
self.stdout.write('cert_dir files:')
|
||||
for name in sorted(os.listdir(cert_dir)):
|
||||
if name.lower().endswith('.pem'):
|
||||
self.stdout.write(f' - {name}')
|
||||
|
||||
serial_from_match = resolve_merchant_cert_serial(key_path, '')
|
||||
self.stdout.write(f"matched_serial: {serial_from_match or '(未匹配到证书)'}")
|
||||
|
||||
if key_path and os.path.isfile(key_path):
|
||||
try:
|
||||
private_key = _load_pem_private_key(key_path)
|
||||
self.stdout.write('private_key : 加载成功')
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
tested = []
|
||||
for fname in sorted(os.listdir(cert_dir)):
|
||||
if not fname.lower().endswith('.pem'):
|
||||
continue
|
||||
if fname.lower() in ('apiclient_key.pem',):
|
||||
continue
|
||||
cert_path = os.path.join(cert_dir, fname)
|
||||
tested.append(fname)
|
||||
try:
|
||||
with open(cert_path, 'rb') as f:
|
||||
data = f.read()
|
||||
if b'PRIVATE KEY' in data:
|
||||
self.stdout.write(f' {fname}: 是私钥,跳过')
|
||||
continue
|
||||
cert = x509.load_pem_x509_certificate(data, default_backend())
|
||||
serial = format(cert.serial_number, 'X').upper()
|
||||
matched = _public_keys_match(private_key, cert.public_key())
|
||||
self.stdout.write(
|
||||
f' {fname}: X509证书 serial={serial} 与私钥匹配={matched}'
|
||||
)
|
||||
except Exception as exc:
|
||||
self.stdout.write(f' {fname}: 非X509商户证书 ({exc})')
|
||||
|
||||
if 'apiclient_cert.pem' not in tested:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'\n缺少 apiclient_cert.pem!'
|
||||
'\n请到微信商户平台(1747846572) → 账户中心 → API安全 → 申请API证书'
|
||||
'\n下载证书包,将 apiclient_cert.pem 放到目录:'
|
||||
f'\n {cert_dir}'
|
||||
'\n当前目录只有 apiclient_key.pem,没有商户证书,所以签名必然失败。'
|
||||
'\npub_key.pem 是微信支付平台公钥,不能代替 apiclient_cert.pem。'
|
||||
))
|
||||
except Exception as exc:
|
||||
self.stdout.write(self.style.ERROR(f'私钥/证书检查失败: {exc}'))
|
||||
|
||||
# 试探请求:查一个不存在的单号,仅验证签名是否通过(404=签名OK,401 SIGN_ERROR=签名失败)
|
||||
url = 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/WX_V3_DIAG_TEST'
|
||||
auth = build_authorization('GET', url, '', club_id=club_id)
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers={
|
||||
'Authorization': auth,
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
self.stdout.write(f"\n探针 HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
if resp.status_code == 401:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'签名仍失败:请确认服务器 apiclient_key.pem / apiclient_cert.pem '
|
||||
'是商户号 1747846572 在微信商户平台下载的那一对'
|
||||
))
|
||||
elif resp.status_code in (404, 200):
|
||||
self.stdout.write(self.style.SUCCESS('签名已通过(404/200 表示微信接受了商户签名)'))
|
||||
except requests.RequestException as exc:
|
||||
self.stdout.write(self.style.ERROR(f'探针请求失败: {exc}'))
|
||||
68
jituan/migrations/0011_club_payment_channel.py
Normal file
68
jituan/migrations/0011_club_payment_channel.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0010_club_huiyuan_bundle_include'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubPaymentChannel',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
|
||||
('channel', models.CharField(
|
||||
choices=[
|
||||
('wechat_jsapi', '微信直连-小程序JSAPI'),
|
||||
('wechat_v3', '微信直连-V3转账/退款'),
|
||||
('alipay_wap', '支付宝-手机网站'),
|
||||
('fubei_wx_mini', '付呗-微信小程序'),
|
||||
],
|
||||
db_index=True,
|
||||
max_length=32,
|
||||
)),
|
||||
('name', models.CharField(help_text='如:主付呗、备用微信', max_length=64, verbose_name='配置名称')),
|
||||
('enabled', models.IntegerField(default=1, verbose_name='1启用0停用')),
|
||||
('is_default', models.BooleanField(default=True, verbose_name='同通道默认配置')),
|
||||
('sort_order', models.IntegerField(default=0, verbose_name='前端展示顺序')),
|
||||
('sandbox', models.BooleanField(default=False, verbose_name='沙箱/联调')),
|
||||
('app_id', models.CharField(blank=True, default='', max_length=64, verbose_name='AppID/付呗ID')),
|
||||
('mch_id', models.CharField(blank=True, default='', max_length=64, verbose_name='商户号')),
|
||||
('api_key_enc', models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')),
|
||||
('private_key_enc', models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')),
|
||||
('public_key_enc', models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')),
|
||||
('cert_serial_no', models.CharField(blank=True, default='', max_length=64)),
|
||||
('private_key_path', models.CharField(blank=True, default='', max_length=255)),
|
||||
('platform_cert_dir', models.CharField(blank=True, default='', max_length=255)),
|
||||
('notify_url', models.CharField(blank=True, default='', max_length=512, verbose_name='异步回调URL')),
|
||||
('return_url', models.CharField(blank=True, default='', max_length=512, verbose_name='同步返回URL')),
|
||||
('gateway_url', models.CharField(blank=True, default='', max_length=255, verbose_name='API网关')),
|
||||
('mini_app_id', models.CharField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text='付呗/微信JSAPI;空则回落 club.wx_appid',
|
||||
max_length=32,
|
||||
verbose_name='支付用小程序AppID',
|
||||
)),
|
||||
('extra_json', models.JSONField(blank=True, default=dict, verbose_name='扩展配置')),
|
||||
('remark', models.CharField(blank=True, default='', max_length=255)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '俱乐部支付通道',
|
||||
'db_table': 'club_payment_channel',
|
||||
'unique_together': {('club_id', 'channel', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubpaymentchannel',
|
||||
index=models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubpaymentchannel',
|
||||
index=models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
|
||||
),
|
||||
]
|
||||
33
jituan/migrations/0012_club_mch_key.py
Normal file
33
jituan/migrations/0012_club_mch_key.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""club 表增加 mch_key 列(APIv2 小程序支付密钥),从 config_json 迁移历史数据。"""
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_mch_key_from_config_json(apps, schema_editor):
|
||||
Club = apps.get_model('jituan', 'Club')
|
||||
for club in Club.objects.all():
|
||||
cfg = club.config_json or {}
|
||||
legacy = (cfg.get('mch_key') or cfg.get('shanghumiyao') or '').strip()
|
||||
if legacy and not (club.mch_key or '').strip():
|
||||
club.mch_key = legacy
|
||||
club.save(update_fields=['mch_key'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0011_club_payment_channel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='mch_key',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default='',
|
||||
max_length=128,
|
||||
verbose_name='APIv2商户密钥',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(copy_mch_key_from_config_json, migrations.RunPython.noop),
|
||||
]
|
||||
70
jituan/migrations/0013_club_pay_withdraw_split.py
Normal file
70
jituan/migrations/0013_club_pay_withdraw_split.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0012_club_mch_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='pay_cert_path',
|
||||
field=models.CharField(
|
||||
blank=True, default='', help_text='apiclient_cert.pem,退款/V2 证书用',
|
||||
max_length=512, verbose_name='收款商户证书路径',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='pay_key_path',
|
||||
field=models.CharField(
|
||||
blank=True, default='', help_text='apiclient_key.pem,退款/V2 证书用',
|
||||
max_length=512, verbose_name='收款商户私钥路径',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='withdraw_mch_id',
|
||||
field=models.CharField(
|
||||
blank=True, default='', help_text='空则回落收款商户号 mch_id',
|
||||
max_length=32, verbose_name='提现商户号',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='mch_id',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款商户号'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='pay_app_id',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款AppID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='mch_key',
|
||||
field=models.CharField(blank=True, default='', max_length=128, verbose_name='收款APIv2密钥'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='api_v3_key',
|
||||
field=models.CharField(blank=True, default='', max_length=128, verbose_name='提现APIv3密钥'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='cert_serial_no',
|
||||
field=models.CharField(blank=True, default='', max_length=64, verbose_name='提现证书序列号'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='private_key_path',
|
||||
field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现商户私钥路径'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='club',
|
||||
name='platform_cert_dir',
|
||||
field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现平台证书目录'),
|
||||
),
|
||||
]
|
||||
41
jituan/migrations/0014_club_mini_pay_channel_and_ledger.py
Normal file
41
jituan/migrations/0014_club_mini_pay_channel_and_ledger.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0013_club_pay_withdraw_split'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='mini_pay_channel',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default='wechat',
|
||||
help_text='wechat=微信直连;fubei=付呗;默认 wechat,不影响现网',
|
||||
max_length=16,
|
||||
verbose_name='小程序收款通道',
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PayChannelLedger',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('out_trade_no', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='商户订单号')),
|
||||
('club_id', models.CharField(db_index=True, default='', max_length=16)),
|
||||
('channel', models.CharField(db_index=True, max_length=32, verbose_name='通道')),
|
||||
('channel_config_id', models.IntegerField(blank=True, null=True)),
|
||||
('biz_type', models.CharField(blank=True, default='', max_length=16, verbose_name='order/czjilu')),
|
||||
('provider_order_sn', models.CharField(blank=True, default='', max_length=64, verbose_name='通道侧订单号')),
|
||||
('amount_yuan', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '支付通道流水',
|
||||
'db_table': 'pay_channel_ledger',
|
||||
},
|
||||
),
|
||||
]
|
||||
31
jituan/migrations/0015_club_recharge_page_copy.py
Normal file
31
jituan/migrations/0015_club_recharge_page_copy.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0014_club_mini_pay_channel_and_ledger'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubRechargePageCopy',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
|
||||
('page_key', models.CharField(db_index=True, max_length=16, verbose_name='huiyuan|yajin')),
|
||||
('region_key', models.CharField(db_index=True, max_length=32, verbose_name='rules|benefits|advantages')),
|
||||
('title', models.CharField(blank=True, default='', max_length=64, verbose_name='区域标题')),
|
||||
('items_json', models.JSONField(blank=True, default=list, verbose_name='条目列表JSON')),
|
||||
('enabled', models.BooleanField(default=True, verbose_name='是否启用该区域')),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '充值页文案配置',
|
||||
'db_table': 'club_recharge_page_copy',
|
||||
'unique_together': {('club_id', 'page_key', 'region_key')},
|
||||
},
|
||||
),
|
||||
]
|
||||
35
jituan/migrations/0016_club_order_grab_link.py
Normal file
35
jituan/migrations/0016_club_order_grab_link.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated manually for ClubOrderGrabLink
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0015_club_recharge_page_copy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubOrderGrabLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('grabber_club_id', models.CharField(db_index=True, max_length=16, verbose_name='抢单方俱乐部')),
|
||||
('order_club_id', models.CharField(db_index=True, max_length=16, verbose_name='被抢订单所属俱乐部')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '俱乐部订单互通抢单',
|
||||
'db_table': 'club_order_grab_link',
|
||||
'unique_together': {('grabber_club_id', 'order_club_id')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubordergrablink',
|
||||
index=models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubordergrablink',
|
||||
index=models.Index(fields=['order_club_id'], name='idx_cogl_order'),
|
||||
),
|
||||
]
|
||||
131
jituan/migrations/0017_fund_freeze.py
Normal file
131
jituan/migrations/0017_fund_freeze.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# Generated manually for fund freeze P0 tables
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0016_club_order_grab_link'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubFundFreezeConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部')),
|
||||
('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长'), ('shenheguan', '考核官')], db_index=True, max_length=16, verbose_name='角色')),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='是否启用冻结')),
|
||||
('freeze_mode', models.SmallIntegerField(choices=[(1, '按比例'), (2, '固定额'), (3, '比例+单笔上限')], default=1)),
|
||||
('freeze_rate', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='0.2=20%', max_digits=8, verbose_name='冻结比例')),
|
||||
('freeze_fixed', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)),
|
||||
('freeze_cap', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='单笔冻结上限(0=不限)')),
|
||||
('min_credit_to_freeze', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='低于此入账不冻')),
|
||||
('source_scope', models.JSONField(blank=True, default=list, help_text='空=全部来源;如 ["order_settle"]', verbose_name='入账来源白名单')),
|
||||
('unfreeze_trigger', models.CharField(choices=[('time_only', '仅到期'), ('condition_only', '仅条件达标'), ('time_and_condition', '到期且达标'), ('time_or_condition', '到期或达标'), ('manual_only', '仅手动')], default='time_only', max_length=32)),
|
||||
('schedule_mode', models.CharField(choices=[('all_at_once', '一次全解'), ('installment', '分期解冻')], default='all_at_once', max_length=16)),
|
||||
('installment_json', models.JSONField(blank=True, default=dict, help_text='{"steps":[...]}', verbose_name='分期计划')),
|
||||
('condition_json', models.JSONField(blank=True, default=dict, verbose_name='解冻条件')),
|
||||
('freeze_min_days', models.IntegerField(default=0, verbose_name='最短冻结天数')),
|
||||
('remark', models.CharField(blank=True, default='', max_length=255)),
|
||||
('updated_by', models.CharField(blank=True, default='', max_length=32)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '资金冻结规则',
|
||||
'db_table': 'club_fund_freeze_config',
|
||||
'unique_together': {('club_id', 'role')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FundFreezeLedger',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('user_id', models.CharField(db_index=True, max_length=16, verbose_name='UserUID')),
|
||||
('role', models.CharField(db_index=True, max_length=16)),
|
||||
('amount_total', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||
('amount_unfrozen', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)),
|
||||
('status', models.SmallIntegerField(db_index=True, default=1)),
|
||||
('frozen_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('source_type', models.CharField(db_index=True, max_length=32)),
|
||||
('order_id', models.CharField(blank=True, db_index=True, default='', max_length=64)),
|
||||
('czjilu_id', models.CharField(blank=True, db_index=True, default='', max_length=64)),
|
||||
('biz_id', models.CharField(max_length=64, verbose_name='幂等业务键')),
|
||||
('freeze_reason', models.CharField(blank=True, default='', max_length=255)),
|
||||
('config_snapshot', models.JSONField(blank=True, default=dict)),
|
||||
('current_step', models.IntegerField(default=1)),
|
||||
('next_eligible_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '资金冻结主单',
|
||||
'db_table': 'fund_freeze_ledger',
|
||||
'unique_together': {('club_id', 'role', 'source_type', 'biz_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FundUnfreezeEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ledger_id', models.BigIntegerField(db_index=True)),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('user_id', models.CharField(db_index=True, max_length=16)),
|
||||
('role', models.CharField(max_length=16)),
|
||||
('step_seq', models.IntegerField(default=1)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||
('unfrozen_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('trigger', models.CharField(default='schedule', max_length=16)),
|
||||
('reason', models.CharField(blank=True, default='', max_length=255)),
|
||||
('operator_id', models.CharField(blank=True, default='', max_length=32)),
|
||||
('condition_snapshot', models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '资金解冻事件',
|
||||
'db_table': 'fund_unfreeze_event',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FundRiskStatDaily',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('user_id', models.CharField(db_index=True, max_length=16)),
|
||||
('role', models.CharField(db_index=True, max_length=16)),
|
||||
('day', models.DateField(db_index=True)),
|
||||
('completed_orders', models.IntegerField(default=0)),
|
||||
('fine_count', models.IntegerField(default=0)),
|
||||
('complaint_count', models.IntegerField(default=0)),
|
||||
('valid_invite_count', models.IntegerField(default=0)),
|
||||
('meta_json', models.JSONField(blank=True, default=dict)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '资金风险日汇总',
|
||||
'db_table': 'fund_risk_stat_daily',
|
||||
'unique_together': {('club_id', 'user_id', 'role', 'day')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='fundfreezeledger',
|
||||
index=models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='fundfreezeledger',
|
||||
index=models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='fundfreezeledger',
|
||||
index=models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='fundriskstatdaily',
|
||||
index=models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'),
|
||||
),
|
||||
]
|
||||
66
jituan/migrations/0018_role_agreement.py
Normal file
66
jituan/migrations/0018_role_agreement.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# Generated manually for role agreement tables
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0017_fund_freeze'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubRoleAgreement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长')], db_index=True, max_length=16)),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='是否要求签署')),
|
||||
('title', models.CharField(blank=True, default='', max_length=128, verbose_name='协议标题')),
|
||||
('content_text', models.TextField(blank=True, default='', verbose_name='协议正文')),
|
||||
('images_json', models.JSONField(blank=True, default=list, verbose_name='说明图片URL列表')),
|
||||
('version', models.PositiveIntegerField(default=0, verbose_name='已发布版本号')),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
('updated_by', models.CharField(blank=True, default='', max_length=32)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '角色协议',
|
||||
'db_table': 'club_role_agreement',
|
||||
'unique_together': {('club_id', 'role')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubRoleAgreementSign',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('user_id', models.CharField(db_index=True, max_length=16, verbose_name='UserUID')),
|
||||
('role', models.CharField(db_index=True, max_length=16)),
|
||||
('agreement_id', models.BigIntegerField(db_index=True, default=0)),
|
||||
('version', models.PositiveIntegerField(db_index=True)),
|
||||
('title_snapshot', models.CharField(blank=True, default='', max_length=128)),
|
||||
('content_snapshot', models.TextField(blank=True, default='')),
|
||||
('images_snapshot', models.JSONField(blank=True, default=list)),
|
||||
('sign_image_url', models.CharField(blank=True, default='', max_length=512)),
|
||||
('client_meta', models.JSONField(blank=True, default=dict)),
|
||||
('signed_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '角色协议签署记录',
|
||||
'db_table': 'club_role_agreement_sign',
|
||||
'unique_together': {('club_id', 'user_id', 'role', 'version')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubroleagreementsign',
|
||||
index=models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubroleagreementsign',
|
||||
index=models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
|
||||
),
|
||||
]
|
||||
22
jituan/migrations/0019_role_agreement_gates.py
Normal file
22
jituan/migrations/0019_role_agreement_gates.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# 角色协议:阅读秒数 + 卡点配置(去掉手写依赖,字段兼容保留)
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0018_role_agreement'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clubroleagreement',
|
||||
name='read_seconds',
|
||||
field=models.PositiveIntegerField(default=2, verbose_name='阅读秒数'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clubroleagreement',
|
||||
name='gate_config',
|
||||
field=models.JSONField(blank=True, default=dict, verbose_name='签署卡点配置'),
|
||||
),
|
||||
]
|
||||
87
jituan/migrations/0020_order_deal_config_stats.py
Normal file
87
jituan/migrations/0020_order_deal_config_stats.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0019_role_agreement_gates'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubOrderDealConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')),
|
||||
('auto_settle_enabled', models.BooleanField(default=False, verbose_name='自动结算开关')),
|
||||
('auto_settle_enabled_at', models.DateTimeField(blank=True, null=True, verbose_name='本次打开自动结算时间')),
|
||||
('hours_platform', models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')),
|
||||
('hours_merchant', models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')),
|
||||
('stats_enabled', models.BooleanField(default=False, verbose_name='成交指标统计开关')),
|
||||
('stats_since', models.DateTimeField(blank=True, null=True, verbose_name='指标统计起点')),
|
||||
('updated_by', models.CharField(blank=True, default='', max_length=32)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '成交与自动结算配置',
|
||||
'db_table': 'club_order_deal_config',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MerchantDealStat',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('merchant_uid', models.CharField(db_index=True, max_length=16, verbose_name='商家UserUID')),
|
||||
('order_count', models.PositiveIntegerField(default=0)),
|
||||
('completed_count', models.PositiveIntegerField(default=0)),
|
||||
('refund_count', models.PositiveIntegerField(default=0)),
|
||||
('fine_paid_count', models.PositiveIntegerField(default=0)),
|
||||
('settle_duration_sum_seconds', models.BigIntegerField(default=0)),
|
||||
('settle_duration_sample_count', models.PositiveIntegerField(default=0)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '商家成交指标',
|
||||
'db_table': 'merchant_deal_stat',
|
||||
'unique_together': {('club_id', 'merchant_uid')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DashouDealStat',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('dashou_uid', models.CharField(db_index=True, max_length=16, verbose_name='打手UserUID')),
|
||||
('order_count', models.PositiveIntegerField(default=0)),
|
||||
('completed_count', models.PositiveIntegerField(default=0)),
|
||||
('fine_paid_count', models.PositiveIntegerField(default=0)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '打手成交指标',
|
||||
'db_table': 'dashou_deal_stat',
|
||||
'unique_together': {('club_id', 'dashou_uid')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DealStatEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('event_key', models.CharField(db_index=True, max_length=96, unique=True)),
|
||||
('event_type', models.CharField(db_index=True, max_length=32)),
|
||||
('order_id', models.CharField(blank=True, db_index=True, default='', max_length=32)),
|
||||
('subject_uid', models.CharField(blank=True, db_index=True, default='', max_length=16)),
|
||||
('meta_json', models.JSONField(blank=True, default=dict)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '成交指标事件',
|
||||
'db_table': 'deal_stat_event',
|
||||
},
|
||||
),
|
||||
]
|
||||
174
jituan/migrations/0021_club_migrate.py
Normal file
174
jituan/migrations/0021_club_migrate.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# Generated manually for club migrate module
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0020_order_deal_config_stats'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubMigratePlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('plan_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='计划ID')),
|
||||
('name', models.CharField(blank=True, default='', max_length=64, verbose_name='计划名称')),
|
||||
('from_club_id', models.CharField(db_index=True, max_length=16, verbose_name='源俱乐部')),
|
||||
('to_club_id', models.CharField(db_index=True, max_length=16, verbose_name='目标俱乐部')),
|
||||
('enabled', models.BooleanField(db_index=True, default=False, verbose_name='是否启用')),
|
||||
('start_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')),
|
||||
('end_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
|
||||
('asset_whitelist', models.JSONField(blank=True, default=list, verbose_name='资产白名单')),
|
||||
('condition_json', models.JSONField(blank=True, default=dict, verbose_name='门槛条件')),
|
||||
('force_intercept', models.BooleanField(default=True, verbose_name='源店强制迁移页')),
|
||||
('force_roles', models.JSONField(blank=True, default=list, verbose_name='强制角色列表')),
|
||||
('default_once_per_user', models.BooleanField(default=True, verbose_name='默认每人成功一次')),
|
||||
('remark', models.CharField(blank=True, default='', max_length=255)),
|
||||
('updated_by', models.CharField(blank=True, default='', max_length=32)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '跨店资产迁移计划',
|
||||
'db_table': 'club_migrate_plan',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubMigrateToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='令牌')),
|
||||
('plan_id', models.CharField(db_index=True, max_length=32)),
|
||||
('from_club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('to_club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('from_uid', models.CharField(db_index=True, max_length=16, verbose_name='源UserUID')),
|
||||
('password_hash', models.CharField(blank=True, default='', max_length=128, verbose_name='转移密码哈希')),
|
||||
('password_salt', models.CharField(blank=True, default='', max_length=32)),
|
||||
('status', models.SmallIntegerField(db_index=True, default=0)),
|
||||
('fail_count', models.PositiveIntegerField(default=0, verbose_name='验密失败次数')),
|
||||
('asset_snapshot', models.JSONField(blank=True, default=dict, verbose_name='发码时资产快照')),
|
||||
('qrcode_url', models.CharField(blank=True, default='', max_length=512, verbose_name='目标店小程序码URL')),
|
||||
('expire_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('consumed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '跨店迁移令牌',
|
||||
'db_table': 'club_migrate_token',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubMigrateConsumption',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('plan_id', models.CharField(db_index=True, max_length=32)),
|
||||
('from_uid', models.CharField(db_index=True, max_length=16)),
|
||||
('success_count', models.PositiveIntegerField(default=0)),
|
||||
('last_ledger_id', models.BigIntegerField(blank=True, null=True)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '跨店迁移占用',
|
||||
'db_table': 'club_migrate_consumption',
|
||||
'unique_together': {('plan_id', 'from_uid')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubMigrateUserQuota',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('plan_id', models.CharField(db_index=True, max_length=32)),
|
||||
('from_uid', models.CharField(db_index=True, max_length=16)),
|
||||
('max_extra_times', models.PositiveIntegerField(default=1, verbose_name='额外成功次数')),
|
||||
('reason', models.CharField(blank=True, default='', max_length=255)),
|
||||
('updated_by', models.CharField(blank=True, default='', max_length=32)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '跨店迁移二次白名单',
|
||||
'db_table': 'club_migrate_user_quota',
|
||||
'unique_together': {('plan_id', 'from_uid')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubMigrateLedger',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('plan_id', models.CharField(db_index=True, max_length=32)),
|
||||
('token', models.CharField(blank=True, db_index=True, default='', max_length=64)),
|
||||
('from_club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('to_club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('from_uid', models.CharField(db_index=True, max_length=16)),
|
||||
('to_uid', models.CharField(db_index=True, max_length=16)),
|
||||
('migrate_seq', models.PositiveIntegerField(default=1, verbose_name='本计划第几次')),
|
||||
('status', models.SmallIntegerField(db_index=True, default=1)),
|
||||
('summary_json', models.JSONField(blank=True, default=dict, verbose_name='摘要')),
|
||||
('client_meta', models.JSONField(blank=True, default=dict)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '跨店迁移流水主单',
|
||||
'db_table': 'club_migrate_ledger',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubMigrateLedgerItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ledger_id', models.BigIntegerField(db_index=True)),
|
||||
('plan_id', models.CharField(db_index=True, max_length=32)),
|
||||
('item_type', models.CharField(db_index=True, default='balance', max_length=16)),
|
||||
('role', models.CharField(db_index=True, max_length=16)),
|
||||
('field', models.CharField(blank=True, default='', max_length=32, verbose_name='字段名')),
|
||||
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=14)),
|
||||
('before_src', models.CharField(blank=True, default='', max_length=64)),
|
||||
('after_src', models.CharField(blank=True, default='', max_length=64)),
|
||||
('before_dst', models.CharField(blank=True, default='', max_length=64)),
|
||||
('after_dst', models.CharField(blank=True, default='', max_length=64)),
|
||||
('huiyuan_id', models.CharField(blank=True, db_index=True, default='', max_length=16)),
|
||||
('meta_json', models.JSONField(blank=True, default=dict)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '跨店迁移流水明细',
|
||||
'db_table': 'club_migrate_ledger_item',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubmigrateplan',
|
||||
index=models.Index(fields=['from_club_id', 'enabled'], name='idx_cmp_from_en'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubmigrateplan',
|
||||
index=models.Index(fields=['to_club_id', 'enabled'], name='idx_cmp_to_en'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubmigratetoken',
|
||||
index=models.Index(fields=['plan_id', 'from_uid', 'status'], name='idx_cmt_plan_uid_st'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubmigrateledger',
|
||||
index=models.Index(fields=['from_uid', 'plan_id'], name='idx_cml_from_plan'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubmigrateledger',
|
||||
index=models.Index(fields=['to_uid', 'plan_id'], name='idx_cml_to_plan'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubmigrateledger',
|
||||
index=models.Index(fields=['from_club_id', 'to_club_id', 'CreateTime'], name='idx_cml_clubs_t'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubmigrateledgeritem',
|
||||
index=models.Index(fields=['ledger_id', 'role'], name='idx_cmli_led_role'),
|
||||
),
|
||||
]
|
||||
22
jituan/migrations/0022_user_wx_openid_wx_appid.py
Normal file
22
jituan/migrations/0022_user_wx_openid_wx_appid.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0021_club_migrate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userwxopenid',
|
||||
name='wx_appid',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default='',
|
||||
db_index=True,
|
||||
help_text='产生该 openid 的 AppID:小程序或服务号;空=历史小程序绑定',
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
]
|
||||
22
jituan/migrations/0023_club_leixing_show_price.py
Normal file
22
jituan/migrations/0023_club_leixing_show_price.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated manually for ClubShangpinLeixingConfig.show_price
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0022_user_wx_openid_wx_appid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clubshangpinleixingconfig',
|
||||
name='show_price',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text='开启后小程序该类型订单显示分成价格,即使未开通对应会员',
|
||||
verbose_name='强制展示订单价格',
|
||||
),
|
||||
),
|
||||
]
|
||||
584
jituan/models.py
584
jituan/models.py
@@ -13,12 +13,34 @@ class Club(QModel):
|
||||
status = models.IntegerField(default=1, verbose_name='1启用0停用')
|
||||
wx_appid = models.CharField(max_length=32, blank=True, default='')
|
||||
wx_secret = models.CharField(max_length=128, blank=True, default='')
|
||||
mch_id = models.CharField(max_length=32, blank=True, default='')
|
||||
pay_app_id = models.CharField(max_length=32, blank=True, default='')
|
||||
api_v3_key = models.CharField(max_length=128, blank=True, default='')
|
||||
cert_serial_no = models.CharField(max_length=64, blank=True, default='')
|
||||
private_key_path = models.CharField(max_length=255, blank=True, default='')
|
||||
platform_cert_dir = models.CharField(max_length=255, blank=True, default='')
|
||||
# —— 点单/会员等收款商户(V2 JSAPI;退款也应对齐这套)——
|
||||
mch_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款商户号')
|
||||
pay_app_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款AppID')
|
||||
mch_key = models.CharField(max_length=128, blank=True, default='', verbose_name='收款APIv2密钥')
|
||||
pay_cert_path = models.CharField(
|
||||
max_length=512, blank=True, default='',
|
||||
verbose_name='收款商户证书路径',
|
||||
help_text='apiclient_cert.pem,退款/V2 证书用',
|
||||
)
|
||||
pay_key_path = models.CharField(
|
||||
max_length=512, blank=True, default='',
|
||||
verbose_name='收款商户私钥路径',
|
||||
help_text='apiclient_key.pem,退款/V2 证书用',
|
||||
)
|
||||
# —— 提现/转账商户(V3;可与收款商户号不同)——
|
||||
withdraw_mch_id = models.CharField(
|
||||
max_length=32, blank=True, default='',
|
||||
verbose_name='提现商户号',
|
||||
help_text='空则回落收款商户号 mch_id',
|
||||
)
|
||||
api_v3_key = models.CharField(max_length=128, blank=True, default='', verbose_name='提现APIv3密钥')
|
||||
cert_serial_no = models.CharField(max_length=64, blank=True, default='', verbose_name='提现证书序列号')
|
||||
private_key_path = models.CharField(
|
||||
max_length=512, blank=True, default='', verbose_name='提现商户私钥路径',
|
||||
)
|
||||
platform_cert_dir = models.CharField(
|
||||
max_length=512, blank=True, default='', verbose_name='提现平台证书目录',
|
||||
)
|
||||
official_appid = models.CharField(max_length=32, blank=True, default='')
|
||||
official_secret = models.CharField(max_length=128, blank=True, default='')
|
||||
official_token = models.CharField(max_length=64, blank=True, default='')
|
||||
@@ -30,6 +52,14 @@ class Club(QModel):
|
||||
goeasy_appkey = models.CharField(max_length=64, blank=True, default='')
|
||||
goeasy_secret = models.CharField(max_length=128, blank=True, default='')
|
||||
config_json = models.JSONField(default=dict, blank=True)
|
||||
# 小程序「微信支付」入口实际走哪条通道:wechat=直连商户号;fubei=付呗(前端仍传 wechat)
|
||||
mini_pay_channel = models.CharField(
|
||||
max_length=16,
|
||||
blank=True,
|
||||
default='wechat',
|
||||
verbose_name='小程序收款通道',
|
||||
help_text='wechat=微信直连;fubei=付呗;默认 wechat,不影响现网',
|
||||
)
|
||||
company_uuid = models.BinaryField(max_length=16, null=True, blank=True)
|
||||
sort_order = models.IntegerField(default=0)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
@@ -40,12 +70,81 @@ class Club(QModel):
|
||||
verbose_name = '俱乐部'
|
||||
|
||||
|
||||
class ClubPaymentChannel(QModel):
|
||||
"""俱乐部支付通道配置(可多条;同 channel 用 is_default 选主配置)。"""
|
||||
CHANNEL_CHOICES = (
|
||||
('wechat_jsapi', '微信直连-小程序JSAPI'),
|
||||
('wechat_v3', '微信直连-V3转账/退款'),
|
||||
('alipay_wap', '支付宝-手机网站'),
|
||||
('fubei_wx_mini', '付呗-微信小程序'),
|
||||
)
|
||||
|
||||
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
|
||||
channel = models.CharField(max_length=32, choices=CHANNEL_CHOICES, db_index=True)
|
||||
name = models.CharField(max_length=64, verbose_name='配置名称', help_text='如:主付呗、备用微信')
|
||||
enabled = models.IntegerField(default=1, verbose_name='1启用0停用')
|
||||
is_default = models.BooleanField(default=True, verbose_name='同通道默认配置')
|
||||
sort_order = models.IntegerField(default=0, verbose_name='前端展示顺序')
|
||||
sandbox = models.BooleanField(default=False, verbose_name='沙箱/联调')
|
||||
app_id = models.CharField(max_length=64, blank=True, default='', verbose_name='AppID/付呗ID')
|
||||
mch_id = models.CharField(max_length=64, blank=True, default='', verbose_name='商户号')
|
||||
api_key_enc = models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')
|
||||
private_key_enc = models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')
|
||||
public_key_enc = models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')
|
||||
cert_serial_no = models.CharField(max_length=64, blank=True, default='')
|
||||
private_key_path = models.CharField(max_length=255, blank=True, default='')
|
||||
platform_cert_dir = models.CharField(max_length=255, blank=True, default='')
|
||||
notify_url = models.CharField(max_length=512, blank=True, default='', verbose_name='异步回调URL')
|
||||
return_url = models.CharField(max_length=512, blank=True, default='', verbose_name='同步返回URL')
|
||||
gateway_url = models.CharField(max_length=255, blank=True, default='', verbose_name='API网关')
|
||||
mini_app_id = models.CharField(
|
||||
max_length=32, blank=True, default='',
|
||||
verbose_name='支付用小程序AppID',
|
||||
help_text='付呗/微信JSAPI;空则回落 club.wx_appid',
|
||||
)
|
||||
extra_json = models.JSONField(blank=True, default=dict, verbose_name='扩展配置')
|
||||
remark = models.CharField(max_length=255, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_payment_channel'
|
||||
verbose_name = '俱乐部支付通道'
|
||||
unique_together = [['club_id', 'channel', 'name']]
|
||||
indexes = [
|
||||
models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
|
||||
models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
|
||||
]
|
||||
|
||||
|
||||
class PayChannelLedger(QModel):
|
||||
"""下单时记录实际支付通道,供回调路由与退款选用(前端无感知)。"""
|
||||
out_trade_no = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='商户订单号')
|
||||
club_id = models.CharField(max_length=16, db_index=True, default='')
|
||||
channel = models.CharField(max_length=32, db_index=True, verbose_name='通道')
|
||||
channel_config_id = models.IntegerField(null=True, blank=True)
|
||||
biz_type = models.CharField(max_length=16, blank=True, default='', verbose_name='order/czjilu')
|
||||
provider_order_sn = models.CharField(max_length=64, blank=True, default='', verbose_name='通道侧订单号')
|
||||
amount_yuan = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'pay_channel_ledger'
|
||||
verbose_name = '支付通道流水'
|
||||
|
||||
|
||||
class UserWxOpenid(QModel):
|
||||
"""微信 openid 与俱乐部用户绑定(club_id + openid 唯一)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
openid = models.CharField(max_length=64, db_index=True)
|
||||
yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID')
|
||||
unionid = models.CharField(max_length=64, null=True, blank=True)
|
||||
# 小程序与服务号 openid 不同;同用户同俱乐部可各绑一条,支付时按 AppID 选取
|
||||
wx_appid = models.CharField(
|
||||
max_length=32, blank=True, default='', db_index=True,
|
||||
help_text='产生该 openid 的 AppID:小程序或服务号;空=历史小程序绑定',
|
||||
)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
@@ -147,6 +246,12 @@ class ClubShangpinLeixingConfig(QModel):
|
||||
is_enabled = models.BooleanField(default=True, verbose_name='是否上架')
|
||||
tupian_url = models.CharField(max_length=500, blank=True, default='', verbose_name='俱乐部专属图标')
|
||||
paixu = models.IntegerField(default=0, verbose_name='排序权重')
|
||||
# 新字段:旧小程序忽略;开启后该类型下真单强制展示价格(不改 hasRequiredMember 抢单逻辑)
|
||||
show_price = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='强制展示订单价格',
|
||||
help_text='开启后小程序该类型订单显示分成价格,即使未开通对应会员',
|
||||
)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -316,6 +421,24 @@ class ClubDashouExamPass(QModel):
|
||||
unique_together = [['club_id', 'yonghuid']]
|
||||
|
||||
|
||||
class ClubRechargePageCopy(QModel):
|
||||
"""会员充值页 / 履约金页按俱乐部可配置的文案与图标。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
|
||||
page_key = models.CharField(max_length=16, db_index=True, verbose_name='huiyuan|yajin')
|
||||
region_key = models.CharField(max_length=32, db_index=True, verbose_name='rules|benefits|advantages')
|
||||
title = models.CharField(max_length=64, blank=True, default='', verbose_name='区域标题')
|
||||
items_json = models.JSONField(default=list, blank=True, verbose_name='条目列表JSON')
|
||||
enabled = models.BooleanField(default=True, verbose_name='是否启用该区域')
|
||||
sort_order = models.IntegerField(default=0)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_recharge_page_copy'
|
||||
unique_together = [['club_id', 'page_key', 'region_key']]
|
||||
verbose_name = '充值页文案配置'
|
||||
|
||||
|
||||
class KefuMenuPage(QModel):
|
||||
"""客服后台左侧菜单页(固定 page_id,与前端路由对应)。"""
|
||||
page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID')
|
||||
@@ -332,3 +455,452 @@ class KefuMenuPage(QModel):
|
||||
class Meta:
|
||||
db_table = 'kefu_menu_page'
|
||||
ordering = ['sort_order', 'page_id']
|
||||
|
||||
|
||||
class ClubOrderGrabLink(QModel):
|
||||
"""
|
||||
俱乐部订单单向互通抢单。
|
||||
grabber_club_id 的打手可抢 order_club_id 的待接单;反向需另建一条。
|
||||
关停:删除记录即可(两字段,无开关列)。
|
||||
"""
|
||||
grabber_club_id = models.CharField(max_length=16, db_index=True, verbose_name='抢单方俱乐部')
|
||||
order_club_id = models.CharField(max_length=16, db_index=True, verbose_name='被抢订单所属俱乐部')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_order_grab_link'
|
||||
verbose_name = '俱乐部订单互通抢单'
|
||||
unique_together = [['grabber_club_id', 'order_club_id']]
|
||||
indexes = [
|
||||
models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'),
|
||||
models.Index(fields=['order_club_id'], name='idx_cogl_order'),
|
||||
]
|
||||
|
||||
|
||||
# ==================== 资金冻结(默认关闭,按俱乐部×角色开启)====================
|
||||
|
||||
class ClubFundFreezeConfig(QModel):
|
||||
"""俱乐部×角色冻结规则。enabled=False 时入账行为与现网完全一致。"""
|
||||
ROLE_CHOICES = (
|
||||
('dashou', '打手'),
|
||||
('guanshi', '管事'),
|
||||
('zuzhang', '组长'),
|
||||
('shenheguan', '考核官'),
|
||||
)
|
||||
FREEZE_MODE_CHOICES = (
|
||||
(1, '按比例'),
|
||||
(2, '固定额'),
|
||||
(3, '比例+单笔上限'),
|
||||
)
|
||||
UNFREEZE_TRIGGER_CHOICES = (
|
||||
('time_only', '仅到期'),
|
||||
('condition_only', '仅条件达标'),
|
||||
('time_and_condition', '到期且达标'),
|
||||
('time_or_condition', '到期或达标'),
|
||||
('manual_only', '仅手动'),
|
||||
)
|
||||
SCHEDULE_MODE_CHOICES = (
|
||||
('all_at_once', '一次全解'),
|
||||
('installment', '分期解冻'),
|
||||
)
|
||||
|
||||
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部')
|
||||
role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True, verbose_name='角色')
|
||||
enabled = models.BooleanField(default=False, verbose_name='是否启用冻结')
|
||||
freeze_mode = models.SmallIntegerField(default=1, choices=FREEZE_MODE_CHOICES)
|
||||
freeze_rate = models.DecimalField(
|
||||
max_digits=8, decimal_places=6, default=Decimal('0'),
|
||||
verbose_name='冻结比例', help_text='0.2=20%',
|
||||
)
|
||||
freeze_fixed = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0'))
|
||||
freeze_cap = models.DecimalField(
|
||||
max_digits=12, decimal_places=2, default=Decimal('0'),
|
||||
verbose_name='单笔冻结上限(0=不限)',
|
||||
)
|
||||
min_credit_to_freeze = models.DecimalField(
|
||||
max_digits=12, decimal_places=2, default=Decimal('0'),
|
||||
verbose_name='低于此入账不冻',
|
||||
)
|
||||
source_scope = models.JSONField(
|
||||
default=list, blank=True,
|
||||
verbose_name='入账来源白名单',
|
||||
help_text='空=全部来源;如 ["order_settle"]',
|
||||
)
|
||||
unfreeze_trigger = models.CharField(
|
||||
max_length=32, default='time_only', choices=UNFREEZE_TRIGGER_CHOICES,
|
||||
)
|
||||
schedule_mode = models.CharField(
|
||||
max_length=16, default='all_at_once', choices=SCHEDULE_MODE_CHOICES,
|
||||
)
|
||||
installment_json = models.JSONField(
|
||||
default=dict, blank=True,
|
||||
verbose_name='分期计划',
|
||||
help_text='{"steps":[{"seq":1,"after_days":7,"type":"ratio","value":0.3,"need_condition":false}]}',
|
||||
)
|
||||
condition_json = models.JSONField(default=dict, blank=True, verbose_name='解冻条件')
|
||||
freeze_min_days = models.IntegerField(default=0, verbose_name='最短冻结天数')
|
||||
remark = models.CharField(max_length=255, blank=True, default='')
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_fund_freeze_config'
|
||||
verbose_name = '资金冻结规则'
|
||||
unique_together = [['club_id', 'role']]
|
||||
|
||||
|
||||
class FundFreezeLedger(QModel):
|
||||
"""单笔入账冻结主单。"""
|
||||
STATUS_FROZEN = 1
|
||||
STATUS_PARTIAL = 2
|
||||
STATUS_DONE = 3
|
||||
STATUS_VOID = 4
|
||||
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
user_id = models.CharField(max_length=16, db_index=True, verbose_name='UserUID')
|
||||
role = models.CharField(max_length=16, db_index=True)
|
||||
amount_total = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
amount_unfrozen = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0'))
|
||||
status = models.SmallIntegerField(default=1, db_index=True)
|
||||
frozen_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
source_type = models.CharField(max_length=32, db_index=True)
|
||||
order_id = models.CharField(max_length=64, blank=True, default='', db_index=True)
|
||||
czjilu_id = models.CharField(max_length=64, blank=True, default='', db_index=True)
|
||||
biz_id = models.CharField(max_length=64, verbose_name='幂等业务键')
|
||||
freeze_reason = models.CharField(max_length=255, blank=True, default='')
|
||||
config_snapshot = models.JSONField(default=dict, blank=True)
|
||||
current_step = models.IntegerField(default=1)
|
||||
next_eligible_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'fund_freeze_ledger'
|
||||
verbose_name = '资金冻结主单'
|
||||
unique_together = [['club_id', 'role', 'source_type', 'biz_id']]
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'),
|
||||
models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'),
|
||||
models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'),
|
||||
]
|
||||
|
||||
|
||||
class FundUnfreezeEvent(QModel):
|
||||
"""每一次解冻执行记录。"""
|
||||
ledger_id = models.BigIntegerField(db_index=True)
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
user_id = models.CharField(max_length=16, db_index=True)
|
||||
role = models.CharField(max_length=16)
|
||||
step_seq = models.IntegerField(default=1)
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
unfrozen_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
trigger = models.CharField(max_length=16, default='schedule')
|
||||
reason = models.CharField(max_length=255, blank=True, default='')
|
||||
operator_id = models.CharField(max_length=32, blank=True, default='')
|
||||
condition_snapshot = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'fund_unfreeze_event'
|
||||
verbose_name = '资金解冻事件'
|
||||
|
||||
|
||||
class FundRiskStatDaily(QModel):
|
||||
"""日汇总:罚款/投诉/完成单等,供解冻条件与后台展示。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
user_id = models.CharField(max_length=16, db_index=True)
|
||||
role = models.CharField(max_length=16, db_index=True)
|
||||
day = models.DateField(db_index=True)
|
||||
completed_orders = models.IntegerField(default=0)
|
||||
fine_count = models.IntegerField(default=0)
|
||||
complaint_count = models.IntegerField(default=0)
|
||||
valid_invite_count = models.IntegerField(default=0)
|
||||
meta_json = models.JSONField(default=dict, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'fund_risk_stat_daily'
|
||||
verbose_name = '资金风险日汇总'
|
||||
unique_together = [['club_id', 'user_id', 'role', 'day']]
|
||||
indexes = [
|
||||
models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'),
|
||||
]
|
||||
|
||||
|
||||
class ClubRoleAgreement(QModel):
|
||||
"""俱乐部×身份用户协议(当前生效配置,发布时 version+1)。"""
|
||||
ROLE_CHOICES = (
|
||||
('dashou', '打手'),
|
||||
('guanshi', '管事'),
|
||||
('zuzhang', '组长'),
|
||||
)
|
||||
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True)
|
||||
enabled = models.BooleanField(default=False, verbose_name='是否要求签署')
|
||||
title = models.CharField(max_length=128, blank=True, default='', verbose_name='协议标题')
|
||||
content_text = models.TextField(blank=True, default='', verbose_name='协议正文')
|
||||
images_json = models.JSONField(default=list, blank=True, verbose_name='说明图片URL列表')
|
||||
# 勾选前强制停留秒数,0=可立即勾选
|
||||
read_seconds = models.PositiveIntegerField(default=2, verbose_name='阅读秒数')
|
||||
# 卡点开关:qiangdan/register/tixian/invite,按角色默认见服务层
|
||||
gate_config = models.JSONField(default=dict, blank=True, verbose_name='签署卡点配置')
|
||||
version = models.PositiveIntegerField(default=0, verbose_name='已发布版本号')
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_role_agreement'
|
||||
verbose_name = '角色协议'
|
||||
unique_together = [['club_id', 'role']]
|
||||
|
||||
|
||||
class ClubRoleAgreementSign(QModel):
|
||||
"""用户签署某俱乐部某身份某版本协议的留痕(勾选确认,无手写图)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
user_id = models.CharField(max_length=16, db_index=True, verbose_name='UserUID')
|
||||
role = models.CharField(max_length=16, db_index=True)
|
||||
agreement_id = models.BigIntegerField(db_index=True, default=0)
|
||||
version = models.PositiveIntegerField(db_index=True)
|
||||
title_snapshot = models.CharField(max_length=128, blank=True, default='')
|
||||
content_snapshot = models.TextField(blank=True, default='')
|
||||
images_snapshot = models.JSONField(default=list, blank=True)
|
||||
# 兼容旧字段,新签署固定留空
|
||||
sign_image_url = models.CharField(max_length=512, blank=True, default='')
|
||||
client_meta = models.JSONField(default=dict, blank=True)
|
||||
signed_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_role_agreement_sign'
|
||||
verbose_name = '角色协议签署记录'
|
||||
unique_together = [['club_id', 'user_id', 'role', 'version']]
|
||||
indexes = [
|
||||
models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
|
||||
models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
|
||||
]
|
||||
|
||||
|
||||
class ClubOrderDealConfig(QModel):
|
||||
"""俱乐部:成交指标统计开关 + 可控自动结算开关(默认全关)。"""
|
||||
club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部')
|
||||
auto_settle_enabled = models.BooleanField(default=False, verbose_name='自动结算开关')
|
||||
auto_settle_enabled_at = models.DateTimeField(null=True, blank=True, verbose_name='本次打开自动结算时间')
|
||||
hours_platform = models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')
|
||||
hours_merchant = models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')
|
||||
stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关')
|
||||
stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点')
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_order_deal_config'
|
||||
verbose_name = '成交与自动结算配置'
|
||||
|
||||
|
||||
class MerchantDealStat(QModel):
|
||||
"""商家成交/罚款/退款指标快照(仅统计起点之后事件累加)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
merchant_uid = models.CharField(max_length=16, db_index=True, verbose_name='商家UserUID')
|
||||
order_count = models.PositiveIntegerField(default=0)
|
||||
completed_count = models.PositiveIntegerField(default=0)
|
||||
refund_count = models.PositiveIntegerField(default=0)
|
||||
fine_paid_count = models.PositiveIntegerField(default=0)
|
||||
settle_duration_sum_seconds = models.BigIntegerField(default=0)
|
||||
settle_duration_sample_count = models.PositiveIntegerField(default=0)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'merchant_deal_stat'
|
||||
verbose_name = '商家成交指标'
|
||||
unique_together = [['club_id', 'merchant_uid']]
|
||||
|
||||
|
||||
class DashouDealStat(QModel):
|
||||
"""打手成交/被罚款指标快照。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
dashou_uid = models.CharField(max_length=16, db_index=True, verbose_name='打手UserUID')
|
||||
order_count = models.PositiveIntegerField(default=0)
|
||||
completed_count = models.PositiveIntegerField(default=0)
|
||||
fine_paid_count = models.PositiveIntegerField(default=0)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'dashou_deal_stat'
|
||||
verbose_name = '打手成交指标'
|
||||
unique_together = [['club_id', 'dashou_uid']]
|
||||
|
||||
|
||||
class DealStatEvent(QModel):
|
||||
"""指标幂等事件(防重复记账)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
event_key = models.CharField(max_length=96, unique=True, db_index=True)
|
||||
event_type = models.CharField(max_length=32, db_index=True)
|
||||
order_id = models.CharField(max_length=32, blank=True, default='', db_index=True)
|
||||
subject_uid = models.CharField(max_length=16, blank=True, default='', db_index=True)
|
||||
meta_json = models.JSONField(default=dict, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'deal_stat_event'
|
||||
verbose_name = '成交指标事件'
|
||||
|
||||
|
||||
# ==================== 跨俱乐部资产迁移(独立模块,默认关闭不影响现网)====================
|
||||
|
||||
class ClubMigratePlan(QModel):
|
||||
"""迁移计划:from_club → to_club,未启用时小程序完全无感。"""
|
||||
plan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='计划ID')
|
||||
name = models.CharField(max_length=64, blank=True, default='', verbose_name='计划名称')
|
||||
from_club_id = models.CharField(max_length=16, db_index=True, verbose_name='源俱乐部')
|
||||
to_club_id = models.CharField(max_length=16, db_index=True, verbose_name='目标俱乐部')
|
||||
enabled = models.BooleanField(default=False, db_index=True, verbose_name='是否启用')
|
||||
# null=永久;有值则必须在区间内
|
||||
start_at = models.DateTimeField(null=True, blank=True, verbose_name='开始时间')
|
||||
end_at = models.DateTimeField(null=True, blank=True, verbose_name='结束时间')
|
||||
# ["dashou.yue","dashou.yajin","dashou.jifen","dashou.dongjie_yue","dashou.huiyuan", ...]
|
||||
asset_whitelist = models.JSONField(default=list, blank=True, verbose_name='资产白名单')
|
||||
# {"require_no_unpaid_penalty":true,"require_orders_closed":false,"require_not_banned":true}
|
||||
condition_json = models.JSONField(default=dict, blank=True, verbose_name='门槛条件')
|
||||
force_intercept = models.BooleanField(default=True, verbose_name='源店强制迁移页')
|
||||
# ["dashou"] 首期默认打手
|
||||
force_roles = models.JSONField(default=list, blank=True, verbose_name='强制角色列表')
|
||||
default_once_per_user = models.BooleanField(default=True, verbose_name='默认每人成功一次')
|
||||
remark = models.CharField(max_length=255, blank=True, default='')
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_plan'
|
||||
verbose_name = '跨店资产迁移计划'
|
||||
indexes = [
|
||||
models.Index(fields=['from_club_id', 'enabled'], name='idx_cmp_from_en'),
|
||||
models.Index(fields=['to_club_id', 'enabled'], name='idx_cmp_to_en'),
|
||||
]
|
||||
|
||||
|
||||
class ClubMigrateToken(QModel):
|
||||
"""一次性迁移令牌(设密后签发,确认后作废)。"""
|
||||
STATUS_PENDING_PASSWORD = 0
|
||||
STATUS_ISSUED = 1
|
||||
STATUS_CONSUMED = 2
|
||||
STATUS_EXPIRED = 3
|
||||
STATUS_CANCELLED = 4
|
||||
|
||||
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='令牌')
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
from_club_id = models.CharField(max_length=16, db_index=True)
|
||||
to_club_id = models.CharField(max_length=16, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True, verbose_name='源UserUID')
|
||||
password_hash = models.CharField(max_length=128, blank=True, default='', verbose_name='转移密码哈希')
|
||||
password_salt = models.CharField(max_length=32, blank=True, default='')
|
||||
status = models.SmallIntegerField(default=0, db_index=True)
|
||||
fail_count = models.PositiveIntegerField(default=0, verbose_name='验密失败次数')
|
||||
asset_snapshot = models.JSONField(default=dict, blank=True, verbose_name='发码时资产快照')
|
||||
qrcode_url = models.CharField(max_length=512, blank=True, default='', verbose_name='目标店小程序码URL')
|
||||
expire_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
consumed_at = models.DateTimeField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_token'
|
||||
verbose_name = '跨店迁移令牌'
|
||||
indexes = [
|
||||
models.Index(fields=['plan_id', 'from_uid', 'status'], name='idx_cmt_plan_uid_st'),
|
||||
]
|
||||
|
||||
|
||||
class ClubMigrateConsumption(QModel):
|
||||
"""计划×源用户成功占用(防并发双迁;默认一次)。"""
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True)
|
||||
success_count = models.PositiveIntegerField(default=0)
|
||||
last_ledger_id = models.BigIntegerField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_consumption'
|
||||
verbose_name = '跨店迁移占用'
|
||||
unique_together = [['plan_id', 'from_uid']]
|
||||
|
||||
|
||||
class ClubMigrateUserQuota(QModel):
|
||||
"""例外:指定源用户在某计划下可额外成功次数。"""
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True)
|
||||
max_extra_times = models.PositiveIntegerField(default=1, verbose_name='额外成功次数')
|
||||
reason = models.CharField(max_length=255, blank=True, default='')
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_user_quota'
|
||||
verbose_name = '跨店迁移二次白名单'
|
||||
unique_together = [['plan_id', 'from_uid']]
|
||||
|
||||
|
||||
class ClubMigrateLedger(QModel):
|
||||
"""一次成功过户主单(P0 溯源)。"""
|
||||
STATUS_SUCCESS = 1
|
||||
STATUS_REVERSED = 2
|
||||
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
token = models.CharField(max_length=64, blank=True, default='', db_index=True)
|
||||
from_club_id = models.CharField(max_length=16, db_index=True)
|
||||
to_club_id = models.CharField(max_length=16, db_index=True)
|
||||
from_uid = models.CharField(max_length=16, db_index=True)
|
||||
to_uid = models.CharField(max_length=16, db_index=True)
|
||||
migrate_seq = models.PositiveIntegerField(default=1, verbose_name='本计划第几次')
|
||||
status = models.SmallIntegerField(default=1, db_index=True)
|
||||
summary_json = models.JSONField(default=dict, blank=True, verbose_name='摘要')
|
||||
client_meta = models.JSONField(default=dict, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_ledger'
|
||||
verbose_name = '跨店迁移流水主单'
|
||||
indexes = [
|
||||
models.Index(fields=['from_uid', 'plan_id'], name='idx_cml_from_plan'),
|
||||
models.Index(fields=['to_uid', 'plan_id'], name='idx_cml_to_plan'),
|
||||
models.Index(fields=['from_club_id', 'to_club_id', 'CreateTime'], name='idx_cml_clubs_t'),
|
||||
]
|
||||
|
||||
|
||||
class ClubMigrateLedgerItem(QModel):
|
||||
"""流水明细:每个角色字段一行(金额前后值必填,便于对账纠错)。"""
|
||||
ITEM_BALANCE = 'balance'
|
||||
ITEM_HUIYUAN = 'huiyuan'
|
||||
|
||||
ledger_id = models.BigIntegerField(db_index=True)
|
||||
plan_id = models.CharField(max_length=32, db_index=True)
|
||||
item_type = models.CharField(max_length=16, default='balance', db_index=True)
|
||||
role = models.CharField(max_length=16, db_index=True)
|
||||
field = models.CharField(max_length=32, blank=True, default='', verbose_name='字段名')
|
||||
amount = models.DecimalField(max_digits=14, decimal_places=2, default=Decimal('0'))
|
||||
before_src = models.CharField(max_length=64, blank=True, default='')
|
||||
after_src = models.CharField(max_length=64, blank=True, default='')
|
||||
before_dst = models.CharField(max_length=64, blank=True, default='')
|
||||
after_dst = models.CharField(max_length=64, blank=True, default='')
|
||||
# 会员专用
|
||||
huiyuan_id = models.CharField(max_length=16, blank=True, default='', db_index=True)
|
||||
meta_json = models.JSONField(default=dict, blank=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_migrate_ledger_item'
|
||||
verbose_name = '跨店迁移流水明细'
|
||||
indexes = [
|
||||
models.Index(fields=['ledger_id', 'role'], name='idx_cmli_led_role'),
|
||||
]
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
"""admin_assignment 任职 CRUD(修复停用不生效、重复插入)。"""
|
||||
"""admin_assignment 任职 CRUD。
|
||||
|
||||
集团任职用 club_id=''(禁止 NULL):MySQL 对 NULL 唯一约束失效,会无限复制,
|
||||
导致「停一条冒出五六条」。停用改为硬删除。
|
||||
"""
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL, DATA_SCOPE_SINGLE
|
||||
from jituan.constants import DATA_SCOPE_ALL, DATA_SCOPE_SINGLE, SUPER_ADMIN_PHONES
|
||||
from jituan.models import AdminAssignment
|
||||
|
||||
GROUP_ROLE_CODES = frozenset({
|
||||
'GROUP_OWNER',
|
||||
'GROUP_SUPER_ADMIN',
|
||||
'GROUP_AFTER_SALES',
|
||||
'GROUP_FINANCE',
|
||||
'GROUP_CONFIG',
|
||||
})
|
||||
|
||||
|
||||
def normalize_club_id(club_id):
|
||||
"""集团级统一成空串,禁止 NULL(避免唯一索引失效)。"""
|
||||
if club_id is None:
|
||||
return ''
|
||||
s = str(club_id).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _club_filter(qs, club_id):
|
||||
if club_id is None:
|
||||
cid = normalize_club_id(club_id)
|
||||
if not cid:
|
||||
return qs.filter(Q(club_id__isnull=True) | Q(club_id=''))
|
||||
return qs.filter(club_id=club_id)
|
||||
return qs.filter(club_id=cid)
|
||||
|
||||
|
||||
def find_assignment(yonghuid, club_id, role_code):
|
||||
qs = AdminAssignment.query.filter(yonghuid=yonghuid, role_code=role_code)
|
||||
return _club_filter(qs, club_id).first()
|
||||
return _club_filter(qs, club_id).order_by('-id').first()
|
||||
|
||||
|
||||
def find_active_assignment(yonghuid, club_id, role_code):
|
||||
qs = AdminAssignment.query.filter(yonghuid=yonghuid, role_code=role_code, status=1)
|
||||
return _club_filter(qs, club_id).first()
|
||||
return _club_filter(qs, club_id).order_by('-id').first()
|
||||
|
||||
|
||||
def clear_other_primary_assignments(yonghuid, except_id=None):
|
||||
@@ -38,6 +59,25 @@ def _clear_other_primary(yonghuid, except_id=None):
|
||||
clear_other_primary_assignments(yonghuid, except_id=except_id)
|
||||
|
||||
|
||||
def _strip_system_super_if_needed(yonghuid):
|
||||
yonghuid = (yonghuid or '').strip()
|
||||
if not yonghuid:
|
||||
return
|
||||
try:
|
||||
from users.business_models import User
|
||||
except Exception:
|
||||
return
|
||||
user = User.objects.filter(UserUID=yonghuid).first()
|
||||
if not user:
|
||||
return
|
||||
if (user.Phone or '') in SUPER_ADMIN_PHONES:
|
||||
return
|
||||
if user.IsSuperuser or user.IsStaff:
|
||||
user.IsSuperuser = False
|
||||
user.IsStaff = False
|
||||
user.save(update_fields=['IsSuperuser', 'IsStaff'])
|
||||
|
||||
|
||||
def create_or_reactivate_assignment(
|
||||
yonghuid,
|
||||
club_id,
|
||||
@@ -46,61 +86,98 @@ def create_or_reactivate_assignment(
|
||||
is_primary,
|
||||
granted_by,
|
||||
):
|
||||
active = find_active_assignment(yonghuid, club_id, role_code)
|
||||
if active:
|
||||
club_id = normalize_club_id(club_id)
|
||||
if not club_id:
|
||||
data_scope = DATA_SCOPE_ALL
|
||||
|
||||
# 创建前先清同键垃圾(含 NULL/空串/status=0 重复)
|
||||
junk = _club_filter(
|
||||
AdminAssignment.objects.filter(yonghuid=yonghuid, role_code=role_code),
|
||||
club_id,
|
||||
)
|
||||
active = junk.filter(status=1).order_by('-id')
|
||||
if active.exists():
|
||||
keep = active.first()
|
||||
junk.exclude(pk=keep.pk).delete()
|
||||
return None, '该任职记录已存在且有效'
|
||||
|
||||
junk.delete()
|
||||
|
||||
if is_primary:
|
||||
_clear_other_primary(yonghuid)
|
||||
|
||||
inactive = find_assignment(yonghuid, club_id, role_code)
|
||||
if inactive and inactive.status != 1:
|
||||
if is_primary:
|
||||
_clear_other_primary(yonghuid)
|
||||
AdminAssignment.objects.filter(pk=inactive.id).update(
|
||||
status=1,
|
||||
data_scope=data_scope,
|
||||
is_primary=is_primary,
|
||||
granted_by=granted_by,
|
||||
UpdateTime=timezone.now(),
|
||||
)
|
||||
return inactive.id, None
|
||||
|
||||
row = AdminAssignment.query.create(
|
||||
row = AdminAssignment.objects.create(
|
||||
yonghuid=yonghuid,
|
||||
club_id=club_id,
|
||||
role_code=role_code,
|
||||
data_scope=data_scope,
|
||||
is_primary=is_primary,
|
||||
granted_by=granted_by,
|
||||
granted_by=granted_by or '',
|
||||
status=1,
|
||||
)
|
||||
return row.id, None
|
||||
|
||||
|
||||
def _is_group_level(row):
|
||||
return (
|
||||
not normalize_club_id(row.club_id)
|
||||
or (row.data_scope or '') == DATA_SCOPE_ALL
|
||||
or (row.role_code or '') in GROUP_ROLE_CODES
|
||||
)
|
||||
|
||||
|
||||
def deactivate_assignment(assignment_id):
|
||||
"""停用=硬删除。集团级则删光该人全部集团任职并摘 IsSuperuser。"""
|
||||
try:
|
||||
pk = int(assignment_id)
|
||||
except (TypeError, ValueError):
|
||||
return False, '任职 id 无效'
|
||||
|
||||
updated = AdminAssignment.objects.filter(pk=pk, status=1).update(
|
||||
status=0,
|
||||
UpdateTime=timezone.now(),
|
||||
)
|
||||
if updated:
|
||||
row = AdminAssignment.objects.filter(pk=pk).first()
|
||||
if not row:
|
||||
# 可能已被删,幂等成功
|
||||
return True, None
|
||||
|
||||
exists = AdminAssignment.objects.filter(pk=pk).exists()
|
||||
if exists:
|
||||
yonghuid = row.yonghuid
|
||||
role_code = row.role_code
|
||||
club_id = row.club_id
|
||||
group_level = _is_group_level(row)
|
||||
|
||||
# 同人同角色同俱乐部(含 NULL/'')全部硬删
|
||||
_club_filter(
|
||||
AdminAssignment.objects.filter(yonghuid=yonghuid, role_code=role_code),
|
||||
club_id,
|
||||
).delete()
|
||||
|
||||
if group_level:
|
||||
AdminAssignment.objects.filter(yonghuid=yonghuid).filter(
|
||||
Q(club_id__isnull=True)
|
||||
| Q(club_id='')
|
||||
| Q(data_scope=DATA_SCOPE_ALL)
|
||||
| Q(role_code__in=list(GROUP_ROLE_CODES))
|
||||
).delete()
|
||||
_strip_system_super_if_needed(yonghuid)
|
||||
|
||||
return True, None
|
||||
return False, '任职记录不存在'
|
||||
|
||||
|
||||
def deactivate_all_assignments_for_user(yonghuid):
|
||||
"""账号禁用:硬删全部任职 + 摘超管。"""
|
||||
yonghuid = (yonghuid or '').strip()
|
||||
if not yonghuid:
|
||||
return 0
|
||||
deleted, _ = AdminAssignment.objects.filter(yonghuid=yonghuid).delete()
|
||||
_strip_system_super_if_needed(yonghuid)
|
||||
return deleted
|
||||
|
||||
|
||||
def dedupe_active_assignments():
|
||||
"""停用重复的有效任职,每组 (yonghuid, club_id, role_code) 只保留一条。"""
|
||||
"""硬删重复任职;并把 club_id=NULL 归一成 ''。"""
|
||||
# 归一 NULL -> ''
|
||||
AdminAssignment.objects.filter(club_id__isnull=True).update(club_id='')
|
||||
|
||||
rows = list(
|
||||
AdminAssignment.query.filter(status=1).order_by('yonghuid', 'club_id', 'role_code', 'id')
|
||||
AdminAssignment.objects.filter(status=1).order_by('yonghuid', 'club_id', 'role_code', 'id')
|
||||
)
|
||||
seen = {}
|
||||
dup_ids = []
|
||||
@@ -111,8 +188,5 @@ def dedupe_active_assignments():
|
||||
else:
|
||||
seen[key] = row.id
|
||||
if dup_ids:
|
||||
AdminAssignment.objects.filter(pk__in=dup_ids).update(
|
||||
status=0,
|
||||
UpdateTime=timezone.now(),
|
||||
)
|
||||
AdminAssignment.objects.filter(pk__in=dup_ids).delete()
|
||||
return len(dup_ids)
|
||||
|
||||
@@ -149,16 +149,40 @@ def build_admin_club_context(user):
|
||||
|
||||
|
||||
def is_system_super_admin(user):
|
||||
"""系统级超管(Django 超管 / admin 账号 / 白名单手机号),非俱乐部 000001 角色。"""
|
||||
return (
|
||||
bool(user.IsSuperuser)
|
||||
or user.UserType == 'admin'
|
||||
or getattr(user, 'Phone', '') in SUPER_ADMIN_PHONES
|
||||
)
|
||||
"""
|
||||
系统级超管。
|
||||
白名单手机号:始终超管。
|
||||
其它账号:仅看 IsSuperuser / UserType=admin;停用集团任职时会清掉 IsSuperuser,
|
||||
避免「任职停了仍是集团上帝」。
|
||||
"""
|
||||
if getattr(user, 'Phone', '') in SUPER_ADMIN_PHONES:
|
||||
return True
|
||||
return bool(user.IsSuperuser) or user.UserType == 'admin'
|
||||
|
||||
|
||||
def is_hardcoded_super_phone(user):
|
||||
"""仅代码白名单手机号(不可被后台「禁用」锁死本人)。"""
|
||||
return getattr(user, 'Phone', '') in SUPER_ADMIN_PHONES
|
||||
|
||||
|
||||
def is_kefu_account_disabled(user):
|
||||
"""
|
||||
客服扩展表已禁用。
|
||||
非白名单账号:即使挂了 IsSuperuser 也必须认 zhuangtai(否则后台点禁用等于摆设)。
|
||||
"""
|
||||
if is_hardcoded_super_phone(user):
|
||||
return False
|
||||
try:
|
||||
kefu = user.KefuProfile
|
||||
except Exception:
|
||||
return False
|
||||
return int(getattr(kefu, 'zhuangtai', 1) or 0) != 1
|
||||
|
||||
|
||||
def is_kefu_backend_account(user):
|
||||
"""可登录客服后台:超管 / UserType=kefu / 有正常 KefuProfile。"""
|
||||
if is_kefu_account_disabled(user):
|
||||
return False
|
||||
if is_system_super_admin(user):
|
||||
return True
|
||||
if getattr(user, 'UserType', '') == 'kefu':
|
||||
@@ -190,27 +214,36 @@ def get_switchable_club_ids(user):
|
||||
|
||||
def can_manage_roles_for_club(user, permissions, club_id):
|
||||
"""
|
||||
是否可为指定俱乐部分配/移除功能角色。
|
||||
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理权限。
|
||||
是否可为指定俱乐部分配/移除功能角色、添加本店后台用户。
|
||||
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理(不必有集团权限)。
|
||||
"""
|
||||
if can_manage_admin_assignments(user, permissions):
|
||||
return True
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return False
|
||||
from jituan.services.club_rbac import resolve_permission_codes_for_club
|
||||
codes = resolve_permission_codes_for_club(user, cid)
|
||||
return '000001' in codes
|
||||
from jituan.constants import DATA_SCOPE_SINGLE
|
||||
from jituan.services.club_rbac import resolve_effective_permission_codes
|
||||
# 与菜单/verify_kefu 同一套:含 legacy,避免「页面能进但添加按钮出不来」
|
||||
codes = resolve_effective_permission_codes(user, cid, DATA_SCOPE_SINGLE)
|
||||
return '000001' in (codes or [])
|
||||
|
||||
|
||||
def get_manageable_club_ids(user, permissions=None):
|
||||
"""当前操作者可管理角色分配的俱乐部集合。"""
|
||||
def get_manageable_club_ids(user, permissions=None, current_club_id=None):
|
||||
"""
|
||||
当前操作者可管理角色/后台用户的俱乐部集合。
|
||||
current_club_id:若请求上下文已带 000001,务必把当前顶栏俱乐部算进去。
|
||||
"""
|
||||
if can_manage_admin_assignments(user, permissions):
|
||||
return {c.club_id for c in Club.query.filter(status=1)}
|
||||
return {
|
||||
ids = {
|
||||
c.club_id for c in Club.query.filter(status=1)
|
||||
if can_manage_roles_for_club(user, permissions, c.club_id)
|
||||
}
|
||||
cid = (current_club_id or '').strip()
|
||||
if cid and permissions and '000001' in permissions:
|
||||
ids.add(cid)
|
||||
return ids
|
||||
|
||||
|
||||
def can_access_admin_user_management(user, permissions=None):
|
||||
|
||||
@@ -18,6 +18,41 @@ from jituan.constants import CLUB_ID_DEFAULT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALIPAY_DISABLED_MSG = '支付宝支付功能暂不可用,请使用微信支付'
|
||||
|
||||
|
||||
def normalize_pay_club_id(club_id=None):
|
||||
return (club_id or '').strip().lower() or CLUB_ID_DEFAULT
|
||||
|
||||
|
||||
def is_alipay_enabled_for_club(club_id=None):
|
||||
"""
|
||||
是否允许该俱乐部「新发起」支付宝支付。
|
||||
仅 settings.ALIPAY_DISABLED_CLUB_IDS 中的俱乐部为 False;其余一律 True。
|
||||
不影响异步回调验签与查单;不影响提现支付宝收款。
|
||||
"""
|
||||
cid = normalize_pay_club_id(club_id)
|
||||
disabled = getattr(settings, 'ALIPAY_DISABLED_CLUB_IDS', None) or ()
|
||||
try:
|
||||
disabled_set = {str(x).strip().lower() for x in disabled if x is not None}
|
||||
except TypeError:
|
||||
disabled_set = set()
|
||||
return cid not in disabled_set
|
||||
|
||||
|
||||
def assert_alipay_allowed_for_club(club_id=None):
|
||||
if not is_alipay_enabled_for_club(club_id):
|
||||
raise Exception(ALIPAY_DISABLED_MSG)
|
||||
|
||||
|
||||
def alipay_pay_method_block_reason(pay_method, club_id=None):
|
||||
"""支付接口入口:仅 pay_method=alipay 且俱乐部禁用时返回文案;微信/其它一律 None。"""
|
||||
if (pay_method or '').strip().lower() != 'alipay':
|
||||
return None
|
||||
if is_alipay_enabled_for_club(club_id):
|
||||
return None
|
||||
return ALIPAY_DISABLED_MSG
|
||||
|
||||
|
||||
# ==================== 配置读取 ====================
|
||||
|
||||
@@ -33,22 +68,38 @@ def get_alipay_config(club_id=None):
|
||||
else:
|
||||
cfg = getattr(settings, 'ALIPAY_PROD', {})
|
||||
|
||||
# 充值回调优先读 secrets;空则用 settings 默认地址(绝不回落到订单 NOTIFY_URL)
|
||||
czjilu_notify = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
|
||||
order_notify = (cfg.get('NOTIFY_URL') or '').strip()
|
||||
default_czjilu = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
|
||||
if not czjilu_notify or (order_notify and czjilu_notify.rstrip('/') == order_notify.rstrip('/')):
|
||||
czjilu_notify = default_czjilu
|
||||
return {
|
||||
'APP_ID': cfg.get('APP_ID', ''),
|
||||
'PID': cfg.get('PID', ''),
|
||||
'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''),
|
||||
'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''),
|
||||
'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'),
|
||||
'NOTIFY_URL': cfg.get('NOTIFY_URL', ''),
|
||||
'NOTIFY_URL_CZJILU': cfg.get('NOTIFY_URL_CZJILU', '') or cfg.get('NOTIFY_URL', ''),
|
||||
'NOTIFY_URL': order_notify,
|
||||
'NOTIFY_URL_CZJILU': czjilu_notify,
|
||||
'RETURN_URL': cfg.get('RETURN_URL', ''),
|
||||
'sandbox': sandbox,
|
||||
}
|
||||
|
||||
|
||||
def get_czjilu_notify_url(club_id=None):
|
||||
"""获取 Czjilu 类充值(押金/积分/会员/商家/考核/罚款)的支付宝异步回调地址。"""
|
||||
return get_alipay_config(club_id)['NOTIFY_URL_CZJILU']
|
||||
"""获取 Czjilu 充值回调地址:secrets 可选覆盖,否则用 settings 写死的正确地址。
|
||||
|
||||
密钥/APP_ID 仍用原有 ALIPAY_PROD,不必再配新密钥。
|
||||
禁止回落到订单 NOTIFY_URL,避免充值通知被订单回调吞掉。
|
||||
"""
|
||||
cfg = get_alipay_config(club_id)
|
||||
notify_url = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
|
||||
if not notify_url:
|
||||
notify_url = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
|
||||
if not notify_url:
|
||||
raise Exception('缺少支付宝充值回调地址 ALIPAY_CZJILU_NOTIFY_URL')
|
||||
return notify_url
|
||||
|
||||
|
||||
def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, expire_minutes=30):
|
||||
@@ -57,6 +108,7 @@ def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, ex
|
||||
返回 {qr_code, qr_image, pay_method},并在 cache 标记该订单为支付宝订单
|
||||
(轮询接口据此跳过微信查单,直接等异步回调更新本地状态)。
|
||||
"""
|
||||
assert_alipay_allowed_for_club(club_id)
|
||||
from django.core.cache import cache
|
||||
notify_url = get_czjilu_notify_url(club_id)
|
||||
pay_url = build_wap_pay_url(
|
||||
@@ -66,6 +118,7 @@ def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, ex
|
||||
club_id=club_id,
|
||||
expire_minutes=expire_minutes,
|
||||
notify_url=notify_url,
|
||||
_skip_club_gate=True,
|
||||
)
|
||||
qr_image = qr_link_to_base64(pay_url)
|
||||
cache.set(f'alipay_pay_url_{dingdanid}', pay_url, 60 * 30)
|
||||
@@ -227,7 +280,10 @@ def verify_notify_params(params, club_id=None):
|
||||
|
||||
# ==================== wap.pay 跳转 URL ====================
|
||||
|
||||
def build_wap_pay_url(out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30, notify_url=None):
|
||||
def build_wap_pay_url(
|
||||
out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30,
|
||||
notify_url=None, _skip_club_gate=False,
|
||||
):
|
||||
"""
|
||||
生成 alipay.trade.wap.pay 跳转 URL(GET 方式)。
|
||||
|
||||
@@ -236,13 +292,16 @@ def build_wap_pay_url(out_trade_no, amount, subject, body='', club_id=None, expi
|
||||
amount: 订单金额(Decimal 或字符串,单位元)
|
||||
subject: 订单标题
|
||||
body: 订单描述(可选)
|
||||
club_id: 预留多 club(暂未使用)
|
||||
club_id: 俱乐部;命中 ALIPAY_DISABLED_CLUB_IDS 时拒绝发起
|
||||
expire_minutes: 超时关闭时间(分钟)
|
||||
notify_url: 自定义异步回调地址(可选,默认用 cfg['NOTIFY_URL'])
|
||||
_skip_club_gate: 内部已校验过时跳过(避免双重校验)
|
||||
|
||||
返回:
|
||||
完整的支付宝跳转 URL,前端 web-view 直接加载即可。
|
||||
"""
|
||||
if not _skip_club_gate:
|
||||
assert_alipay_allowed_for_club(club_id)
|
||||
cfg = get_alipay_config(club_id)
|
||||
|
||||
biz_content = {
|
||||
@@ -295,6 +354,7 @@ def build_precreate_qr(out_trade_no, amount, subject, body='', club_id=None, exp
|
||||
"""
|
||||
import requests
|
||||
|
||||
assert_alipay_allowed_for_club(club_id)
|
||||
cfg = get_alipay_config(club_id)
|
||||
|
||||
biz_content = {
|
||||
@@ -387,3 +447,61 @@ def club_id_from_czjilu(dingdan_id):
|
||||
except Exception:
|
||||
pass
|
||||
return CLUB_ID_DEFAULT
|
||||
|
||||
|
||||
# ==================== 主动查单(alipay.trade.query)====================
|
||||
|
||||
def query_alipay_trade(out_trade_no, club_id=None):
|
||||
"""主动查询支付宝交易状态。
|
||||
|
||||
调用 alipay.trade.query 接口,返回 trade_status 字符串:
|
||||
WAIT_BUYER_PAY / TRADE_CLOSED / TRADE_SUCCESS / TRADE_FINISHED
|
||||
查询失败或异常返回空字符串。
|
||||
|
||||
用于轮询补偿:支付宝异步回调未到达时,通过主动查单确认支付状态。
|
||||
"""
|
||||
import requests
|
||||
|
||||
cfg = get_alipay_config(club_id)
|
||||
|
||||
biz_content = {
|
||||
'out_trade_no': out_trade_no,
|
||||
}
|
||||
|
||||
params = {
|
||||
'app_id': cfg['APP_ID'],
|
||||
'method': 'alipay.trade.query',
|
||||
'charset': 'utf-8',
|
||||
'sign_type': 'RSA2',
|
||||
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'version': '1.0',
|
||||
'biz_content': json.dumps(biz_content, separators=(',', ':'), ensure_ascii=False),
|
||||
}
|
||||
|
||||
sign_content = _build_sign_content(params)
|
||||
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
|
||||
|
||||
try:
|
||||
resp = requests.post(cfg['GATEWAY'], data=params, timeout=10)
|
||||
result = resp.json()
|
||||
except Exception as exc:
|
||||
logger.error('支付宝查单请求异常 out_trade_no=%s: %s', out_trade_no, exc, exc_info=True)
|
||||
return ''
|
||||
|
||||
biz_resp = result.get('alipay_trade_query_response', {})
|
||||
code = biz_resp.get('code', '')
|
||||
trade_status = biz_resp.get('trade_status', '')
|
||||
|
||||
if code == '10000':
|
||||
logger.info('支付宝查单成功 out_trade_no=%s trade_status=%s', out_trade_no, trade_status)
|
||||
return trade_status
|
||||
|
||||
# 40004 = 交易不存在(订单未在支付宝侧创建或已关闭)
|
||||
sub_code = biz_resp.get('sub_code', '')
|
||||
sub_msg = biz_resp.get('sub_msg', '')
|
||||
logger.warning(
|
||||
'支付宝查单返回非成功 out_trade_no=%s code=%s msg=%s sub_code=%s sub_msg=%s',
|
||||
out_trade_no, code, biz_resp.get('msg', ''), sub_code, sub_msg,
|
||||
)
|
||||
return trade_status or ''
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""集团全局商品目录(商品/类型/专区)须在集团汇总视图操作。"""
|
||||
"""商品与专区按俱乐部隔离;类型仍可走集团汇总。"""
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import resolve_club_scope
|
||||
|
||||
GROUP_CATALOG_SCOPE_MSG = (
|
||||
'商品、类型与专区为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;'
|
||||
'具体小程序仅可在「小程序配置」中做上架/图标等俱乐部侧配置。'
|
||||
'商品类型为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;'
|
||||
'商品与专区已按俱乐部隔离,切换对应俱乐部即可管理。'
|
||||
)
|
||||
|
||||
|
||||
def block_non_group_catalog_scope(request):
|
||||
"""非集团汇总视图时返回错误文案,否则返回 None。"""
|
||||
"""非集团汇总视图时返回错误文案,否则返回 None。用于类型等全局目录。"""
|
||||
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||
return GROUP_CATALOG_SCOPE_MSG
|
||||
return None
|
||||
|
||||
120
jituan/services/club_cert_upload.py
Normal file
120
jituan/services/club_cert_upload.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""俱乐部微信商户证书落盘(收款 / 提现分目录)。"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# pay = 点单收款商户;withdraw = 提现转账商户
|
||||
CERT_KINDS = {
|
||||
'pay_key': ('pay', 'apiclient_key.pem', 'pay_key_path'),
|
||||
'pay_cert': ('pay', 'apiclient_cert.pem', 'pay_cert_path'),
|
||||
'withdraw_key': ('withdraw', 'apiclient_key.pem', 'private_key_path'),
|
||||
'withdraw_cert': ('withdraw', 'apiclient_cert.pem', None), # 与私钥同目录,不单独存字段
|
||||
'withdraw_platform': ('withdraw', 'pub_key.pem', 'platform_cert_dir'), # 目录字段指向所在目录
|
||||
}
|
||||
|
||||
_SAFE_CLUB = re.compile(r'^[a-zA-Z0-9_-]{1,16}$')
|
||||
|
||||
|
||||
def club_wx_cert_root():
|
||||
"""
|
||||
证书根目录。优先 settings.CLUB_WX_CERT_ROOT;
|
||||
否则尝试沿用现网 secrets 父目录下的 wx_certs;最后 BASE_DIR/wx_certs。
|
||||
"""
|
||||
configured = (getattr(settings, 'CLUB_WX_CERT_ROOT', None) or '').strip()
|
||||
if configured:
|
||||
return configured
|
||||
# 兼容现网:.../sites/xxx/tuikuanzhengshu → .../sites/xxx/wx_certs
|
||||
for attr in ('WEIXIN_CERT_PATH', 'WEIXIN_KEY_PATH'):
|
||||
p = (getattr(settings, attr, None) or '').strip()
|
||||
if p:
|
||||
parent = os.path.dirname(os.path.dirname(os.path.abspath(p)))
|
||||
if parent and parent not in ('.', os.sep):
|
||||
return os.path.join(parent, 'wx_certs')
|
||||
v3 = getattr(settings, 'WECHAT_PAY_V3_CONFIG', None) or {}
|
||||
pk = (v3.get('PRIVATE_KEY_PATH') or '').strip()
|
||||
if pk:
|
||||
parent = os.path.dirname(os.path.dirname(os.path.abspath(pk)))
|
||||
if parent and parent not in ('.', os.sep):
|
||||
return os.path.join(parent, 'wx_certs')
|
||||
return os.path.join(str(settings.BASE_DIR), 'wx_certs')
|
||||
|
||||
|
||||
def club_cert_dir(club_id, bucket):
|
||||
cid = (club_id or '').strip()
|
||||
if not _SAFE_CLUB.match(cid):
|
||||
raise ValueError('非法 club_id')
|
||||
if bucket not in ('pay', 'withdraw'):
|
||||
raise ValueError('非法证书分类')
|
||||
path = os.path.join(club_wx_cert_root(), cid, bucket)
|
||||
os.makedirs(path, mode=0o750, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _validate_pem(raw: bytes, expect_private: bool):
|
||||
if not raw or len(raw) > 64 * 1024:
|
||||
raise ValueError('文件过大或为空')
|
||||
text = raw.decode('utf-8', errors='ignore')
|
||||
if 'BEGIN' not in text or 'END' not in text:
|
||||
raise ValueError('不是有效的 PEM 文件')
|
||||
has_private = 'PRIVATE KEY' in text
|
||||
if expect_private and not has_private:
|
||||
raise ValueError('需要私钥 PEM(含 PRIVATE KEY)')
|
||||
if not expect_private and has_private:
|
||||
raise ValueError('请上传证书/公钥 PEM,不要上传私钥')
|
||||
return raw
|
||||
|
||||
|
||||
def save_club_cert_file(club, kind, uploaded_file):
|
||||
"""
|
||||
保存上传的证书文件并回写 Club 字段。
|
||||
kind: pay_key | pay_cert | withdraw_key | withdraw_cert | withdraw_platform
|
||||
返回 {'path': ..., 'field': ..., 'kind': ...}
|
||||
"""
|
||||
if kind not in CERT_KINDS:
|
||||
raise ValueError(f'不支持的文件类型: {kind}')
|
||||
bucket, filename, field_name = CERT_KINDS[kind]
|
||||
expect_private = kind in ('pay_key', 'withdraw_key')
|
||||
raw = uploaded_file.read()
|
||||
raw = _validate_pem(raw, expect_private=expect_private)
|
||||
|
||||
directory = club_cert_dir(club.club_id, bucket)
|
||||
dest = os.path.join(directory, filename)
|
||||
# 原子写入
|
||||
tmp = dest + '.tmp'
|
||||
with open(tmp, 'wb') as f:
|
||||
f.write(raw)
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, dest)
|
||||
|
||||
update_fields = []
|
||||
if field_name == 'platform_cert_dir':
|
||||
# 平台证书:字段存目录
|
||||
setattr(club, field_name, directory)
|
||||
update_fields.append(field_name)
|
||||
saved_path = directory
|
||||
elif field_name:
|
||||
setattr(club, field_name, dest)
|
||||
update_fields.append(field_name)
|
||||
saved_path = dest
|
||||
else:
|
||||
# withdraw_cert:只落盘,序列号由私钥配对自动解析
|
||||
saved_path = dest
|
||||
|
||||
if update_fields:
|
||||
club.save(update_fields=update_fields)
|
||||
|
||||
logger.warning(
|
||||
'[CLUB_CERT_UPLOAD] club=%s kind=%s path=%s field=%s',
|
||||
club.club_id, kind, saved_path, field_name or '(none)',
|
||||
)
|
||||
return {
|
||||
'kind': kind,
|
||||
'path': saved_path,
|
||||
'field': field_name,
|
||||
'bucket': bucket,
|
||||
'filename': filename,
|
||||
}
|
||||
@@ -128,13 +128,17 @@ def build_member_list_payload(request):
|
||||
for m in members:
|
||||
member_list.append(_format_member(m))
|
||||
else:
|
||||
# 有本俱乐部专属价:只展示本店上架项;尚无专属价:回落集团默认会员定义。
|
||||
# 绝不混入其他俱乐部的 ClubHuiyuanPrice。
|
||||
has_club_prices = bool(price_map)
|
||||
for m in members:
|
||||
row = price_map.get(m.huiyuan_id)
|
||||
if row is None and club_id != CLUB_ID_DEFAULT:
|
||||
continue
|
||||
if row is not None and not row.is_enabled:
|
||||
if has_club_prices:
|
||||
if row is None or not row.is_enabled:
|
||||
continue
|
||||
item = _format_member(m, row, club_id=club_id)
|
||||
else:
|
||||
item = _format_member(m, club_id=club_id)
|
||||
item['goumai_cishu'] = _sales_map.get(m.huiyuan_id, 0)
|
||||
member_list.append(item)
|
||||
|
||||
@@ -143,7 +147,7 @@ def build_member_list_payload(request):
|
||||
'club_id': club_id,
|
||||
'scope': scope,
|
||||
'price_note': (
|
||||
f'当前俱乐部 {club_id} 专属售价(club_huiyuan_price)。'
|
||||
f'当前俱乐部 {club_id} 专属售价;未配置专属价时展示集团默认会员。'
|
||||
if scope != DATA_SCOPE_ALL else
|
||||
'集团汇总:展示全局默认价;切换具体俱乐部可配置售价与体验会员。'
|
||||
),
|
||||
@@ -272,9 +276,13 @@ MEMBER_CARD_FOLDER = 'beijing/huiyuan'
|
||||
|
||||
|
||||
def upload_member_card_image(request, huiyuan_id, field, file_obj):
|
||||
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。"""
|
||||
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。
|
||||
|
||||
每次上传生成新相对路径并删除旧图,避免「同名覆盖但 CDN/浏览器仍显示旧图」。
|
||||
"""
|
||||
import os
|
||||
from utils.oss_utils import validate_image, upload_to_oss
|
||||
import uuid
|
||||
from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss
|
||||
|
||||
if club_write_blocked(request):
|
||||
return None, CLUB_WRITE_SCOPE_MSG
|
||||
@@ -285,18 +293,36 @@ def upload_member_card_image(request, huiyuan_id, field, file_obj):
|
||||
return None, msg
|
||||
|
||||
club_id = club_id_for_write(request)
|
||||
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
|
||||
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
|
||||
ext = '.png'
|
||||
rel_path = f'{MEMBER_CARD_FOLDER}/{club_id}/{huiyuan_id}_{field}{ext}'
|
||||
if not upload_to_oss(file_obj, rel_path):
|
||||
return None, '上传 OSS 失败'
|
||||
|
||||
row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first()
|
||||
if not row:
|
||||
return None, '请先在当前俱乐部上架该会员'
|
||||
|
||||
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
|
||||
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
|
||||
ext = '.png'
|
||||
# 带时间戳+uuid,保证 URL 变化,前端/CDN 不会命中旧缓存
|
||||
stamp = uuid.uuid4().hex[:12]
|
||||
rel_path = f'{MEMBER_CARD_FOLDER}/{club_id}/{huiyuan_id}_{field}_{stamp}{ext}'
|
||||
|
||||
if hasattr(file_obj, 'seek'):
|
||||
try:
|
||||
file_obj.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
if not upload_to_oss(file_obj, rel_path):
|
||||
return None, '上传 OSS 失败'
|
||||
|
||||
old_path = (getattr(row, field, None) or '').strip()
|
||||
setattr(row, field, rel_path)
|
||||
row.save(update_fields=[field, 'UpdateTime'])
|
||||
|
||||
if old_path and old_path != rel_path:
|
||||
try:
|
||||
delete_from_oss(old_path)
|
||||
except Exception as e:
|
||||
logger.warning('删除旧会员卡片图失败 club=%s hy=%s field=%s path=%s err=%s',
|
||||
club_id, huiyuan_id, field, old_path, e)
|
||||
|
||||
return {field: rel_path, 'huiyuan_id': huiyuan_id}, None
|
||||
|
||||
|
||||
|
||||
1380
jituan/services/club_migrate.py
Normal file
1380
jituan/services/club_migrate.py
Normal file
File diff suppressed because it is too large
Load Diff
62
jituan/services/club_migrate_assets.py
Normal file
62
jituan/services/club_migrate_assets.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""跨俱乐部资产迁移 — 资产字段白名单字典(只迁登记过的字段)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# key = "role.field" ;huiyuan 为特殊包
|
||||
ASSET_CATALOG: Dict[str, dict] = {
|
||||
'dashou.yue': {'role': 'dashou', 'field': 'yue', 'label': '打手可提现', 'kind': 'money'},
|
||||
'dashou.yajin': {'role': 'dashou', 'field': 'yajin', 'label': '打手押金', 'kind': 'money'},
|
||||
'dashou.jifen': {'role': 'dashou', 'field': 'jifen', 'label': '打手积分', 'kind': 'int'},
|
||||
'dashou.dongjie_yue': {'role': 'dashou', 'field': 'dongjie_yue', 'label': '打手冻结资金', 'kind': 'money'},
|
||||
'dashou.huiyuan': {'role': 'dashou', 'field': 'huiyuan', 'label': '打手会员资格', 'kind': 'huiyuan'},
|
||||
'shangjia.yue': {'role': 'shangjia', 'field': 'yue', 'label': '商家余额', 'kind': 'money'},
|
||||
'guanshi.yue': {'role': 'guanshi', 'field': 'yue', 'label': '管事可提现', 'kind': 'money'},
|
||||
'guanshi.dongjie_yue': {'role': 'guanshi', 'field': 'dongjie_yue', 'label': '管事冻结资金', 'kind': 'money'},
|
||||
'zuzhang.ketixian_jine': {'role': 'zuzhang', 'field': 'ketixian_jine', 'label': '组长可提现', 'kind': 'money'},
|
||||
'zuzhang.dongjie_yue': {'role': 'zuzhang', 'field': 'dongjie_yue', 'label': '组长冻结资金', 'kind': 'money'},
|
||||
'shenheguan.yue': {'role': 'shenheguan', 'field': 'yue', 'label': '考核官可提现', 'kind': 'money'},
|
||||
'shenheguan.dongjie_yue': {'role': 'shenheguan', 'field': 'dongjie_yue', 'label': '考核官冻结资金', 'kind': 'money'},
|
||||
}
|
||||
|
||||
ROLE_PROFILE_ATTR = {
|
||||
'dashou': 'DashouProfile',
|
||||
'shangjia': 'ShopProfile',
|
||||
'guanshi': 'GuanshiProfile',
|
||||
'zuzhang': 'ZuzhangProfile',
|
||||
'shenheguan': 'ShenheguanProfile',
|
||||
}
|
||||
|
||||
|
||||
def normalize_whitelist(raw) -> List[str]:
|
||||
if not raw:
|
||||
return []
|
||||
out = []
|
||||
seen = set()
|
||||
for x in raw:
|
||||
k = str(x or '').strip()
|
||||
if k in ASSET_CATALOG and k not in seen:
|
||||
seen.add(k)
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def catalog_for_admin() -> List[dict]:
|
||||
return [
|
||||
{'key': k, 'label': v['label'], 'role': v['role'], 'field': v['field'], 'kind': v['kind']}
|
||||
for k, v in ASSET_CATALOG.items()
|
||||
]
|
||||
|
||||
|
||||
def roles_from_whitelist(keys: List[str]) -> List[str]:
|
||||
roles = []
|
||||
seen = set()
|
||||
for k in keys:
|
||||
meta = ASSET_CATALOG.get(k)
|
||||
if not meta:
|
||||
continue
|
||||
r = meta['role']
|
||||
if r not in seen:
|
||||
seen.add(r)
|
||||
roles.append(r)
|
||||
return roles
|
||||
90
jituan/services/club_migrate_qr.py
Normal file
90
jituan/services/club_migrate_qr.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""迁移码:按目标俱乐部 AppID 生成无限小程序码并上传 COS。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from jituan.services.club_migrate import SCENE_PREFIX, scene_for_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECEIVE_PAGE = 'pages/migrate/receive'
|
||||
|
||||
|
||||
def generate_migrate_qrcode(*, to_club_id: str, token: str) -> Tuple[str, str]:
|
||||
"""返回 (relative_url, err)。成功时 err 为空。"""
|
||||
from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid
|
||||
|
||||
club_id = (to_club_id or '').strip()
|
||||
if not club_id:
|
||||
return '', '缺少目标俱乐部'
|
||||
scene = scene_for_token(token)
|
||||
if len(scene) > 32:
|
||||
return '', 'scene 超长'
|
||||
if not scene.startswith(SCENE_PREFIX):
|
||||
return '', 'scene 非法'
|
||||
|
||||
access_token = get_club_mini_access_token(club_id)
|
||||
if not access_token:
|
||||
return '', f'俱乐部 {club_id} 获取微信 token 失败'
|
||||
|
||||
post_data = {
|
||||
'scene': scene,
|
||||
'page': RECEIVE_PAGE,
|
||||
'width': 430,
|
||||
'auto_color': False,
|
||||
'line_color': {'r': 0, 'g': 0, 'b': 0},
|
||||
'check_path': False,
|
||||
}
|
||||
|
||||
def _req(tok: str):
|
||||
url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={tok}'
|
||||
return requests.post(url, json=post_data, timeout=15)
|
||||
|
||||
wx_resp = _req(access_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_club_mini_access_token(club_id, force_refresh=True)
|
||||
if access_token:
|
||||
wx_resp = _req(access_token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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('migrate qr fail club=%s err=%s', club_id, error_info)
|
||||
except Exception:
|
||||
logger.error('migrate qr fail club=%s status=%s', club_id, wx_resp.status_code)
|
||||
return '', f'生成小程序码失败: {errmsg}'
|
||||
|
||||
from utils.oss_utils import upload_to_oss
|
||||
|
||||
filename = f"migrate_{club_id}_{token}_{int(time.time())}.png"
|
||||
oss_path = f"club_migrate/{club_id}/{filename}"
|
||||
file_obj = io.BytesIO(wx_resp.content)
|
||||
try:
|
||||
full_url = upload_to_oss(file_obj, oss_path, content_type='image/png')
|
||||
except Exception as e:
|
||||
logger.exception('migrate qr upload fail: %s', e)
|
||||
return '', f'上传小程序码失败: {e}'
|
||||
if not full_url:
|
||||
return '', '上传小程序码失败'
|
||||
|
||||
# 前端保存相册必须用 https 完整链;相对路径在 oss 未就绪时会保存失败
|
||||
if str(full_url).startswith('http'):
|
||||
return str(full_url), ''
|
||||
cos_domain = getattr(settings, 'COS_DOMAIN', '').rstrip('/')
|
||||
if cos_domain:
|
||||
return f'{cos_domain}/{str(full_url).lstrip("/")}', ''
|
||||
return str(full_url), ''
|
||||
246
jituan/services/club_payment_channel.py
Normal file
246
jituan/services/club_payment_channel.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""俱乐部支付通道:读配置、加解密、付呗/微信/支付宝字段归一化。"""
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from gvsdsdk.payment.models import EncryptField, _decrypt_field
|
||||
|
||||
from jituan.constants import (
|
||||
CLUB_ID_DEFAULT,
|
||||
FUBEI_GATEWAY_PROD,
|
||||
FUBEI_GATEWAY_SANDBOX,
|
||||
PAY_CHANNEL_ALIPAY_WAP,
|
||||
PAY_CHANNEL_FUBEI_WX_MINI,
|
||||
PAY_CHANNEL_WECHAT_JSAPI,
|
||||
PAY_METHOD_ALIPAY,
|
||||
PAY_METHOD_FUBEI,
|
||||
PAY_METHOD_LABELS,
|
||||
PAY_METHOD_TO_CHANNEL,
|
||||
PAY_METHOD_WECHAT,
|
||||
)
|
||||
from jituan.models import Club, ClubPaymentChannel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_TTL = 60
|
||||
|
||||
|
||||
def encrypt_secret_field(plain: str) -> str:
|
||||
if not plain:
|
||||
return ''
|
||||
return EncryptField(plain)
|
||||
|
||||
|
||||
def decrypt_secret_field(encrypted: str) -> str:
|
||||
if not encrypted:
|
||||
return ''
|
||||
return _decrypt_field(encrypted)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentChannelConfig:
|
||||
"""归一化后的通道配置,供支付网关读取。"""
|
||||
club_id: str
|
||||
channel: str
|
||||
config_id: int
|
||||
name: str
|
||||
enabled: bool
|
||||
sandbox: bool
|
||||
app_id: str = ''
|
||||
mch_id: str = ''
|
||||
api_key: str = ''
|
||||
private_key: str = ''
|
||||
public_key: str = ''
|
||||
cert_serial_no: str = ''
|
||||
private_key_path: str = ''
|
||||
platform_cert_dir: str = ''
|
||||
notify_url: str = ''
|
||||
return_url: str = ''
|
||||
gateway_url: str = ''
|
||||
mini_app_id: str = ''
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def store_id(self):
|
||||
return self.extra.get('store_id') or self.extra.get('fubei_store_id')
|
||||
|
||||
@property
|
||||
def fubei_vendor_sn(self):
|
||||
return self.extra.get('vendor_sn') or self.extra.get('fubei_vendor_sn') or self.app_id
|
||||
|
||||
|
||||
def _cache_key(club_id: str, channel: str) -> str:
|
||||
return f'club_pay_ch:{club_id}:{channel}'
|
||||
|
||||
|
||||
def _default_notify_path(channel: str) -> str:
|
||||
if channel == PAY_CHANNEL_FUBEI_WX_MINI:
|
||||
return '/hqhd/dingdan/fubei-notify/'
|
||||
if channel == PAY_CHANNEL_ALIPAY_WAP:
|
||||
return '/hqhd/dingdan/alipay-notify/'
|
||||
return '/hqhd/dingdan/pay-notify/'
|
||||
|
||||
|
||||
def _build_base_url(club: Club) -> str:
|
||||
domain = (club.h5_domain or '').strip().rstrip('/')
|
||||
if domain:
|
||||
return domain
|
||||
return 'https://www.abas.asia'
|
||||
|
||||
|
||||
def _resolve_notify_url(club: Club, row: ClubPaymentChannel) -> str:
|
||||
if row.notify_url:
|
||||
return row.notify_url.strip()
|
||||
return f'{_build_base_url(club)}{_default_notify_path(row.channel)}'
|
||||
|
||||
|
||||
def _row_to_config(club: Club, row: ClubPaymentChannel) -> PaymentChannelConfig:
|
||||
extra = dict(row.extra_json or {})
|
||||
gateway = (row.gateway_url or '').strip()
|
||||
if not gateway and row.channel == PAY_CHANNEL_FUBEI_WX_MINI:
|
||||
gateway = FUBEI_GATEWAY_SANDBOX if row.sandbox else FUBEI_GATEWAY_PROD
|
||||
|
||||
mini_app_id = (row.mini_app_id or club.pay_app_id or club.wx_appid or '').strip()
|
||||
|
||||
return PaymentChannelConfig(
|
||||
club_id=row.club_id,
|
||||
channel=row.channel,
|
||||
config_id=row.id,
|
||||
name=row.name,
|
||||
enabled=row.enabled == 1,
|
||||
sandbox=bool(row.sandbox),
|
||||
app_id=(row.app_id or '').strip(),
|
||||
mch_id=(row.mch_id or '').strip(),
|
||||
api_key=decrypt_secret_field(row.api_key_enc),
|
||||
private_key=decrypt_secret_field(row.private_key_enc),
|
||||
public_key=decrypt_secret_field(row.public_key_enc) or (row.public_key_enc or '').strip(),
|
||||
cert_serial_no=row.cert_serial_no or '',
|
||||
private_key_path=row.private_key_path or '',
|
||||
platform_cert_dir=row.platform_cert_dir or '',
|
||||
notify_url=_resolve_notify_url(club, row),
|
||||
return_url=(row.return_url or '').strip(),
|
||||
gateway_url=gateway,
|
||||
mini_app_id=mini_app_id,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def get_club_payment_channel(
|
||||
club_id: Optional[str],
|
||||
channel: str,
|
||||
*,
|
||||
config_id: Optional[int] = None,
|
||||
require_enabled: bool = True,
|
||||
) -> Optional[PaymentChannelConfig]:
|
||||
"""读取俱乐部某通道配置;优先 is_default,可指定 config_id。"""
|
||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||
cache_k = _cache_key(cid, channel)
|
||||
if config_id is None and require_enabled:
|
||||
cached = cache.get(cache_k)
|
||||
if cached is not None:
|
||||
return cached if cached != '__none__' else None
|
||||
|
||||
club = Club.query.filter(club_id=cid).first()
|
||||
if not club:
|
||||
logger.warning('club %s 不存在,无法读取支付通道 %s', cid, channel)
|
||||
return None
|
||||
|
||||
qs = ClubPaymentChannel.query.filter(club_id=cid, channel=channel)
|
||||
if require_enabled:
|
||||
qs = qs.filter(enabled=1)
|
||||
if config_id is not None:
|
||||
row = qs.filter(id=config_id).first()
|
||||
else:
|
||||
row = qs.filter(is_default=True).order_by('sort_order', 'id').first()
|
||||
if not row:
|
||||
row = qs.order_by('sort_order', 'id').first()
|
||||
|
||||
if not row:
|
||||
if config_id is None and require_enabled:
|
||||
cache.set(cache_k, '__none__', CACHE_TTL)
|
||||
return None
|
||||
|
||||
cfg = _row_to_config(club, row)
|
||||
if config_id is None and require_enabled:
|
||||
cache.set(cache_k, cfg, CACHE_TTL)
|
||||
return cfg
|
||||
|
||||
|
||||
def invalidate_club_payment_cache(club_id: str, channel: Optional[str] = None):
|
||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||
if channel:
|
||||
cache.delete(_cache_key(cid, channel))
|
||||
return
|
||||
for ch in (
|
||||
PAY_CHANNEL_WECHAT_JSAPI,
|
||||
PAY_CHANNEL_ALIPAY_WAP,
|
||||
PAY_CHANNEL_FUBEI_WX_MINI,
|
||||
):
|
||||
cache.delete(_cache_key(cid, ch))
|
||||
|
||||
|
||||
def get_fubei_config(club_id: Optional[str] = None) -> Optional[PaymentChannelConfig]:
|
||||
"""付呗微信小程序通道配置。"""
|
||||
return get_club_payment_channel(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
|
||||
|
||||
|
||||
def list_payment_methods_for_miniapp(club_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""小程序下单页:返回用户可见的支付方式(enabled 且配置完整)。"""
|
||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||
methods = []
|
||||
|
||||
wechat_direct = get_club_payment_channel(cid, PAY_CHANNEL_WECHAT_JSAPI)
|
||||
fubei = get_fubei_config(cid)
|
||||
alipay = get_club_payment_channel(cid, PAY_CHANNEL_ALIPAY_WAP)
|
||||
|
||||
if fubei and fubei.app_id and fubei.api_key:
|
||||
methods.append({
|
||||
'code': PAY_METHOD_FUBEI,
|
||||
'label': PAY_METHOD_LABELS[PAY_METHOD_FUBEI],
|
||||
'channel': PAY_CHANNEL_FUBEI_WX_MINI,
|
||||
'sort_order': _sort_for(cid, PAY_CHANNEL_FUBEI_WX_MINI),
|
||||
})
|
||||
elif wechat_direct and wechat_direct.mch_id and wechat_direct.api_key:
|
||||
methods.append({
|
||||
'code': PAY_METHOD_WECHAT,
|
||||
'label': PAY_METHOD_LABELS[PAY_METHOD_WECHAT],
|
||||
'channel': PAY_CHANNEL_WECHAT_JSAPI,
|
||||
'sort_order': _sort_for(cid, PAY_CHANNEL_WECHAT_JSAPI),
|
||||
})
|
||||
|
||||
if alipay and alipay.app_id and alipay.private_key and alipay.public_key:
|
||||
from jituan.services.alipay_pay import is_alipay_enabled_for_club
|
||||
if is_alipay_enabled_for_club(cid):
|
||||
methods.append({
|
||||
'code': PAY_METHOD_ALIPAY,
|
||||
'label': PAY_METHOD_LABELS[PAY_METHOD_ALIPAY],
|
||||
'channel': PAY_CHANNEL_ALIPAY_WAP,
|
||||
'sort_order': _sort_for(cid, PAY_CHANNEL_ALIPAY_WAP),
|
||||
})
|
||||
|
||||
methods.sort(key=lambda x: x.get('sort_order', 0))
|
||||
return methods
|
||||
|
||||
|
||||
def _sort_for(club_id: str, channel: str) -> int:
|
||||
row = ClubPaymentChannel.query.filter(
|
||||
club_id=club_id, channel=channel, enabled=1,
|
||||
).order_by('-is_default', 'sort_order', 'id').first()
|
||||
return row.sort_order if row else 0
|
||||
|
||||
|
||||
def channel_for_pay_method(pay_method: str) -> Optional[str]:
|
||||
return PAY_METHOD_TO_CHANNEL.get((pay_method or '').strip().lower())
|
||||
|
||||
|
||||
# 付呗通道后台必填字段说明(文档/校验用)
|
||||
FUBEI_REQUIRED_FIELDS = {
|
||||
'app_id': '付呗开放平台 vendor_sn / 付呗ID',
|
||||
'api_key_enc': '付呗 Secret(入库前加密)',
|
||||
'mini_app_id': '微信小程序 AppID(可与 club.wx_appid 相同;须在付呗后台关联)',
|
||||
'notify_url': '异步回调,默认 /hqhd/dingdan/fubei-notify/',
|
||||
'extra_json.store_id': '付呗门店 store_id(服务商开户后提供,统一下单必填)',
|
||||
}
|
||||
@@ -13,26 +13,44 @@ def _global_role_q():
|
||||
|
||||
|
||||
def resolve_role_club_id(request, explicit=None):
|
||||
"""创建/筛选角色时使用的俱乐部;集团视图可显式指定 target_club_id。"""
|
||||
"""
|
||||
创建/筛选角色时使用的俱乐部。
|
||||
单俱乐部视图:强制当前顶栏俱乐部,忽略跨店 target_club_id。
|
||||
集团汇总视图:可显式指定;未指定则为全局(club_id 空)。
|
||||
"""
|
||||
scope = resolve_club_scope(request)
|
||||
if scope != DATA_SCOPE_ALL:
|
||||
return resolve_club_id_from_request(request)
|
||||
if explicit:
|
||||
return (explicit or '').strip()
|
||||
scope = resolve_club_scope(request)
|
||||
if scope == DATA_SCOPE_ALL:
|
||||
return ''
|
||||
return resolve_club_id_from_request(request)
|
||||
|
||||
|
||||
def filter_roles_for_request(request):
|
||||
"""
|
||||
子公司视图:仅本俱乐部角色。
|
||||
集团汇总视图:全部角色(含各俱乐部 + 全局 club_id 为空)。
|
||||
子公司视图:优先本俱乐部角色;本俱乐部尚无角色时仅回落集团全局角色。
|
||||
绝不混入其他俱乐部角色。
|
||||
集团汇总视图:全部角色(含各俱乐部 + 全局)。
|
||||
"""
|
||||
qs = Role.objects.filter(RoleStatus=1)
|
||||
scope = resolve_club_scope(request)
|
||||
if scope == DATA_SCOPE_ALL:
|
||||
return qs
|
||||
club_id = resolve_club_id_from_request(request)
|
||||
return qs.filter(Q(ClubID=club_id) | _global_role_q())
|
||||
club_qs = qs.filter(ClubID=club_id)
|
||||
if club_qs.exists():
|
||||
return club_qs
|
||||
return qs.filter(_global_role_q())
|
||||
|
||||
|
||||
def role_visible_in_scope(role_club_id, request):
|
||||
"""列表展示用:单俱乐部视图只允许本俱乐部或全局(无本俱乐部角色时由调用方决定)。"""
|
||||
scope = resolve_club_scope(request)
|
||||
if scope == DATA_SCOPE_ALL:
|
||||
return True
|
||||
curr = (resolve_club_id_from_request(request) or '').strip()
|
||||
cid = (role_club_id or '').strip()
|
||||
return cid == '' or cid == curr
|
||||
|
||||
|
||||
def filter_kefu_users_by_admin_scope(qs, request):
|
||||
|
||||
@@ -13,25 +13,45 @@ logger = logging.getLogger(__name__)
|
||||
_REFERER_APPID_RE = re.compile(r'servicewechat\.com/(wx[0-9a-fA-F]+)/', re.I)
|
||||
|
||||
|
||||
def get_club_miniapp_appid(club):
|
||||
"""小程序 AppID:登录 jscode2session 与 JSAPI 支付须一致。"""
|
||||
if not club:
|
||||
return ''
|
||||
return (club.wx_appid or club.pay_app_id or '').strip()
|
||||
|
||||
|
||||
def resolve_club_by_appid(app_id):
|
||||
if not app_id:
|
||||
return None
|
||||
app_id = app_id.strip()
|
||||
try:
|
||||
return Club.query.filter(wx_appid=app_id, status=1).first()
|
||||
club = Club.query.filter(wx_appid=app_id, status=1).first()
|
||||
if club:
|
||||
return club
|
||||
return Club.query.filter(pay_app_id=app_id, status=1).first()
|
||||
except Exception:
|
||||
return Club.objects.filter(wx_appid=app_id, status=1).first()
|
||||
club = Club.objects.filter(wx_appid=app_id, status=1).first()
|
||||
if club:
|
||||
return club
|
||||
return Club.objects.filter(pay_app_id=app_id, status=1).first()
|
||||
|
||||
|
||||
def get_club_wx_credentials(club_id):
|
||||
"""优先读 club 表;xq 缺省时回退 settings 全局配置(兼容现网)。"""
|
||||
club_id = club_id or CLUB_ID_DEFAULT
|
||||
club = None
|
||||
try:
|
||||
club = Club.query.get(club_id=club_id)
|
||||
except Club.DoesNotExist:
|
||||
club = None
|
||||
except Exception:
|
||||
logger.exception('读取俱乐部失败 club_id=%s', club_id)
|
||||
club = None
|
||||
|
||||
if club and club.wx_appid and club.wx_secret:
|
||||
return club.wx_appid, club.wx_secret, club
|
||||
appid = get_club_miniapp_appid(club)
|
||||
secret = (club.wx_secret or '').strip() if club else ''
|
||||
if appid and secret:
|
||||
return appid, secret, club
|
||||
|
||||
if club_id == CLUB_ID_DEFAULT:
|
||||
return (
|
||||
@@ -39,7 +59,10 @@ def get_club_wx_credentials(club_id):
|
||||
getattr(settings, 'WEIXIN_SECRET', ''),
|
||||
club,
|
||||
)
|
||||
return '', '', club
|
||||
# 非默认俱乐部未配置时给出明确原因,避免后续空指针 500
|
||||
if not club:
|
||||
return '', '', None
|
||||
return appid or '', secret or '', club
|
||||
|
||||
|
||||
def _invite_code_from_request(request):
|
||||
@@ -120,6 +143,62 @@ def wechat_jscode2session_for_request(request, code):
|
||||
return jscode2session(code, club_id=club_id)
|
||||
|
||||
|
||||
def get_club_official_credentials(club_id):
|
||||
"""服务号 AppID/Secret(H5 网页授权 + 公众号 JSAPI)。"""
|
||||
club_id = club_id or CLUB_ID_DEFAULT
|
||||
club = None
|
||||
try:
|
||||
club = Club.query.get(club_id=club_id)
|
||||
except Club.DoesNotExist:
|
||||
club = None
|
||||
except Exception:
|
||||
logger.exception('读取俱乐部失败 club_id=%s', club_id)
|
||||
club = None
|
||||
if not club:
|
||||
return '', '', None
|
||||
appid = (club.official_appid or '').strip()
|
||||
secret = (club.official_secret or '').strip()
|
||||
return appid, secret, club
|
||||
|
||||
|
||||
def oauth2_access_token(code, club_id=None):
|
||||
"""
|
||||
服务号网页授权:code → openid(sns/oauth2/access_token)。
|
||||
"""
|
||||
appid, secret, club = get_club_official_credentials(club_id)
|
||||
if not appid or not secret:
|
||||
return {'errmsg': '俱乐部服务号配置未设置(official_appid/official_secret)'}
|
||||
|
||||
url = 'https://api.weixin.qq.com/sns/oauth2/access_token'
|
||||
params = {
|
||||
'appid': appid,
|
||||
'secret': secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
result = response.json()
|
||||
if 'openid' in result:
|
||||
return {
|
||||
'openid': result['openid'],
|
||||
'access_token': result.get('access_token', ''),
|
||||
'refresh_token': result.get('refresh_token', ''),
|
||||
'scope': result.get('scope', ''),
|
||||
'unionid': result.get('unionid', ''),
|
||||
'club_id': (club.club_id if club else None) or club_id or CLUB_ID_DEFAULT,
|
||||
'wx_appid': appid,
|
||||
}
|
||||
errcode = result.get('errcode', 'unknown')
|
||||
errmsg = result.get('errmsg', '未知错误')
|
||||
return {'errmsg': f'[{errcode}]{errmsg}'}
|
||||
except requests.exceptions.Timeout:
|
||||
return {'errmsg': '请求微信接口超时'}
|
||||
except Exception as e:
|
||||
logger.exception('oauth2_access_token 异常 club_id=%s', club_id)
|
||||
return {'errmsg': f'请求微信接口异常: {str(e)}'}
|
||||
|
||||
|
||||
def jscode2session(code, club_id=None, app_id=None):
|
||||
"""
|
||||
按俱乐部微信配置换取 openid。
|
||||
|
||||
@@ -62,6 +62,8 @@ def build_leixing_item(leixing, cfg=None):
|
||||
'bankuai_id': leixing.bankuai_id,
|
||||
'club_enabled': True,
|
||||
'icon_source': 'club' if cfg and cfg.tupian_url else 'global',
|
||||
# 新字段:旧客户端忽略;1/true 时该类型订单强制展示价格
|
||||
'show_price': bool(getattr(cfg, 'show_price', False)) if cfg else False,
|
||||
}
|
||||
return item
|
||||
|
||||
@@ -165,6 +167,33 @@ def disable_leixing_for_club(request, leixing_id):
|
||||
return {'leixing_id': leixing_id}, None
|
||||
|
||||
|
||||
def set_leixing_show_price(request, leixing_id, show_price):
|
||||
"""按俱乐部商品类型开关:是否强制展示订单价格。"""
|
||||
if club_write_blocked(request):
|
||||
return None, CLUB_WRITE_SCOPE_MSG
|
||||
club_id = club_id_for_write(request)
|
||||
try:
|
||||
lx = ShangpinLeixing.query.get(id=leixing_id)
|
||||
except ShangpinLeixing.DoesNotExist:
|
||||
return None, '商品类型不存在'
|
||||
|
||||
row, _ = ClubShangpinLeixingConfig.query.get_or_create(
|
||||
club_id=club_id,
|
||||
leixing_id=int(leixing_id),
|
||||
defaults={
|
||||
'is_enabled': club_id == CLUB_ID_DEFAULT,
|
||||
'paixu': int(lx.paixu or 0),
|
||||
'show_price': False,
|
||||
},
|
||||
)
|
||||
row.show_price = bool(show_price)
|
||||
row.save(update_fields=['show_price', 'UpdateTime'])
|
||||
return {
|
||||
'leixing_id': int(leixing_id),
|
||||
'show_price': bool(row.show_price),
|
||||
}, None
|
||||
|
||||
|
||||
def upload_leixing_icon(request, leixing_id, file_obj):
|
||||
if club_write_blocked(request):
|
||||
return None, CLUB_WRITE_SCOPE_MSG
|
||||
@@ -206,4 +235,5 @@ def copy_club_shangpin_leixing_config(template_club_id, new_club_id):
|
||||
is_enabled=row.is_enabled,
|
||||
tupian_url='', # 新俱乐部需自行上传图标,不复制 OSS 路径
|
||||
paixu=row.paixu,
|
||||
show_price=bool(getattr(row, 'show_price', False)),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""用户所属俱乐部解析。"""
|
||||
import logging
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
from jituan.models import UserWxOpenid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_invite_party_club(user, request_club_id=None):
|
||||
"""
|
||||
@@ -95,7 +99,12 @@ def find_user_by_wx_openid(club_id, openid):
|
||||
return None
|
||||
|
||||
|
||||
def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
|
||||
def ensure_wx_openid_binding(club_id, openid, user, unionid='', wx_appid=''):
|
||||
"""
|
||||
绑定 openid。
|
||||
wx_appid 用于区分小程序 / 服务号;只清理「同 club + 同 wx_appid」的旧绑定,
|
||||
避免 H5 登录冲掉小程序 openid(或反过来)。
|
||||
"""
|
||||
if not club_id or not openid or not user:
|
||||
return None
|
||||
uid = getattr(user, 'UserUID', None)
|
||||
@@ -104,25 +113,58 @@ def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
|
||||
|
||||
club_id = (club_id or '').strip()
|
||||
openid = (openid or '').strip()
|
||||
wx_appid = (wx_appid or '').strip()
|
||||
|
||||
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
||||
if binding:
|
||||
if binding and binding.yonghuid == uid:
|
||||
if wx_appid and getattr(binding, 'wx_appid', '') != wx_appid:
|
||||
binding.wx_appid = wx_appid
|
||||
if unionid:
|
||||
binding.unionid = unionid
|
||||
binding.save()
|
||||
return binding
|
||||
|
||||
try:
|
||||
# 独立 savepoint:并发/重复绑定时不污染外层 transaction.atomic
|
||||
with transaction.atomic():
|
||||
return UserWxOpenid.query.create(
|
||||
# 仅清理同一 AppID 下的过期绑定;小程序与服务号可并存
|
||||
stale_qs = UserWxOpenid.query.filter(yonghuid=uid, club_id=club_id).exclude(openid=openid)
|
||||
if wx_appid:
|
||||
stale_qs = stale_qs.filter(wx_appid=wx_appid)
|
||||
else:
|
||||
stale_qs = stale_qs.filter(wx_appid='')
|
||||
stale_count = stale_qs.count()
|
||||
if stale_count:
|
||||
logger.info(
|
||||
'[WX_OPENID_BIND] uid=%s club=%s appid=%s 清除 %d 条过期 openid 绑定',
|
||||
uid, club_id, wx_appid or '(legacy)', stale_count,
|
||||
)
|
||||
stale_qs.delete()
|
||||
|
||||
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
||||
if binding:
|
||||
if binding.yonghuid != uid:
|
||||
return binding
|
||||
if wx_appid and getattr(binding, 'wx_appid', '') != wx_appid:
|
||||
binding.wx_appid = wx_appid
|
||||
binding.save(update_fields=['wx_appid'])
|
||||
return binding
|
||||
|
||||
row = UserWxOpenid.query.create(
|
||||
club_id=club_id,
|
||||
openid=openid,
|
||||
yonghuid=uid,
|
||||
unionid=unionid or getattr(user, 'UnionID', None) or '',
|
||||
wx_appid=wx_appid,
|
||||
)
|
||||
if club_id == CLUB_ID_DEFAULT and getattr(user, 'OpenID', None) != openid:
|
||||
user.OpenID = openid
|
||||
user.save(update_fields=['OpenID'])
|
||||
return row
|
||||
except IntegrityError:
|
||||
return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
||||
|
||||
|
||||
def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
|
||||
def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid='', wx_appid=''):
|
||||
"""
|
||||
微信登录/注册统一找用户,避免 UserName(openid) 唯一键冲突。
|
||||
星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,不可 get_or_create(OpenID=...)。
|
||||
@@ -133,12 +175,29 @@ def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
|
||||
|
||||
openid = (openid or '').strip()
|
||||
club_id = (club_id or '').strip()
|
||||
wx_appid = (wx_appid or '').strip()
|
||||
if not openid:
|
||||
raise ValueError('openid 不能为空')
|
||||
|
||||
user = find_user_by_wx_openid(club_id, openid)
|
||||
if user:
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid)
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
|
||||
return user, False
|
||||
|
||||
# 有 unionid 时尽量合并到已有用户(小程序 / 服务号同人)
|
||||
if unionid:
|
||||
binding_u = UserWxOpenid.query.filter(unionid=unionid).order_by('id').first()
|
||||
if binding_u:
|
||||
user = User.objects.filter(UserUID=binding_u.yonghuid).first()
|
||||
if not user:
|
||||
user = User.query.filter(UserUID=binding_u.yonghuid).first()
|
||||
if user:
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
|
||||
return user, False
|
||||
for manager in _user_querysets():
|
||||
user = manager.filter(UnionID=unionid).first()
|
||||
if user:
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
|
||||
return user, False
|
||||
|
||||
try:
|
||||
@@ -156,10 +215,10 @@ def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
|
||||
user = find_user_by_wx_openid(club_id, openid)
|
||||
if not user:
|
||||
raise
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid)
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
|
||||
return user, False
|
||||
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid)
|
||||
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
|
||||
return user, True
|
||||
|
||||
|
||||
@@ -200,32 +259,148 @@ def _looks_like_wx_openid(value):
|
||||
return len(v) >= 20 and v.startswith('o')
|
||||
|
||||
|
||||
def get_wx_openid_for_user(user, club_id=None):
|
||||
def _client_type_from_request(request):
|
||||
if not request:
|
||||
return 'mini'
|
||||
meta = getattr(request, 'META', None) or {}
|
||||
header = (meta.get('HTTP_X_CLIENT') or meta.get('HTTP_X_PAY_CLIENT') or '').strip().lower()
|
||||
data = getattr(request, 'data', None) or {}
|
||||
body = ''
|
||||
if isinstance(data, dict):
|
||||
body = (data.get('client_type') or data.get('pay_client') or '').strip().lower()
|
||||
val = header or body
|
||||
if val in ('h5', 'official', 'mp-h5', 'oa'):
|
||||
return 'h5'
|
||||
return 'mini'
|
||||
|
||||
|
||||
def resolve_prefer_wx_appid(club_id, client_type='mini'):
|
||||
"""按端解析应付的 AppID:H5→服务号;小程序→wx_appid。"""
|
||||
from jituan.models import Club
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return ''
|
||||
try:
|
||||
club = Club.query.get(club_id=cid)
|
||||
except Exception:
|
||||
return ''
|
||||
if client_type == 'h5':
|
||||
return (club.official_appid or '').strip()
|
||||
return (club.wx_appid or club.pay_app_id or '').strip()
|
||||
|
||||
|
||||
def resolve_club_payment_openid(user, club_id=None, prefer_wx_appid=''):
|
||||
"""
|
||||
按俱乐部取微信支付 JSAPI 用 openid。
|
||||
多俱乐部用户 openid 在 user_wx_openid;xq 老用户可回落 User.OpenID;
|
||||
星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,再回落 UserName。
|
||||
支付专用:按俱乐部取 openid。
|
||||
prefer_wx_appid 有值时优先取该 AppID 绑定(H5 服务号 / 小程序分流)。
|
||||
1) user_wx_openid(yonghuid, club_id[, wx_appid])
|
||||
2) 无本 club 绑定但有其他 club → 拒绝(须在本端重新登录)
|
||||
3) 无任何绑定时 lazy backfill:xq→User.OpenID;xzj 等→UserName(openid)
|
||||
"""
|
||||
if not user:
|
||||
return ''
|
||||
return '', 'none'
|
||||
cid = (club_id or '').strip() or get_user_club_id(user)
|
||||
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
||||
if uid and cid:
|
||||
binding = UserWxOpenid.query.filter(yonghuid=uid, club_id=cid).first()
|
||||
if not uid or not cid:
|
||||
return '', 'none'
|
||||
|
||||
prefer = (prefer_wx_appid or '').strip()
|
||||
qs = UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
|
||||
binding = None
|
||||
if prefer:
|
||||
binding = qs.filter(wx_appid=prefer).order_by('-id').first()
|
||||
if not binding and prefer:
|
||||
# 历史行 wx_appid 为空:仅小程序端可回落
|
||||
pass
|
||||
if not binding and not prefer:
|
||||
binding = qs.order_by('-id').first()
|
||||
if not binding and prefer:
|
||||
# 小程序端:允许回落空 wx_appid 历史绑定
|
||||
from jituan.models import Club
|
||||
try:
|
||||
club = Club.query.get(club_id=cid)
|
||||
mini = (club.wx_appid or club.pay_app_id or '').strip()
|
||||
except Exception:
|
||||
mini = ''
|
||||
if prefer == mini:
|
||||
binding = qs.filter(wx_appid='').order_by('-id').first()
|
||||
|
||||
if binding and binding.openid:
|
||||
return binding.openid
|
||||
legacy = getattr(user, 'OpenID', None) or getattr(user, 'openid', None)
|
||||
if legacy:
|
||||
return legacy.strip()
|
||||
return binding.openid.strip(), f'user_wx_openid#{binding.id}'
|
||||
|
||||
if prefer:
|
||||
logger.warning(
|
||||
'[PAY_OPENID] uid=%s club=%s 无 appid=%s 绑定,须先在对应端完成微信登录',
|
||||
uid, cid, prefer,
|
||||
)
|
||||
return '', 'missing_appid_bind'
|
||||
|
||||
other_clubs = list(
|
||||
UserWxOpenid.query.filter(yonghuid=uid)
|
||||
.exclude(club_id=cid)
|
||||
.values_list('club_id', flat=True)[:5]
|
||||
)
|
||||
if other_clubs:
|
||||
logger.warning(
|
||||
'[PAY_OPENID] uid=%s 无 club=%s 绑定,已有其他俱乐部 %s;须在本小程序重新登录',
|
||||
uid, cid, other_clubs,
|
||||
)
|
||||
return '', 'other_club_only'
|
||||
|
||||
username = (getattr(user, 'UserName', None) or '').strip()
|
||||
if _looks_like_wx_openid(username):
|
||||
return username
|
||||
return ''
|
||||
legacy_oid = (getattr(user, 'OpenID', None) or getattr(user, 'openid', None) or '').strip()
|
||||
candidate = ''
|
||||
source = ''
|
||||
if cid == CLUB_ID_DEFAULT:
|
||||
if legacy_oid and _looks_like_wx_openid(legacy_oid):
|
||||
candidate, source = legacy_oid, 'legacy_OpenID'
|
||||
elif _looks_like_wx_openid(username):
|
||||
candidate, source = username, 'legacy_UserName'
|
||||
|
||||
if candidate:
|
||||
logger.info('[PAY_OPENID] uid=%s club=%s lazy backfill from %s', uid, cid, source)
|
||||
ensure_wx_openid_binding(cid, candidate, user)
|
||||
return candidate, source
|
||||
|
||||
logger.warning(
|
||||
'[PAY_OPENID] uid=%s club=%s 无绑定 legacy_UserName=%s… legacy_OpenID=%s',
|
||||
uid, cid, username[:10] if username else '', bool(legacy_oid),
|
||||
)
|
||||
return '', 'missing'
|
||||
|
||||
|
||||
def get_wx_openid_for_user(user, club_id=None, prefer_wx_appid=''):
|
||||
openid, _ = resolve_club_payment_openid(user, club_id, prefer_wx_appid=prefer_wx_appid)
|
||||
return openid
|
||||
|
||||
|
||||
def get_wx_openid_for_user_with_source(user, club_id=None, prefer_wx_appid=''):
|
||||
return resolve_club_payment_openid(user, club_id, prefer_wx_appid=prefer_wx_appid)
|
||||
|
||||
|
||||
def get_payment_openid(request, user=None, club_id=None):
|
||||
"""下单/充值:按请求俱乐部解析 openid。"""
|
||||
"""下单/充值:按请求俱乐部 + 端类型解析 openid。"""
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
user = user or getattr(request, 'user', None)
|
||||
cid = club_id or resolve_club_id_for_write(request, user)
|
||||
return get_wx_openid_for_user(user, cid), cid
|
||||
cid = (club_id or '').strip() or resolve_club_id_for_write(request, user)
|
||||
client_type = _client_type_from_request(request)
|
||||
prefer = resolve_prefer_wx_appid(cid, client_type)
|
||||
openid, source = get_wx_openid_for_user_with_source(user, cid, prefer_wx_appid=prefer)
|
||||
if openid:
|
||||
logger.info(
|
||||
'[PAY_OPENID] uid=%s club=%s client=%s appid=%s openid=%s… source=%s',
|
||||
getattr(user, 'UserUID', ''), cid, client_type, prefer or '-',
|
||||
openid[:6] + '...' if len(openid) > 10 else openid, source,
|
||||
)
|
||||
elif user:
|
||||
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
||||
logger.warning(
|
||||
'[PAY_OPENID] 无法解析支付 openid uid=%s club=%s client=%s appid=%s source=%s user_club=%s',
|
||||
uid, cid, client_type, prefer or '-', source, get_user_club_id(user),
|
||||
)
|
||||
return openid, cid
|
||||
|
||||
|
||||
def get_pay_app_source(request):
|
||||
"""供统一下单选用 AppID:h5→official,其它→mini。"""
|
||||
return 'official' if _client_type_from_request(request) == 'h5' else 'mini'
|
||||
|
||||
@@ -41,3 +41,20 @@ def forbid_if_order_out_of_scope(request, order):
|
||||
if order_belongs_to_request(order, request):
|
||||
return None
|
||||
return Response({'code': 403, 'msg': '该订单不属于当前俱乐部数据范围'})
|
||||
|
||||
|
||||
def forbid_if_order_mutate_out_of_scope(request, order):
|
||||
"""
|
||||
订单写操作闸门:
|
||||
- SINGLE_CLUB:只能处理当前俱乐部订单
|
||||
- ALL_CLUBS:仅集团高管可处理(本店超管须切到具体俱乐部)
|
||||
"""
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
from jituan.services.group_finance_gate import has_group_finance_exec
|
||||
if not has_group_finance_exec(request.user, request):
|
||||
return Response({
|
||||
'code': 403,
|
||||
'msg': '跨俱乐部订单处理仅集团高管可执行,请切换到具体俱乐部后再操作',
|
||||
})
|
||||
return None
|
||||
return forbid_if_order_out_of_scope(request, order)
|
||||
|
||||
@@ -5,12 +5,14 @@ import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
|
||||
from backend.utils import update_guanshi_daily_by_action
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
from jituan.services.club_penalty import resolve_gsfenhong_club_id
|
||||
from orders.models import CommissionRate
|
||||
from products.models import Czjilu, Gsfenhong
|
||||
from products.czjilu_types import is_czjilu_fulfillable_status
|
||||
from users.models import UserDashou, UserGuanshi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,15 +28,73 @@ class CzjiluFulfillError(Exception):
|
||||
self.code = code
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def fulfill_zhuanpan(dingdan_id, paid_amount_yuan=None):
|
||||
"""转盘购票履约:Czjilu 标已付 + 抽奖机会改为待抽。绝不创建抢单池 Order。"""
|
||||
from products.czjilu_types import CZJILU_LEIXING_ZHUANPAN, CZJILU_PAID_STATUS
|
||||
from products.models import ZhuanpanJihui
|
||||
|
||||
try:
|
||||
order = Czjilu.query.select_for_update().get(dingdan_id=dingdan_id)
|
||||
except Czjilu.DoesNotExist:
|
||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||
|
||||
if order.leixing != CZJILU_LEIXING_ZHUANPAN:
|
||||
raise CzjiluFulfillError('订单类型不是转盘购票', 'type_error')
|
||||
|
||||
if order.zhuangtai == CZJILU_PAID_STATUS:
|
||||
return {'already_done': True, 'dingdan_id': dingdan_id, 'zhuangtai': CZJILU_PAID_STATUS}
|
||||
|
||||
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||
return {'already_done': True, 'dingdan_id': dingdan_id, 'zhuangtai': order.zhuangtai}
|
||||
|
||||
amount = Decimal(str(order.jine))
|
||||
if paid_amount_yuan is not None:
|
||||
paid = Decimal(str(paid_amount_yuan))
|
||||
if abs(paid - amount) > Decimal('0.01'):
|
||||
raise CzjiluFulfillError(f'支付金额{paid}与订单{amount}不一致', 'amount_mismatch')
|
||||
|
||||
jihui = ZhuanpanJihui.query.select_for_update().filter(
|
||||
czjilu_dingdan_id=dingdan_id
|
||||
).first()
|
||||
if not jihui:
|
||||
raise CzjiluFulfillError('转盘机会单不存在', 'not_found')
|
||||
|
||||
order.zhuangtai = CZJILU_PAID_STATUS
|
||||
order.save(update_fields=['zhuangtai'])
|
||||
|
||||
if int(jihui.zhuangtai) == 9:
|
||||
jihui.zhuangtai = 1 # 已付待抽
|
||||
jihui.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||
|
||||
try:
|
||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||
repair_wechat_income_if_missing(
|
||||
amount,
|
||||
getattr(order, 'club_id', None) or CLUB_ID_DEFAULT,
|
||||
biz_ref=f'cz:{dingdan_id}',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('转盘购票补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
||||
|
||||
logger.info('转盘购票履约完成 dingdan=%s jihui=%s', dingdan_id, jihui.id)
|
||||
return {
|
||||
'already_done': False,
|
||||
'dingdan_id': dingdan_id,
|
||||
'zhuangtai': CZJILU_PAID_STATUS,
|
||||
'jihui_id': jihui.id,
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def fulfill_yajin_recharge(dingdan_id, paid_amount_yuan=None):
|
||||
"""押金充值幂等履约:仅 zhuangtai==9 时入账。"""
|
||||
"""押金充值幂等履约:仅待履约状态(9/用户关单2)时入账;已付(3)直接跳过防多加。"""
|
||||
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
||||
if not order:
|
||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||
if order.leixing != 2:
|
||||
raise CzjiluFulfillError('订单类型不是押金', 'type_error')
|
||||
if order.zhuangtai != 9:
|
||||
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||
try:
|
||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||
repair_wechat_income_if_missing(
|
||||
@@ -93,9 +153,20 @@ def _apply_yajin_fenhong(order, dashou, amount):
|
||||
if fenhong_jine <= 0:
|
||||
return
|
||||
guanshi = UserGuanshi.objects.select_for_update().get(user__UserUID=guanshi_id)
|
||||
guanshi.yue += fenhong_jine
|
||||
guanshi.chongzhifenrun += fenhong_jine
|
||||
guanshi.save(update_fields=['yue', 'chongzhifenrun'])
|
||||
from jituan.services.fund_freeze import credit_guanshi_fenhong
|
||||
dingdan_id = str(order.dingdan_id or '')
|
||||
result = credit_guanshi_fenhong(
|
||||
user_id=str(guanshi_id),
|
||||
amount=fenhong_jine,
|
||||
biz_id=f'{dingdan_id}:guanshi:yajin',
|
||||
profile=guanshi,
|
||||
czjilu_id=dingdan_id,
|
||||
freeze_reason=f'押金分红 {dingdan_id}',
|
||||
)
|
||||
if not result.get('idempotent'):
|
||||
UserGuanshi.objects.filter(pk=guanshi.pk).update(
|
||||
chongzhifenrun=F('chongzhifenrun') + fenhong_jine,
|
||||
)
|
||||
user_main = dashou.user
|
||||
Gsfenhong.query.create(
|
||||
dingdan_id=order.dingdan_id,
|
||||
@@ -118,13 +189,13 @@ def _apply_yajin_fenhong(order, dashou, amount):
|
||||
|
||||
@transaction.atomic
|
||||
def fulfill_jifen_recharge(dingdan_id, paid_amount_yuan=None):
|
||||
"""积分补充幂等履约:仅 zhuangtai==9 时入账。"""
|
||||
"""积分补充幂等履约:仅待履约状态(9/2)时入账。"""
|
||||
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
||||
if not order:
|
||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||
if order.leixing != 3:
|
||||
raise CzjiluFulfillError('订单类型不是积分', 'type_error')
|
||||
if order.zhuangtai != 9:
|
||||
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||
try:
|
||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||
repair_wechat_income_if_missing(
|
||||
@@ -179,7 +250,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
|
||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||
if order.leixing != 6:
|
||||
raise CzjiluFulfillError('订单类型不是罚款', 'type_error')
|
||||
if order.zhuangtai != 9:
|
||||
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||
try:
|
||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||
repair_wechat_income_if_missing(
|
||||
@@ -205,25 +276,51 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
|
||||
fadan = None
|
||||
m = re.search(r'#fadan:(\d+)', order.shuoming or '')
|
||||
if m:
|
||||
fadan_id_str = m.group(1)
|
||||
fadan = Penalty.query.filter(
|
||||
id=int(m.group(1)),
|
||||
id=int(fadan_id_str),
|
||||
PenalizedUserID=order.yonghuid,
|
||||
Status__in=[1, 3],
|
||||
).first()
|
||||
if not fadan:
|
||||
logger.warning(
|
||||
'罚款订单 %s shuoming 标记罚单 %s 未找到(被处罚者=%s, 状态需为1/3)',
|
||||
dingdan_id, fadan_id_str, order.yonghuid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'罚款订单 %s shuoming 无 #fadan:xxx 标记,回退金额匹配: %s',
|
||||
dingdan_id, order.shuoming,
|
||||
)
|
||||
if not fadan:
|
||||
fadan = Penalty.query.filter(
|
||||
PenalizedUserID=order.yonghuid,
|
||||
FineAmount=order.jine,
|
||||
Status__in=[1, 3],
|
||||
).order_by('-CreateTime').first()
|
||||
if fadan:
|
||||
logger.warning(
|
||||
'罚款订单 %s 回退金额匹配到罚单 %s(金额=%s)',
|
||||
dingdan_id, fadan.id, order.jine,
|
||||
)
|
||||
if fadan:
|
||||
fadan.Status = 2
|
||||
fadan.save(update_fields=['Status'])
|
||||
try:
|
||||
from jituan.services.deal_stats import on_penalty_paid
|
||||
on_penalty_paid(fadan)
|
||||
except Exception as exc:
|
||||
logger.warning('成交罚款指标记账跳过 fadan=%s: %s', fadan.id, exc)
|
||||
success, msg = process_fadan_fenhong(fadan.id)
|
||||
if not success:
|
||||
logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg)
|
||||
else:
|
||||
logger.info('罚单 %s 分红处理完成: %s', fadan.id, msg)
|
||||
else:
|
||||
logger.error(
|
||||
'罚款订单 %s 未找到对应罚单,罚单状态未更新(被处罚者=%s, 金额=%s)',
|
||||
dingdan_id, order.yonghuid, order.jine,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('罚款罚单状态更新失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
||||
|
||||
@@ -239,7 +336,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
|
||||
|
||||
@transaction.atomic
|
||||
def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
|
||||
"""考核支付幂等履约:仅 zhuangtai==9 时入账。"""
|
||||
"""考核支付幂等履约:仅待履约状态(9/2)时入账。"""
|
||||
from rank.models import KaohePayTemp
|
||||
from rank.utils import create_shenhe_jilu_from_temp
|
||||
from users.business_models import User
|
||||
@@ -249,7 +346,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
|
||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||
if order.leixing != 5:
|
||||
raise CzjiluFulfillError('订单类型不是考核', 'type_error')
|
||||
if order.zhuangtai != 9:
|
||||
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||
try:
|
||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||
repair_wechat_income_if_missing(
|
||||
@@ -302,7 +399,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
|
||||
|
||||
@transaction.atomic
|
||||
def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
|
||||
"""商家充值幂等履约:仅 zhuangtai==9 时入账。"""
|
||||
"""商家充值幂等履约:仅待履约状态(9/2)时入账;已付(3)跳过防多加。"""
|
||||
from users.models import UserShangjia
|
||||
|
||||
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
||||
@@ -310,7 +407,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
|
||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||
if order.leixing != 4:
|
||||
raise CzjiluFulfillError('订单类型不是商家充值', 'type_error')
|
||||
if order.zhuangtai != 9:
|
||||
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||
try:
|
||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||
repair_wechat_income_if_missing(
|
||||
@@ -350,9 +447,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
|
||||
|
||||
|
||||
def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
|
||||
"""轮询确认:支付宝订单直接看本地状态,微信订单查微信 SUCCESS 后幂等履约。"""
|
||||
from django.core.cache import cache
|
||||
|
||||
"""轮询确认:付呗/微信/支付宝官方查单成功后幂等履约(防多加)。"""
|
||||
try:
|
||||
order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid)
|
||||
except Czjilu.DoesNotExist:
|
||||
@@ -373,21 +468,22 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
|
||||
logger.error('充值单补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
||||
return {'already_done': True, 'zhuangtai': 3}
|
||||
|
||||
if order.zhuangtai != 9:
|
||||
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||
raise CzjiluFulfillError(f'订单状态异常: {order.zhuangtai}', 'status_error')
|
||||
|
||||
# 支付宝订单:异步回调会更新状态,轮询时只看本地状态,未支付则提示稍后重试
|
||||
is_alipay = cache.get(f'alipay_pay_url_{dingdan_id}') is not None
|
||||
if is_alipay:
|
||||
raise CzjiluFulfillError('支付宝支付结果尚未同步,请稍后再试', 'not_paid')
|
||||
|
||||
# 微信订单:主动查单
|
||||
from jituan.services.member_recharge import query_wechat_v2_trade_state
|
||||
trade_state, _ = query_wechat_v2_trade_state(dingdan_id, getattr(order, 'club_id', None))
|
||||
if trade_state != 'SUCCESS':
|
||||
raise CzjiluFulfillError('微信侧尚未支付成功,请稍后再试', 'not_paid')
|
||||
|
||||
paid = Decimal(str(order.jine))
|
||||
club_id = getattr(order, 'club_id', None)
|
||||
|
||||
from jituan.services.pay_confirm import confirm_remote_payment
|
||||
remote = confirm_remote_payment(dingdan_id, club_id, prefer_amount=paid)
|
||||
if not remote.get('ok'):
|
||||
if remote.get('pending'):
|
||||
raise CzjiluFulfillError('支付结果尚未同步,请稍后再试', 'not_paid')
|
||||
raise CzjiluFulfillError(remote.get('msg') or '支付未成功', 'not_paid')
|
||||
if remote.get('amount') is not None:
|
||||
paid = Decimal(str(remote['amount']))
|
||||
|
||||
# 查单确认支付成功,执行履约(fulfill_* 内行锁+仅 9/2→3,防多加)
|
||||
if expected_leixing == 1:
|
||||
from jituan.services.member_recharge import fulfill_member_recharge
|
||||
return fulfill_member_recharge(dingdan_id, paid_amount_yuan=paid, source='confirm')
|
||||
@@ -401,4 +497,15 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
|
||||
return fulfill_kaohe(dingdan_id, paid_amount_yuan=paid)
|
||||
if expected_leixing == 6:
|
||||
return fulfill_fakuan(dingdan_id, paid_amount_yuan=paid)
|
||||
if expected_leixing == 7:
|
||||
return fulfill_zhuanpan(dingdan_id, paid_amount_yuan=paid)
|
||||
raise CzjiluFulfillError('不支持的充值类型', 'type_error')
|
||||
|
||||
|
||||
def repair_czjilu_payment(dingdan_id):
|
||||
"""客服补单:官方查单确认已付后,走同一套幂等履约(绝不多加)。"""
|
||||
try:
|
||||
order = Czjilu.query.get(dingdan_id=dingdan_id)
|
||||
except Czjilu.DoesNotExist:
|
||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||
return confirm_czjilu_paid(dingdan_id, order.yonghuid, expected_leixing=order.leixing)
|
||||
|
||||
416
jituan/services/deal_stats.py
Normal file
416
jituan/services/deal_stats.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""成交/罚款指标幂等记账(仅 stats_enabled 且 CreateTime>=stats_since)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _cfg(club_id: str):
|
||||
from jituan.services.order_deal import get_or_create_deal_config
|
||||
return get_or_create_deal_config(club_id)
|
||||
|
||||
|
||||
def _stats_on(cfg, order) -> bool:
|
||||
"""
|
||||
成交指标始终记账(抢单池要展示成交率/罚款率/均时)。
|
||||
若配置了 stats_since,只统计该时间点之后的订单。
|
||||
"""
|
||||
if not order:
|
||||
return False
|
||||
since = getattr(cfg, 'stats_since', None) if cfg else None
|
||||
if since:
|
||||
ct = getattr(order, 'CreateTime', None)
|
||||
if ct and ct < since:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def ensure_club_stats_bootstrapped(club_id: str, *, lookback_days: int = 180):
|
||||
"""
|
||||
俱乐部首次拉取抢单池商家指标时,若从未记账则回填近 N 天历史订单。
|
||||
幂等:用 DealStatEvent 打标记。
|
||||
"""
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return
|
||||
marker = f'{cid}:stats_bootstrap_v1'
|
||||
from jituan.models import DealStatEvent, MerchantDealStat
|
||||
if DealStatEvent.query.filter(event_key=marker).exists():
|
||||
return
|
||||
# 已有商家样本则只打标,避免重复重算
|
||||
if MerchantDealStat.query.filter(club_id=cid, order_count__gt=0).exists():
|
||||
_claim_event(cid, marker, 'stats_bootstrap', subject_uid='system')
|
||||
return
|
||||
try:
|
||||
from orders.models import Order
|
||||
from datetime import timedelta
|
||||
since = timezone.now() - timedelta(days=max(7, int(lookback_days or 180)))
|
||||
qs = (
|
||||
Order.query.filter(ClubID=cid, CreateTime__gte=since)
|
||||
.exclude(PlayerID__isnull=True)
|
||||
.exclude(PlayerID='')
|
||||
.filter(Status__in=[2, 3, 4, 5, 6, 8])
|
||||
.order_by('CreateTime')[:800]
|
||||
)
|
||||
for order in qs:
|
||||
try:
|
||||
on_order_enter_pool(order, force=True)
|
||||
if int(order.Status or 0) == 3:
|
||||
on_order_completed(order, settle_started_at=getattr(order, 'SettlementTime', None), force=True)
|
||||
elif int(order.Status or 0) == 5:
|
||||
on_order_refunded(order, force=True)
|
||||
except Exception as e:
|
||||
logger.warning('回填单条成交指标失败 order=%s: %s', getattr(order, 'OrderID', ''), e)
|
||||
_claim_event(cid, marker, 'stats_bootstrap', subject_uid='system', meta={'n': len(qs)})
|
||||
logger.info('俱乐部 %s 成交指标回填完成 n=%s', cid, len(qs))
|
||||
except Exception as e:
|
||||
logger.error('俱乐部成交指标回填失败 club=%s: %s', cid, e, exc_info=True)
|
||||
|
||||
|
||||
def _claim_event(club_id: str, event_key: str, event_type: str, order_id='', subject_uid='', meta=None) -> bool:
|
||||
from jituan.models import DealStatEvent
|
||||
if DealStatEvent.query.filter(event_key=event_key).exists():
|
||||
return False
|
||||
try:
|
||||
DealStatEvent.objects.create(
|
||||
club_id=club_id,
|
||||
event_key=event_key[:96],
|
||||
event_type=event_type[:32],
|
||||
order_id=(order_id or '')[:32],
|
||||
subject_uid=(subject_uid or '')[:16],
|
||||
meta_json=meta or {},
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_merchant(club_id, uid):
|
||||
from jituan.models import MerchantDealStat
|
||||
from django.db import IntegrityError
|
||||
row = MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first()
|
||||
if row:
|
||||
return row
|
||||
try:
|
||||
row = MerchantDealStat(club_id=club_id, merchant_uid=uid)
|
||||
row.save()
|
||||
return row
|
||||
except IntegrityError:
|
||||
# 并发回填/记账时可能已有同 club+merchant 行,读回即可
|
||||
return MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first()
|
||||
|
||||
|
||||
def _ensure_dashou(club_id, uid):
|
||||
from jituan.models import DashouDealStat
|
||||
from django.db import IntegrityError
|
||||
row = DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first()
|
||||
if row:
|
||||
return row
|
||||
try:
|
||||
row = DashouDealStat(club_id=club_id, dashou_uid=uid)
|
||||
row.save()
|
||||
return row
|
||||
except IntegrityError:
|
||||
return DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first()
|
||||
|
||||
|
||||
def _merchant_uid(order):
|
||||
try:
|
||||
ext = getattr(order, 'shangjia_kuozhan', None)
|
||||
return (getattr(ext, 'MerchantID', None) or '') if ext else ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def on_order_enter_pool(order, *, force=False):
|
||||
"""接单入池:商家/打手 order_count+1。force=True 用于历史回填(忽略 since)。"""
|
||||
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
||||
cfg = _cfg(club_id)
|
||||
if not force and not _stats_on(cfg, order):
|
||||
return
|
||||
oid = order.OrderID
|
||||
dashou = (order.PlayerID or '').strip()
|
||||
if dashou and _claim_event(club_id, f'{oid}:dashou_in', 'order_in_pool', oid, dashou):
|
||||
_ensure_dashou(club_id, dashou)
|
||||
from jituan.models import DashouDealStat
|
||||
DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update(
|
||||
order_count=F('order_count') + 1, UpdateTime=timezone.now(),
|
||||
)
|
||||
if order.Platform == 2:
|
||||
mid = _merchant_uid(order)
|
||||
if mid and _claim_event(club_id, f'{oid}:merchant_in', 'order_in_pool', oid, mid):
|
||||
_ensure_merchant(club_id, mid)
|
||||
from jituan.models import MerchantDealStat
|
||||
MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update(
|
||||
order_count=F('order_count') + 1, UpdateTime=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def on_order_completed(order, settle_started_at=None, *, force=False):
|
||||
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
||||
cfg = _cfg(club_id)
|
||||
if not force and not _stats_on(cfg, order):
|
||||
return
|
||||
oid = order.OrderID
|
||||
if not _claim_event(club_id, f'{oid}:completed', 'completed', oid):
|
||||
return
|
||||
# 确保入池
|
||||
on_order_enter_pool(order, force=force)
|
||||
|
||||
st = settle_started_at or getattr(order, '_deal_settle_started_at', None)
|
||||
duration = None
|
||||
if st:
|
||||
try:
|
||||
duration = max(0, int((timezone.now() - st).total_seconds()))
|
||||
except Exception:
|
||||
duration = None
|
||||
|
||||
dashou = (order.PlayerID or '').strip()
|
||||
if dashou:
|
||||
_ensure_dashou(club_id, dashou)
|
||||
from jituan.models import DashouDealStat
|
||||
DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update(
|
||||
completed_count=F('completed_count') + 1, UpdateTime=timezone.now(),
|
||||
)
|
||||
if order.Platform == 2:
|
||||
mid = _merchant_uid(order)
|
||||
if mid:
|
||||
_ensure_merchant(club_id, mid)
|
||||
from jituan.models import MerchantDealStat
|
||||
qs = MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid)
|
||||
qs.update(completed_count=F('completed_count') + 1, UpdateTime=timezone.now())
|
||||
if duration is not None:
|
||||
qs.update(
|
||||
settle_duration_sum_seconds=F('settle_duration_sum_seconds') + duration,
|
||||
settle_duration_sample_count=F('settle_duration_sample_count') + 1,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def on_order_refunded(order, *, force=False):
|
||||
club_id = (getattr(order, 'ClubID', None) or '').strip()
|
||||
cfg = _cfg(club_id)
|
||||
if (not force and not _stats_on(cfg, order)) or order.Platform != 2:
|
||||
return
|
||||
oid = order.OrderID
|
||||
mid = _merchant_uid(order)
|
||||
if not mid:
|
||||
return
|
||||
if not _claim_event(club_id, f'{oid}:refunded', 'refunded', oid, mid):
|
||||
return
|
||||
on_order_enter_pool(order, force=force)
|
||||
_ensure_merchant(club_id, mid)
|
||||
from jituan.models import MerchantDealStat
|
||||
MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update(
|
||||
refund_count=F('refund_count') + 1, UpdateTime=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def on_penalty_paid(penalty):
|
||||
"""罚单 Status→2 已缴纳。"""
|
||||
from utils.penalty_status import PENALTY_PAID
|
||||
if int(getattr(penalty, 'Status', 0) or 0) != PENALTY_PAID:
|
||||
return
|
||||
club_id = (getattr(penalty, 'ClubID', None) or '').strip()
|
||||
if not club_id:
|
||||
return
|
||||
pid = getattr(penalty, 'id', None) or getattr(penalty, 'pk', None)
|
||||
key = f'penalty:{pid}:paid'
|
||||
if not _claim_event(club_id, key, 'fine_paid', getattr(penalty, 'RelatedOrderID', '') or ''):
|
||||
return
|
||||
applicant = (getattr(penalty, 'ApplicantID', None) or '').strip()
|
||||
penalized = (getattr(penalty, 'PenalizedUserID', None) or '').strip()
|
||||
# 商家申请人 → 商家罚款率分子
|
||||
if applicant:
|
||||
_ensure_merchant(club_id, applicant)
|
||||
from jituan.models import MerchantDealStat
|
||||
MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=applicant).update(
|
||||
fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(),
|
||||
)
|
||||
# 被罚打手 → 被罚款率
|
||||
if penalized:
|
||||
_ensure_dashou(club_id, penalized)
|
||||
from jituan.models import DashouDealStat
|
||||
DashouDealStat.objects.filter(club_id=club_id, dashou_uid=penalized).update(
|
||||
fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
def serialize_merchant_stat(row) -> dict:
|
||||
n = int(row.order_count or 0)
|
||||
c = int(row.completed_count or 0)
|
||||
r = int(row.refund_count or 0)
|
||||
f = int(row.fine_paid_count or 0)
|
||||
sc = int(row.settle_duration_sample_count or 0)
|
||||
ss = int(row.settle_duration_sum_seconds or 0)
|
||||
return {
|
||||
'merchant_uid': row.merchant_uid,
|
||||
'order_count': n,
|
||||
'completed_count': c,
|
||||
'refund_count': r,
|
||||
'fine_paid_count': f,
|
||||
'deal_rate': round(c / n, 6) if n else 0,
|
||||
'refund_rate': round(r / n, 6) if n else 0,
|
||||
'fine_rate': round(f / n, 6) if n else 0,
|
||||
'avg_deal_hours': round((ss / sc) / 3600.0, 4) if sc else 0,
|
||||
'settle_duration_sample_count': sc,
|
||||
}
|
||||
|
||||
|
||||
def pool_merchant_stat_payload(row) -> dict | None:
|
||||
"""
|
||||
抢单池展示用:有订单样本才返回。
|
||||
成交率/罚款率有样本即返回;均时仅有结算样本时返回(含均时≈0)。
|
||||
前端按字段有哪个展示哪个。
|
||||
"""
|
||||
if not row:
|
||||
return None
|
||||
n = int(row.order_count or 0)
|
||||
if n <= 0:
|
||||
return None
|
||||
full = serialize_merchant_stat(row)
|
||||
out = {
|
||||
'deal_rate': full['deal_rate'],
|
||||
'fine_rate': full['fine_rate'],
|
||||
'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%",
|
||||
'fine_rate_text': f"{round(full['fine_rate'] * 100, 1)}%",
|
||||
}
|
||||
sc = int(full.get('settle_duration_sample_count') or 0)
|
||||
if sc > 0:
|
||||
h = float(full.get('avg_deal_hours') or 0)
|
||||
out['avg_deal_hours'] = h
|
||||
if h < 1:
|
||||
out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' if h > 0 else '不足1分钟'
|
||||
else:
|
||||
out['avg_deal_hours_text'] = f'{round(h, 1)}小时'
|
||||
return out
|
||||
|
||||
|
||||
def batch_pool_merchant_stats(club_id: str, merchant_uids, order_club_map=None) -> dict:
|
||||
"""
|
||||
批量取商家抢单池指标。
|
||||
order_club_map: {merchant_uid: order_club_id} 优先用订单所属俱乐部查指标。
|
||||
返回 {merchant_uid: payload}
|
||||
"""
|
||||
from jituan.models import MerchantDealStat
|
||||
uids = [str(u).strip() for u in (merchant_uids or []) if u and str(u).strip()]
|
||||
if not uids:
|
||||
return {}
|
||||
default_club = (club_id or '').strip()
|
||||
# 首次无样本时回填历史,保证旧单刷新也能看到成交率/罚款率/均时
|
||||
try:
|
||||
if default_club:
|
||||
ensure_club_stats_bootstrapped(default_club)
|
||||
if order_club_map:
|
||||
for cid in set((c or '').strip() for c in order_club_map.values() if c):
|
||||
if cid and cid != default_club:
|
||||
ensure_club_stats_bootstrapped(cid)
|
||||
except Exception as e:
|
||||
logger.warning('抢单池成交指标回填触发失败: %s', e)
|
||||
club_ids = {default_club} if default_club else set()
|
||||
if order_club_map:
|
||||
for cid in order_club_map.values():
|
||||
c = (cid or '').strip()
|
||||
if c:
|
||||
club_ids.add(c)
|
||||
if not club_ids:
|
||||
return {}
|
||||
rows = list(
|
||||
MerchantDealStat.query.filter(
|
||||
club_id__in=list(club_ids),
|
||||
merchant_uid__in=uids,
|
||||
)
|
||||
)
|
||||
by_key = {(r.club_id, r.merchant_uid): r for r in rows}
|
||||
out = {}
|
||||
missing = []
|
||||
for uid in uids:
|
||||
prefer = ((order_club_map or {}).get(uid) or default_club or '').strip()
|
||||
row = by_key.get((prefer, uid))
|
||||
if not row:
|
||||
for cid in club_ids:
|
||||
row = by_key.get((cid, uid))
|
||||
if row:
|
||||
break
|
||||
if not row:
|
||||
missing.append(uid)
|
||||
continue
|
||||
payload = pool_merchant_stat_payload(row)
|
||||
if payload:
|
||||
out[uid] = payload
|
||||
# 订单俱乐部对不上时,按商家 UID 兜底(取样本最多的一条)
|
||||
if missing:
|
||||
for r in MerchantDealStat.query.filter(merchant_uid__in=missing).order_by('-order_count'):
|
||||
uid = r.merchant_uid
|
||||
if uid in out:
|
||||
continue
|
||||
payload = pool_merchant_stat_payload(r)
|
||||
if payload:
|
||||
out[uid] = payload
|
||||
return out
|
||||
|
||||
|
||||
def serialize_dashou_stat(row) -> dict:
|
||||
n = int(row.order_count or 0)
|
||||
c = int(row.completed_count or 0)
|
||||
f = int(row.fine_paid_count or 0)
|
||||
return {
|
||||
'dashou_uid': row.dashou_uid,
|
||||
'order_count': n,
|
||||
'completed_count': c,
|
||||
'fine_paid_count': f,
|
||||
'deal_rate': round(c / n, 6) if n else 0,
|
||||
'fined_rate': round(f / n, 6) if n else 0,
|
||||
}
|
||||
|
||||
|
||||
def miniapp_merchant_deal_payload(row) -> dict | None:
|
||||
"""小程序商家订单列表顶:有样本才返回;文案字段供前端标「结算率」。"""
|
||||
if not row:
|
||||
return None
|
||||
n = int(row.order_count or 0)
|
||||
if n <= 0:
|
||||
return None
|
||||
full = serialize_merchant_stat(row)
|
||||
out = {
|
||||
'role': 'merchant',
|
||||
'order_count': full['order_count'],
|
||||
'deal_rate': full['deal_rate'],
|
||||
'fine_rate': full['fine_rate'],
|
||||
'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%",
|
||||
'fine_rate_text': f"{round(full['fine_rate'] * 100, 1)}%",
|
||||
}
|
||||
sc = int(full.get('settle_duration_sample_count') or 0)
|
||||
if sc > 0:
|
||||
h = float(full.get('avg_deal_hours') or 0)
|
||||
out['avg_deal_hours'] = h
|
||||
if h < 1:
|
||||
out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' if h > 0 else '不足1分钟'
|
||||
else:
|
||||
out['avg_deal_hours_text'] = f'{round(h, 1)}小时'
|
||||
return out
|
||||
|
||||
|
||||
def miniapp_dashou_deal_payload(row) -> dict | None:
|
||||
"""小程序打手订单列表顶:有样本才返回。"""
|
||||
if not row:
|
||||
return None
|
||||
n = int(row.order_count or 0)
|
||||
if n <= 0:
|
||||
return None
|
||||
full = serialize_dashou_stat(row)
|
||||
return {
|
||||
'role': 'dashou',
|
||||
'order_count': full['order_count'],
|
||||
'deal_rate': full['deal_rate'],
|
||||
'fined_rate': full['fined_rate'],
|
||||
'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%",
|
||||
'fined_rate_text': f"{round(full['fined_rate'] * 100, 1)}%",
|
||||
}
|
||||
47
jituan/services/default_invite.py
Normal file
47
jituan/services/default_invite.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""俱乐部默认打手邀请码(一键登录无扫码时使用)。"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_KEY = 'default_dashou_invite'
|
||||
|
||||
# 与 club_migrate.CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE 保持同步(无 config_json 时回落)
|
||||
_FALLBACK_DEFAULT_INVITE = {
|
||||
'lxs': '0000008VfJKPu4Spmcjt',
|
||||
'xq': '0000008RffVgKHMj7kQC',
|
||||
}
|
||||
|
||||
|
||||
def get_club_default_dashou_invite(club_id: str) -> str:
|
||||
"""
|
||||
优先读 Club.config_json.default_dashou_invite,
|
||||
否则回落内置各店默认管事邀请码。
|
||||
"""
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return ''
|
||||
try:
|
||||
from jituan.models import Club
|
||||
club = Club.query.filter(club_id=cid).first()
|
||||
if club:
|
||||
cfg = club.config_json or {}
|
||||
code = (cfg.get(CONFIG_KEY) or '').strip()
|
||||
if code:
|
||||
return code
|
||||
except Exception:
|
||||
logger.exception('read default_dashou_invite failed club=%s', cid)
|
||||
try:
|
||||
from jituan.services.club_migrate import CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE
|
||||
code = (CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE.get(cid) or '').strip()
|
||||
if code:
|
||||
return code
|
||||
except Exception:
|
||||
pass
|
||||
return (_FALLBACK_DEFAULT_INVITE.get(cid) or '').strip()
|
||||
|
||||
|
||||
def set_club_default_dashou_invite(club, invite_code: str) -> None:
|
||||
"""写入 club.config_json,调用方负责 save。"""
|
||||
cfg = dict(getattr(club, 'config_json', None) or {})
|
||||
cfg[CONFIG_KEY] = (invite_code or '').strip()
|
||||
club.config_json = cfg
|
||||
@@ -11,26 +11,44 @@ from rest_framework.response import Response
|
||||
# 定稿 B 方案 page_key
|
||||
LUNBO_PAGE_ORDER_POOL = 'order_pool'
|
||||
LUNBO_PAGE_ACCEPT_ORDER = 'accept_order'
|
||||
LUNBO_PAGE_ACCEPT_BANNER2 = 'accept_order_banner2' # 点单页第二横幅轮播(如 UFO banner2)
|
||||
LUNBO_PAGE_MERCHANT_HOME = 'merchant_home'
|
||||
LUNBO_PAGE_DASHOU_CENTER = 'dashou_center'
|
||||
LUNBO_PAGE_BOSS_CENTER = 'boss_center' # 点单端「我的」顶区背景
|
||||
|
||||
LUNBO_CENTER_BG_KEYS = {LUNBO_PAGE_DASHOU_CENTER, LUNBO_PAGE_BOSS_CENTER}
|
||||
LUNBO_CAROUSEL_KEYS = {
|
||||
LUNBO_PAGE_ORDER_POOL,
|
||||
LUNBO_PAGE_ACCEPT_ORDER,
|
||||
LUNBO_PAGE_ACCEPT_BANNER2,
|
||||
LUNBO_PAGE_MERCHANT_HOME,
|
||||
}
|
||||
|
||||
LUNBO_PAGE_OPTIONS = [
|
||||
(LUNBO_PAGE_ORDER_POOL, '抢单池'),
|
||||
(LUNBO_PAGE_ACCEPT_ORDER, '点单页'),
|
||||
(LUNBO_PAGE_ACCEPT_ORDER, '点单页主轮播'),
|
||||
(LUNBO_PAGE_ACCEPT_BANNER2, '点单页第二横幅(可多图)'),
|
||||
(LUNBO_PAGE_MERCHANT_HOME, '商家首页'),
|
||||
(LUNBO_PAGE_DASHOU_CENTER, '打手个人中心背景'),
|
||||
(LUNBO_PAGE_BOSS_CENTER, '点单个人中心背景(可多图)'),
|
||||
]
|
||||
|
||||
|
||||
def normalize_page_key(page_key, image_type=1):
|
||||
key = (page_key or '').strip()
|
||||
if key in LUNBO_CENTER_BG_KEYS or key in LUNBO_CAROUSEL_KEYS:
|
||||
return key
|
||||
if image_type == 2:
|
||||
return LUNBO_PAGE_DASHOU_CENTER
|
||||
if key in {LUNBO_PAGE_ORDER_POOL, LUNBO_PAGE_ACCEPT_ORDER, LUNBO_PAGE_MERCHANT_HOME}:
|
||||
return key
|
||||
return LUNBO_PAGE_ORDER_POOL
|
||||
|
||||
|
||||
def lunbo_image_type_for_page(page_key):
|
||||
"""个人中心背景页用 ImageType=2,其余轮播用 1。"""
|
||||
pk = normalize_page_key(page_key, image_type=1)
|
||||
return 2 if pk in LUNBO_CENTER_BG_KEYS else 1
|
||||
|
||||
|
||||
def forbid_display_write_in_all_scope(request):
|
||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||
return Response({'code': 403, 'msg': '请在子公司视图下修改展示配置'})
|
||||
@@ -73,6 +91,9 @@ def get_lunbo_urls(request, page_key=None, image_type=1):
|
||||
from config.models import Lunbo
|
||||
club_id = _display_club_id(request)
|
||||
pk = normalize_page_key(page_key, image_type=image_type)
|
||||
# 个人中心背景页一律 ImageType=2,避免误传 image_type=1 读不到
|
||||
if pk in LUNBO_CENTER_BG_KEYS:
|
||||
image_type = 2
|
||||
qs = Lunbo.query.filter(club_id=club_id, ImageType=image_type, page_key=pk)
|
||||
return [row.ImageURL for row in qs.order_by('id') if row.ImageURL]
|
||||
|
||||
@@ -108,12 +129,20 @@ def list_tupianpeizhi_by_type(club_id, image_type):
|
||||
|
||||
|
||||
def upsert_tupianpeizhi_single(club_id, image_type, image_url):
|
||||
"""ImageType 1/2 每俱乐部仅一条。"""
|
||||
"""ImageType 1/2 每俱乐部仅一条;换图时删除旧 OSS 对象。"""
|
||||
from config.models import Tupianpeizhi
|
||||
from utils.oss_utils import delete_from_oss
|
||||
|
||||
row = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type).first()
|
||||
if row:
|
||||
old = (row.ImageURL or '').strip()
|
||||
row.ImageURL = image_url
|
||||
row.save(update_fields=['ImageURL', 'UpdateTime'])
|
||||
if old and old != image_url:
|
||||
try:
|
||||
delete_from_oss(old)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
Tupianpeizhi.query.create(
|
||||
club_id=club_id, ImageType=image_type, ImageURL=image_url, AccessCount=0,
|
||||
|
||||
@@ -113,6 +113,8 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20
|
||||
'scope': resolve_club_scope(request),
|
||||
'scope_error': CLUB_READ_SCOPE_MSG,
|
||||
'leixing_options': build_leixing_options(),
|
||||
'fake_grab_enabled': False,
|
||||
'fake_yajin_hide_threshold': 10,
|
||||
**list_response_meta(request),
|
||||
}
|
||||
|
||||
@@ -129,6 +131,11 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20
|
||||
rows = list(qs[offset:offset + page_size])
|
||||
names = _leixing_name_map()
|
||||
|
||||
from jituan.services.fake_grab_switch import (
|
||||
get_fake_yajin_hide_threshold,
|
||||
is_club_fake_grab_enabled,
|
||||
)
|
||||
|
||||
return {
|
||||
'list': [_row_to_dict(r, names) for r in rows],
|
||||
'total': total,
|
||||
@@ -137,10 +144,64 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20
|
||||
'club_id': club_id,
|
||||
'scope': resolve_club_scope(request),
|
||||
'leixing_options': build_leixing_options(),
|
||||
'fake_grab_enabled': is_club_fake_grab_enabled(club_id),
|
||||
'fake_yajin_hide_threshold': float(get_fake_yajin_hide_threshold(club_id)),
|
||||
**list_response_meta(request),
|
||||
}
|
||||
|
||||
|
||||
def set_fake_grab_enabled(request, enabled):
|
||||
"""开启/关闭本俱乐部打手侧假单功能。"""
|
||||
if club_write_blocked(request):
|
||||
return None, CLUB_WRITE_SCOPE_MSG
|
||||
|
||||
from jituan.models import Club
|
||||
from jituan.services.fake_grab_switch import (
|
||||
get_fake_yajin_hide_threshold,
|
||||
is_club_fake_grab_enabled,
|
||||
set_club_fake_grab_enabled,
|
||||
)
|
||||
|
||||
club_id = club_id_for_write(request)
|
||||
club = Club.query.filter(club_id=club_id).first()
|
||||
if not club:
|
||||
return None, '俱乐部不存在'
|
||||
|
||||
set_club_fake_grab_enabled(club, bool(enabled))
|
||||
club.save(update_fields=['config_json'])
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'fake_grab_enabled': is_club_fake_grab_enabled(club_id),
|
||||
'fake_yajin_hide_threshold': float(get_fake_yajin_hide_threshold(club_id)),
|
||||
}, None
|
||||
|
||||
|
||||
def set_fake_yajin_threshold(request, threshold):
|
||||
"""设置押金 ≥ 该值时过滤假单。"""
|
||||
if club_write_blocked(request):
|
||||
return None, CLUB_WRITE_SCOPE_MSG
|
||||
|
||||
from jituan.models import Club
|
||||
from jituan.services.fake_grab_switch import (
|
||||
get_fake_yajin_hide_threshold,
|
||||
is_club_fake_grab_enabled,
|
||||
set_fake_yajin_hide_threshold,
|
||||
)
|
||||
|
||||
club_id = club_id_for_write(request)
|
||||
club = Club.query.filter(club_id=club_id).first()
|
||||
if not club:
|
||||
return None, '俱乐部不存在'
|
||||
|
||||
val = set_fake_yajin_hide_threshold(club, threshold)
|
||||
club.save(update_fields=['config_json'])
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'fake_grab_enabled': is_club_fake_grab_enabled(club_id),
|
||||
'fake_yajin_hide_threshold': float(val),
|
||||
}, None
|
||||
|
||||
|
||||
def _parse_decimal(value, field_name='金额'):
|
||||
try:
|
||||
val = Decimal(str(value))
|
||||
|
||||
86
jituan/services/fake_grab_switch.py
Normal file
86
jituan/services/fake_grab_switch.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""俱乐部假单开关 + 押金过滤门槛(config_json)。"""
|
||||
import logging
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_KEY = 'fake_grab_order_enabled'
|
||||
YAJIN_THRESHOLD_KEY = 'fake_grab_yajin_hide_threshold'
|
||||
DEFAULT_YAJIN_HIDE_THRESHOLD = Decimal('10')
|
||||
|
||||
|
||||
def is_club_fake_grab_enabled(club_id: str) -> bool:
|
||||
"""某俱乐部是否对打手侧开启假单池。默认关闭。"""
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return False
|
||||
try:
|
||||
from django.conf import settings
|
||||
if not getattr(settings, 'FAKE_GRAB_ORDER_POOL_ENABLED', True):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from jituan.models import Club
|
||||
club = Club.query.filter(club_id=cid).first()
|
||||
if not club and cid != cid.lower():
|
||||
club = Club.query.filter(club_id=cid.lower()).first()
|
||||
if not club:
|
||||
return False
|
||||
cfg = club.config_json or {}
|
||||
raw = cfg.get(CONFIG_KEY)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
return bool(raw)
|
||||
except Exception:
|
||||
logger.exception('read fake_grab_order_enabled failed club=%s', cid)
|
||||
return False
|
||||
|
||||
|
||||
def get_fake_yajin_hide_threshold(club_id: str) -> Decimal:
|
||||
"""
|
||||
押金 ≥ 该值时过滤假单(只看真单)。
|
||||
后台可配,默认 10。
|
||||
"""
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return DEFAULT_YAJIN_HIDE_THRESHOLD
|
||||
try:
|
||||
from jituan.models import Club
|
||||
club = Club.query.filter(club_id=cid).first()
|
||||
if not club:
|
||||
return DEFAULT_YAJIN_HIDE_THRESHOLD
|
||||
cfg = club.config_json or {}
|
||||
raw = cfg.get(YAJIN_THRESHOLD_KEY, DEFAULT_YAJIN_HIDE_THRESHOLD)
|
||||
try:
|
||||
val = Decimal(str(raw))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return DEFAULT_YAJIN_HIDE_THRESHOLD
|
||||
if val < 0:
|
||||
return DEFAULT_YAJIN_HIDE_THRESHOLD
|
||||
return val
|
||||
except Exception:
|
||||
logger.exception('read fake_grab_yajin_hide_threshold failed club=%s', cid)
|
||||
return DEFAULT_YAJIN_HIDE_THRESHOLD
|
||||
|
||||
|
||||
def set_club_fake_grab_enabled(club, enabled: bool) -> None:
|
||||
"""写入 club.config_json,调用方负责 save。"""
|
||||
cfg = dict(getattr(club, 'config_json', None) or {})
|
||||
cfg[CONFIG_KEY] = bool(enabled)
|
||||
club.config_json = cfg
|
||||
|
||||
|
||||
def set_fake_yajin_hide_threshold(club, threshold) -> Decimal:
|
||||
"""写入押金过滤门槛,调用方负责 save。返回规范化后的值。"""
|
||||
try:
|
||||
val = Decimal(str(threshold))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
val = DEFAULT_YAJIN_HIDE_THRESHOLD
|
||||
if val < 0:
|
||||
val = DEFAULT_YAJIN_HIDE_THRESHOLD
|
||||
cfg = dict(getattr(club, 'config_json', None) or {})
|
||||
# 存成可 JSON 序列化的数字
|
||||
cfg[YAJIN_THRESHOLD_KEY] = float(val)
|
||||
club.config_json = cfg
|
||||
return val
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user