Compare commits
306 Commits
9dbca25b4d
...
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 | |||
|
|
2a99e2ea10 | ||
|
|
390202b9e4 | ||
|
|
65dae4f3ab | ||
|
|
df49631401 | ||
|
|
90e187b65c | ||
|
|
a92352f2b0 | ||
|
|
214b52badd | ||
| 581295443a | |||
|
|
914e8c70f2 | ||
| 7443fef6de | |||
| d734ce1dcc | |||
| 6c0a461a2b | |||
|
|
f2295717ee | ||
|
|
fd7bcde9d9 | ||
| fb8d905fb0 | |||
| f702c13422 | |||
| bf1088d0c5 | |||
| 51edcf9fce | |||
| 0826b0749d | |||
| aa3aa932fe | |||
| 5755730093 | |||
| de3ff211a6 | |||
| e2b95edeb8 | |||
| 92abc0d9e7 | |||
| d7d38d9b08 | |||
| 9c11698660 | |||
|
|
d64042fe46 | ||
|
|
5754f6a509 | ||
|
|
d5fbc08364 | ||
|
|
b2a2da0f97 | ||
|
|
c2db2ad583 | ||
|
|
2248a61936 | ||
|
|
39dfe3d52e | ||
|
|
1ffd92758d | ||
|
|
ad33ee6c86 | ||
|
|
d025287785 | ||
|
|
e0c48073f1 | ||
|
|
7fe49775b9 | ||
|
|
2225722ab5 | ||
|
|
aeaa54583a | ||
|
|
f23c8a3360 | ||
|
|
a47d4ee0a6 | ||
|
|
95d7e8d13d | ||
|
|
f87a40fbd6 | ||
|
|
627eeeb29b | ||
|
|
c464c31140 | ||
|
|
7c3f04e7f3 | ||
|
|
1c6e908f28 | ||
|
|
cec7d69199 | ||
|
|
38fc52241f | ||
|
|
15cef26d81 | ||
|
|
aaa745baf4 | ||
|
|
12f9100b17 | ||
|
|
bcfa30d5de | ||
|
|
7edb8f22cb | ||
|
|
4eb74ae968 | ||
|
|
7819f1a897 | ||
|
|
3ec821ae6a | ||
|
|
b54904a9f2 | ||
|
|
abfe497ddc | ||
|
|
f46e36b539 | ||
|
|
eedd5fbd46 | ||
|
|
86e7a60642 | ||
|
|
dfd4bed221 | ||
|
|
3a2b8a2bba | ||
|
|
0062d495c9 | ||
|
|
7dc78acb73 | ||
|
|
1e3d65e0e4 | ||
|
|
bfd5a63cb4 | ||
|
|
6e2e1944b6 | ||
|
|
2edca09859 | ||
|
|
7bb891187b | ||
|
|
a14cba63e6 | ||
|
|
ebcedbdea1 | ||
|
|
45a4dde034 | ||
|
|
553d2d5707 | ||
|
|
1e54d8b871 | ||
|
|
df6c6912d9 | ||
|
|
64ddb7d0b8 | ||
|
|
7c763ad5b4 | ||
|
|
1c6607fa6e | ||
|
|
2bbe166d63 | ||
|
|
2573db1bbf | ||
|
|
1dfb46c38c | ||
| f1c8633345 | |||
|
|
76ea5f8255 | ||
|
|
59a6deef73 | ||
|
|
ddfd3d3ac7 | ||
|
|
2a350a2ec8 | ||
|
|
37bc8f960a | ||
|
|
5754a69e0b | ||
|
|
69c7ffd962 |
@@ -1,120 +1,61 @@
|
||||
"""
|
||||
阿龙电竞 - Celery定时任务主配置
|
||||
阿龙电竞 - 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,12 +385,21 @@ 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',
|
||||
}
|
||||
|
||||
|
||||
# ==================== 支付宝支付 ====================
|
||||
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')),
|
||||
]
|
||||
|
||||
# 重要说明:
|
||||
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
259
backend/services/shop_copy_service.py
Normal file
259
backend/services/shop_copy_service.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
店铺商品目录复制服务:复制店铺商品类型、专区、商品(含 OSS 图片独立拷贝)。
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from products.models import Shangpin, ShangpinZhuanqu
|
||||
from shop.models import Dianpu, ShangpinLeixingDianpu
|
||||
from utils.oss_utils import copy_in_oss, delete_from_oss, _url_to_oss_key
|
||||
|
||||
logger = logging.getLogger('houtai')
|
||||
|
||||
COPY_MODE_REPLACE = 'replace'
|
||||
COPY_MODE_APPEND = 'append'
|
||||
|
||||
|
||||
class ShopCopyError(Exception):
|
||||
def __init__(self, message, code=400):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
|
||||
|
||||
def resolve_dianpu_by_user_uid(user_uid):
|
||||
"""通过小程序用户 ID(YonghuPingzheng.yonghu,全局唯一)定位店铺"""
|
||||
uid = str(user_uid or '').strip()
|
||||
if not uid:
|
||||
raise ShopCopyError('用户ID不能为空')
|
||||
try:
|
||||
return Dianpu.objects.select_related('pingzheng').get(pingzheng__yonghu=uid)
|
||||
except Dianpu.DoesNotExist:
|
||||
raise ShopCopyError(f'未找到用户ID {uid} 对应的店铺', 404)
|
||||
|
||||
|
||||
def _gen_dest_key(target_dianpu_id, category, src_url):
|
||||
src_key = _url_to_oss_key(src_url)
|
||||
ext = 'png'
|
||||
if src_key and '.' in src_key:
|
||||
ext = src_key.rsplit('.', 1)[-1].lower() or 'png'
|
||||
ts = int(time.time() * 1000)
|
||||
rand = uuid.uuid4().hex[:8]
|
||||
return f"dianpu/shangpin/{category}/{target_dianpu_id}_{ts}_{rand}.{ext}"
|
||||
|
||||
|
||||
def _copy_image_url(src_url, target_dianpu_id, category):
|
||||
"""复制 OSS 图片到目标店铺独立路径,返回相对 URL"""
|
||||
if not src_url:
|
||||
return None
|
||||
dest_key = _gen_dest_key(target_dianpu_id, category, src_url)
|
||||
if not copy_in_oss(src_url, dest_key):
|
||||
raise ShopCopyError(f'图片复制失败: {src_url}')
|
||||
return dest_key
|
||||
|
||||
|
||||
def _clear_target_catalog_db_only(target_dianpu):
|
||||
"""全部覆盖模式:仅删除目标店铺商品目录 DB 记录,返回待清理的 OSS URL"""
|
||||
dianpu_id = target_dianpu.id
|
||||
oss_to_delete = []
|
||||
|
||||
products = list(Shangpin.query.filter(dianpu_id=dianpu_id))
|
||||
for p in products:
|
||||
if p.tupian_url:
|
||||
oss_to_delete.append(p.tupian_url)
|
||||
if p.guize_tupian:
|
||||
oss_to_delete.append(p.guize_tupian)
|
||||
if products:
|
||||
Shangpin.query.filter(dianpu_id=dianpu_id).delete()
|
||||
|
||||
ShangpinZhuanqu.query.filter(dianpu_id=dianpu_id).delete()
|
||||
|
||||
types = list(ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id))
|
||||
for t in types:
|
||||
if t.tupian_url:
|
||||
oss_to_delete.append(t.tupian_url)
|
||||
if types:
|
||||
ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id).delete()
|
||||
|
||||
return oss_to_delete
|
||||
|
||||
|
||||
def _delete_oss_urls(urls):
|
||||
for url in urls:
|
||||
if url:
|
||||
delete_from_oss(url)
|
||||
|
||||
|
||||
def _pre_copy_source_images(source_dianpu, target_dianpu):
|
||||
"""
|
||||
预复制源店铺所有图片到目标店铺 OSS 路径。
|
||||
返回结构化的预复制数据,供事务内写库使用。
|
||||
"""
|
||||
src_id = source_dianpu.id
|
||||
tgt_id = target_dianpu.id
|
||||
|
||||
source_types = list(
|
||||
ShangpinLeixingDianpu.query.filter(dianpu_id=src_id).order_by('paixu', 'id')
|
||||
)
|
||||
type_payloads = []
|
||||
for st in source_types:
|
||||
type_payloads.append({
|
||||
'source': st,
|
||||
'tupian_url': _copy_image_url(st.tupian_url, tgt_id, 'shangpinleixing'),
|
||||
})
|
||||
|
||||
source_zones = list(
|
||||
ShangpinZhuanqu.query.filter(dianpu_id=src_id).order_by('paixu', 'id')
|
||||
)
|
||||
zone_payloads = []
|
||||
for sz in source_zones:
|
||||
zone_payloads.append({'source': sz})
|
||||
|
||||
source_products = list(
|
||||
Shangpin.query.filter(dianpu_id=src_id).order_by('paixu', 'id')
|
||||
)
|
||||
product_payloads = []
|
||||
for sp in source_products:
|
||||
product_payloads.append({
|
||||
'source': sp,
|
||||
'tupian_url': _copy_image_url(sp.tupian_url, tgt_id, 'shangpin'),
|
||||
'guize_tupian': _copy_image_url(sp.guize_tupian, tgt_id, 'shangpinguize') if sp.guize_tupian else None,
|
||||
})
|
||||
|
||||
return type_payloads, zone_payloads, product_payloads
|
||||
|
||||
|
||||
def _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads):
|
||||
"""在目标店铺插入复制后的类型、专区、商品,维护 ID 映射"""
|
||||
type_id_map = {}
|
||||
zone_id_map = {}
|
||||
|
||||
for item in type_payloads:
|
||||
st = item['source']
|
||||
new_type = ShangpinLeixingDianpu.query.create(
|
||||
dianpu_id=target_dianpu.id,
|
||||
gonggong_leixing_id=st.gonggong_leixing_id,
|
||||
jieshao=st.jieshao,
|
||||
tupian_url=item['tupian_url'],
|
||||
paixu=st.paixu,
|
||||
shangjia_zhuangtai=st.shangjia_zhuangtai,
|
||||
fengjin_zhuangtai=st.fengjin_zhuangtai,
|
||||
shenhe_zhuangtai=st.shenhe_zhuangtai,
|
||||
)
|
||||
type_id_map[st.id] = new_type.id
|
||||
|
||||
for item in zone_payloads:
|
||||
sz = item['source']
|
||||
new_dianpu_leixing_id = type_id_map.get(sz.dianpu_leixing_id)
|
||||
if not new_dianpu_leixing_id:
|
||||
raise ShopCopyError(f'专区「{sz.mingzi}」关联的商品类型在复制映射中缺失')
|
||||
new_zone = ShangpinZhuanqu.query.create(
|
||||
dianpu_id=target_dianpu.id,
|
||||
mingzi=sz.mingzi,
|
||||
dianpu_leixing_id=new_dianpu_leixing_id,
|
||||
leixing_id=sz.leixing_id,
|
||||
paixu=sz.paixu,
|
||||
shangjia_zhuangtai=sz.shangjia_zhuangtai,
|
||||
fengjin_zhuangtai=sz.fengjin_zhuangtai,
|
||||
shenhezhuangtai=sz.shenhezhuangtai,
|
||||
)
|
||||
zone_id_map[sz.id] = new_zone.id
|
||||
|
||||
copied_products = 0
|
||||
for item in product_payloads:
|
||||
sp = item['source']
|
||||
new_dianpu_leixing_id = type_id_map.get(sp.dianpu_leixing_id)
|
||||
new_zhuanqu_id = zone_id_map.get(sp.zhuanqu_id) if sp.zhuanqu_id else None
|
||||
if sp.dianpu_leixing_id and not new_dianpu_leixing_id:
|
||||
raise ShopCopyError(f'商品「{sp.biaoqian}」关联的商品类型在复制映射中缺失')
|
||||
if sp.zhuanqu_id and not new_zhuanqu_id:
|
||||
raise ShopCopyError(f'商品「{sp.biaoqian}」关联的专区在复制映射中缺失')
|
||||
|
||||
Shangpin.query.create(
|
||||
biaoqian=sp.biaoqian,
|
||||
jiage=sp.jiage,
|
||||
kucun=sp.kucun,
|
||||
leixing_id=sp.leixing_id,
|
||||
zhuanqu_id=new_zhuanqu_id,
|
||||
zhenshi_xiaoliang=0,
|
||||
duiwai_xiaoliang=0,
|
||||
jieshao=sp.jieshao,
|
||||
xiadan_xuzhi=sp.xiadan_xuzhi,
|
||||
guize_tupian=item['guize_tupian'],
|
||||
yaoqiuleixing=sp.yaoqiuleixing,
|
||||
huiyuan_id=sp.huiyuan_id,
|
||||
yongjin=sp.yongjin,
|
||||
kaioi_ewai_dashou_fencheng=sp.kaioi_ewai_dashou_fencheng,
|
||||
ewai_dashou_fencheng=sp.ewai_dashou_fencheng,
|
||||
tupian_url=item['tupian_url'],
|
||||
paixu=sp.paixu,
|
||||
shenhezhuangtai=sp.shenhezhuangtai,
|
||||
dianpu_id=target_dianpu.id,
|
||||
dianpu_leixing_id=new_dianpu_leixing_id,
|
||||
shangjia_zhuangtai=sp.shangjia_zhuangtai,
|
||||
fengjin_zhuangtai=sp.fengjin_zhuangtai,
|
||||
shenhe_zhuangtai=sp.shenhe_zhuangtai,
|
||||
)
|
||||
copied_products += 1
|
||||
|
||||
return {
|
||||
'types': len(type_payloads),
|
||||
'zones': len(zone_payloads),
|
||||
'products': copied_products,
|
||||
}
|
||||
|
||||
|
||||
def copy_shop_catalog(source_user_uid, target_user_uid, copy_mode=COPY_MODE_APPEND):
|
||||
"""
|
||||
复制店铺商品目录。
|
||||
:param source_user_uid: 源店铺小程序用户 ID(YonghuPingzheng.yonghu,唯一)
|
||||
:param target_user_uid: 目标店铺小程序用户 ID
|
||||
:param copy_mode: 'replace' 全部覆盖 | 'append' 补充追加
|
||||
"""
|
||||
if copy_mode not in (COPY_MODE_REPLACE, COPY_MODE_APPEND):
|
||||
raise ShopCopyError('copy_mode 无效,仅支持 replace 或 append')
|
||||
|
||||
source_dianpu = resolve_dianpu_by_user_uid(source_user_uid)
|
||||
target_dianpu = resolve_dianpu_by_user_uid(target_user_uid)
|
||||
|
||||
if source_dianpu.id == target_dianpu.id:
|
||||
raise ShopCopyError('源店铺与目标店铺不能相同')
|
||||
|
||||
# 1. 预复制 OSS 图片(DB 变更前完成,失败则目标店不受影响)
|
||||
type_payloads, zone_payloads, product_payloads = _pre_copy_source_images(
|
||||
source_dianpu, target_dianpu
|
||||
)
|
||||
|
||||
oss_cleanup = []
|
||||
# 2. 数据库事务:锁定目标店 → 可选清空 → 写入
|
||||
with transaction.atomic():
|
||||
Dianpu.objects.select_for_update().get(id=target_dianpu.id)
|
||||
|
||||
if copy_mode == COPY_MODE_REPLACE:
|
||||
oss_cleanup = _clear_target_catalog_db_only(target_dianpu)
|
||||
|
||||
stats = _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads)
|
||||
|
||||
if oss_cleanup:
|
||||
_delete_oss_urls(oss_cleanup)
|
||||
|
||||
logger.info(
|
||||
'店铺商品复制完成: source=%s(%s) -> target=%s(%s) mode=%s stats=%s',
|
||||
source_dianpu.id, source_user_uid,
|
||||
target_dianpu.id, target_user_uid,
|
||||
copy_mode, stats,
|
||||
)
|
||||
|
||||
return {
|
||||
'source_dianpu_id': source_dianpu.id,
|
||||
'source_dianpu_name': source_dianpu.dianpu_mingcheng,
|
||||
'target_dianpu_id': target_dianpu.id,
|
||||
'target_dianpu_name': target_dianpu.dianpu_mingcheng,
|
||||
'copy_mode': copy_mode,
|
||||
'copied_types': stats['types'],
|
||||
'copied_zones': stats['zones'],
|
||||
'copied_products': stats['products'],
|
||||
}
|
||||
@@ -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, KefuChatPermissionsView,
|
||||
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='后台获取提现配置'),
|
||||
@@ -82,8 +90,10 @@ urlpatterns = [
|
||||
|
||||
path('hqhtdpsplx', ProductListView.as_view(), name='后台获取店铺商品数据'),
|
||||
path('htxgdpspsj', ProductModifyView.as_view(), name='后台修改店铺商品数据'),
|
||||
path('htfzdpspsj', ShopCopyCatalogView.as_view(), name='后台复制店铺商品目录'),
|
||||
|
||||
path('kfhqltqx', KefuChatPermissionsView.as_view(), name='后台获取客服聊天配置'),
|
||||
path('kfimssclub', KefuImUserClubFilterView.as_view(), name='客服聊天按俱乐部过滤客户ID'),
|
||||
|
||||
|
||||
path('htglyhqcfsltj', FaKuanTongJiView.as_view(),name='后台罚单统计图片'), # 罚款统计
|
||||
|
||||
151
backend/utils.py
151
backend/utils.py
@@ -77,11 +77,47 @@ class _AllPermissions(list):
|
||||
return True
|
||||
|
||||
|
||||
def has_merchant_order_permission(permissions):
|
||||
"""商家/链接订单处理权限:002ac 商家订单、002ab 平台订单、003aa 跨平台。"""
|
||||
def has_perm_code(permissions, *codes):
|
||||
"""权限码 in 检查;系统超管 _AllPermissions 视为全部通过。"""
|
||||
if isinstance(permissions, _AllPermissions):
|
||||
return True
|
||||
return any(p in permissions for p in ('002ac', '002ab', '003aa'))
|
||||
perms = permissions or []
|
||||
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 跨平台;本店超管 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):
|
||||
@@ -135,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:
|
||||
@@ -164,25 +221,44 @@ def verify_kefu_permission(request, username_from_frontend=None):
|
||||
'msg': '身份错误,无权操作其他账号,相关操作已记录'
|
||||
}, status=403)
|
||||
|
||||
# ---------- 4. 获取用户的所有权限 ----------
|
||||
# 使用 gvsdsdk RBAC 表(UserRole → RolePermission → Permission)
|
||||
# ---------- 4. 获取用户的所有权限(与菜单 menu-access 同一套算法) ----------
|
||||
from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_SINGLE
|
||||
from jituan.services.admin_context import resolve_admin_effective_club, get_allowed_club_ids, is_system_super_admin
|
||||
from jituan.services.club_rbac import resolve_effective_permission_codes
|
||||
|
||||
eff_club = getattr(request, 'club_id', None) or CLUB_ID_DEFAULT
|
||||
eff_scope = getattr(request, 'club_scope', None) or DATA_SCOPE_SINGLE
|
||||
allowed_club_ids = None
|
||||
if not is_system_super_admin(current_user):
|
||||
eff_club, eff_scope, club_err = resolve_admin_effective_club(current_user, request)
|
||||
if club_err:
|
||||
return None, Response({'code': 403, 'msg': club_err}, status=403)
|
||||
request.club_id = eff_club
|
||||
request.club_scope = eff_scope
|
||||
allowed_club_ids = list(get_allowed_club_ids(current_user))
|
||||
|
||||
try:
|
||||
user_uuid = current_user.UserUUID
|
||||
role_uuids = list(SdkUserRole.objects.filter(
|
||||
UserUUID=user_uuid
|
||||
).values_list('RoleUUID', flat=True))
|
||||
|
||||
if role_uuids:
|
||||
perm_uuids = list(SdkRolePermission.objects.filter(
|
||||
RoleUUID__in=role_uuids
|
||||
).values_list('PermUUID', flat=True))
|
||||
|
||||
permissions = list(SdkPermission.objects.filter(
|
||||
PermUUID__in=perm_uuids,
|
||||
PermStatus=1
|
||||
).values_list('PermCode', flat=True).distinct())
|
||||
if is_system_super_admin(current_user):
|
||||
user_uuid = current_user.UserUUID
|
||||
role_uuids = list(SdkUserRole.objects.filter(
|
||||
UserUUID=user_uuid
|
||||
).values_list('RoleUUID', flat=True))
|
||||
if role_uuids:
|
||||
perm_uuids = list(SdkRolePermission.objects.filter(
|
||||
RoleUUID__in=role_uuids
|
||||
).values_list('PermUUID', flat=True))
|
||||
permissions = list(SdkPermission.objects.filter(
|
||||
PermUUID__in=perm_uuids, PermStatus=1
|
||||
).values_list('PermCode', flat=True).distinct())
|
||||
else:
|
||||
permissions = []
|
||||
legacy_codes, _ = _legacy_backend_role_permissions(current_user.Phone)
|
||||
if legacy_codes:
|
||||
permissions = list(set(list(permissions) + legacy_codes))
|
||||
else:
|
||||
permissions = []
|
||||
permissions = resolve_effective_permission_codes(
|
||||
current_user, eff_club, eff_scope, allowed_club_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'verify_kefu_permission failed: UserUUID=%s Phone=%s err=%s',
|
||||
@@ -193,21 +269,10 @@ def verify_kefu_permission(request, username_from_frontend=None):
|
||||
)
|
||||
permissions = []
|
||||
|
||||
# 旧权限表(user_role 按手机号),与 gvsdsdk 合并
|
||||
legacy_codes, _ = _legacy_backend_role_permissions(current_user.Phone)
|
||||
if legacy_codes:
|
||||
if isinstance(permissions, _AllPermissions):
|
||||
pass
|
||||
else:
|
||||
permissions = list(set(list(permissions) + legacy_codes))
|
||||
|
||||
# 管理员/超级用户自动拥有所有权限
|
||||
if is_admin and '000001' not in permissions:
|
||||
permissions.insert(0, '000001')
|
||||
|
||||
# 拥有 000001(超级管理权限)的用户自动获得所有功能权限
|
||||
# 使用 _AllPermissions 包装,使任何权限码检查都自动通过
|
||||
if '000001' in permissions:
|
||||
# 仅系统级超管自动拥有全部功能权限;俱乐部 000001 只解锁超管页,不展开全部菜单
|
||||
if is_system_super_admin(current_user):
|
||||
if '000001' not in permissions:
|
||||
permissions.insert(0, '000001')
|
||||
permissions = _AllPermissions(permissions)
|
||||
|
||||
if not permissions:
|
||||
|
||||
@@ -35,19 +35,23 @@ 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,
|
||||
ClubMemberCatalogView, ClubMemberEnableView, ClubMemberDisableView,
|
||||
MemberCardImageUploadView, MemberRechargeRecordsView, MemberBundleCreateView,
|
||||
)
|
||||
from .views.guanli import (
|
||||
GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView,
|
||||
UpdateGuanliView,
|
||||
UpdateGuanliView, GuanshiChangeLeaderQuotaView, GuanshiChangeLeaderLogView,
|
||||
)
|
||||
from .views.withdraw_settings import (
|
||||
GetWithdrawSettingsView, UpdateWithdrawSettingsView,
|
||||
@@ -62,6 +66,7 @@ from .views.system import (
|
||||
from .views.shops import (
|
||||
ShopListView, ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView,
|
||||
ShopProductTypeMappingView, ShopProductModifyView, ProductListView, ProductModifyView,
|
||||
ShopCopyCatalogView,
|
||||
)
|
||||
from .views.penalties import (
|
||||
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanPingTaiShenHeView,
|
||||
@@ -94,17 +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
|
||||
@@ -115,6 +124,7 @@ __all__ = [
|
||||
# shops
|
||||
'ShopListView', 'ShopModifyView', 'ShopPublicTypeAndAuditView', 'ShopListForProductView',
|
||||
'ShopProductTypeMappingView', 'ShopProductModifyView', 'ProductListView', 'ProductModifyView',
|
||||
'ShopCopyCatalogView',
|
||||
# penalties
|
||||
'FaKuanTongJiView', 'FaKuanLieBiaoView', 'FaKuanChuLiView', 'FaKuanPingTaiShenHeView',
|
||||
'FaKuanChuangJianView', 'FineApplyView', 'PunishDashouView',
|
||||
|
||||
@@ -580,12 +580,19 @@ class KhjlglView(APIView):
|
||||
pending=Count('jilu_id', filter=Q(zhuangtai=3)),
|
||||
).order_by('-total')
|
||||
|
||||
_stats_agg = stats_base.aggregate(
|
||||
total=Count('id'),
|
||||
passed=Count('id', filter=Q(zhuangtai=1)),
|
||||
failed=Count('id', filter=Q(zhuangtai=2)),
|
||||
in_progress=Count('id', filter=Q(zhuangtai=0)),
|
||||
pending=Count('id', filter=Q(zhuangtai=3)),
|
||||
)
|
||||
stats = {
|
||||
'total': stats_base.count(),
|
||||
'passed': stats_base.filter(zhuangtai=1).count(),
|
||||
'failed': stats_base.filter(zhuangtai=2).count(),
|
||||
'in_progress': stats_base.filter(zhuangtai=0).count(),
|
||||
'pending': stats_base.filter(zhuangtai=3).count(),
|
||||
'total': _stats_agg['total'],
|
||||
'passed': _stats_agg['passed'],
|
||||
'failed': _stats_agg['failed'],
|
||||
'in_progress': _stats_agg['in_progress'],
|
||||
'pending': _stats_agg['pending'],
|
||||
'total_fee': float(agg['total_fee'] or 0),
|
||||
'tag_stats': [
|
||||
{
|
||||
@@ -702,8 +709,11 @@ class KhjlczView(APIView):
|
||||
for f in KaoheJiluFeiyong.query.filter(jilu_id=jilu_id).order_by('cishu')
|
||||
]
|
||||
reject_list = []
|
||||
for r in KaoheJujueJilu.query.filter(jilu_id=jilu_id).order_by('-CreateTime'):
|
||||
kg_prof = _batch_user_profile_map([r.shenheguan_id]).get(r.shenheguan_id, {})
|
||||
_reject_records = list(KaoheJujueJilu.query.filter(jilu_id=jilu_id).order_by('-CreateTime'))
|
||||
_kg_ids = [r.shenheguan_id for r in _reject_records if r.shenheguan_id]
|
||||
_kg_prof_map = _batch_user_profile_map(_kg_ids) if _kg_ids else {}
|
||||
for r in _reject_records:
|
||||
kg_prof = _kg_prof_map.get(r.shenheguan_id, {})
|
||||
reject_list.append({
|
||||
'cishu': r.cishu,
|
||||
'shenheguan_id': r.shenheguan_id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -12,6 +12,7 @@ import secrets
|
||||
import random
|
||||
import string
|
||||
from calendar import monthrange
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from datetime import date, datetime, timedelta
|
||||
from django.utils import timezone
|
||||
@@ -155,13 +156,18 @@ class CwhybkhqView(APIView):
|
||||
if 'huiyuancaiwu' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403)
|
||||
|
||||
all_bankuai = list(Bankuai.query.all())
|
||||
bankuai_ids = [bk.bankuai_id for bk in all_bankuai]
|
||||
all_members = Huiyuan.query.filter(bankuai_id__in=bankuai_ids).values('huiyuan_id', 'jieshao', 'bankuai_id')
|
||||
members_by_bankuai = defaultdict(list)
|
||||
for m in all_members:
|
||||
members_by_bankuai[m['bankuai_id']].append(dict(m))
|
||||
bankuai_list = []
|
||||
for bk in Bankuai.query.all():
|
||||
members = Huiyuan.query.filter(bankuai=bk).values('huiyuan_id', 'jieshao', 'bankuai_id')
|
||||
for bk in all_bankuai:
|
||||
bankuai_list.append({
|
||||
'bankuai_id': bk.bankuai_id,
|
||||
'mingcheng': bk.mingcheng,
|
||||
'members': list(members)
|
||||
'members': members_by_bankuai.get(bk.bankuai_id, [])
|
||||
})
|
||||
|
||||
return Response({'code': 0, 'msg': 'ok', 'data': bankuai_list})
|
||||
@@ -597,21 +603,30 @@ class CwhqjtddsjView(APIView):
|
||||
summary_qs = filter_queryset_by_club(
|
||||
Order.query.filter(**filters), request, club_field='ClubID',
|
||||
)
|
||||
total_order_count = summary_qs.count()
|
||||
total_order_amount = summary_qs.aggregate(s=Sum('Amount'))['s'] or 0
|
||||
total_success_count = summary_qs.filter(Status=3).count()
|
||||
total_success_amount = summary_qs.filter(Status=3).aggregate(s=Sum('Amount'))['s'] or 0
|
||||
total_refund_count = summary_qs.filter(Status=5).count()
|
||||
total_dashou = summary_qs.filter(Status=3).aggregate(s=Sum('PlayerCommission'))['s'] or 0
|
||||
total_dianpu_fenhong = summary_qs.filter(Status=3, Platform=1).aggregate(
|
||||
s=Sum('pingtai_kuozhan__ShopIncome'))['s'] or 0
|
||||
|
||||
platform_amount = summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('Amount'))['s'] or 0
|
||||
merchant_amount = summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('Amount'))['s'] or 0
|
||||
platform_dashou_sum = \
|
||||
summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('PlayerCommission'))['s'] or 0
|
||||
merchant_dashou_sum = \
|
||||
summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('PlayerCommission'))['s'] or 0
|
||||
agg = summary_qs.aggregate(
|
||||
total_order_count=Count('id'),
|
||||
total_order_amount=Sum('Amount'),
|
||||
total_success_count=Count('id', filter=Q(Status=3)),
|
||||
total_success_amount=Sum('Amount', filter=Q(Status=3)),
|
||||
total_refund_count=Count('id', filter=Q(Status=5)),
|
||||
total_dashou=Sum('PlayerCommission', filter=Q(Status=3)),
|
||||
total_dianpu_fenhong=Sum('pingtai_kuozhan__ShopIncome', filter=Q(Status=3, Platform=1)),
|
||||
platform_amount=Sum('Amount', filter=Q(Status=3, Platform=1)),
|
||||
merchant_amount=Sum('Amount', filter=Q(Status=3, Platform=2)),
|
||||
platform_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=1)),
|
||||
merchant_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=2)),
|
||||
)
|
||||
total_order_count = agg['total_order_count'] or 0
|
||||
total_order_amount = agg['total_order_amount'] or 0
|
||||
total_success_count = agg['total_success_count'] or 0
|
||||
total_success_amount = agg['total_success_amount'] or 0
|
||||
total_refund_count = agg['total_refund_count'] or 0
|
||||
total_dashou = agg['total_dashou'] or 0
|
||||
total_dianpu_fenhong = agg['total_dianpu_fenhong'] or 0
|
||||
platform_amount = agg['platform_amount'] or 0
|
||||
merchant_amount = agg['merchant_amount'] or 0
|
||||
platform_dashou_sum = agg['platform_dashou_sum'] or 0
|
||||
merchant_dashou_sum = agg['merchant_dashou_sum'] or 0
|
||||
|
||||
profit_platform = platform_amount - platform_dashou_sum - total_dianpu_fenhong
|
||||
profit_merchant = merchant_amount - merchant_dashou_sum
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -357,9 +357,16 @@ class GetGuanliDetailView(APIView):
|
||||
custom_first = {} # 首次分红定制(cishu=1)
|
||||
extra_map = {} # 其他次数 { cishu: { huiyuan_id: { guanshi_fenhong, jieshao, jiage } } }
|
||||
|
||||
# 批量预取会员信息
|
||||
_huiyuan_ids = [r.huiyuan for r in duoci_records]
|
||||
_huiyuan_map = {
|
||||
h.huiyuan_id: h
|
||||
for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao', 'jiage')
|
||||
}
|
||||
|
||||
for record in duoci_records:
|
||||
# 获取会员信息
|
||||
member = Huiyuan.query.filter(huiyuan_id=record.huiyuan).first()
|
||||
member = _huiyuan_map.get(record.huiyuan)
|
||||
if not member:
|
||||
continue
|
||||
member_info = {
|
||||
@@ -380,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': {
|
||||
@@ -391,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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -629,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}元'
|
||||
@@ -643,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,
|
||||
@@ -651,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}'
|
||||
),
|
||||
)
|
||||
|
||||
@@ -711,10 +737,16 @@ class UpdateGuanliView(APIView):
|
||||
if custom_first is not None and isinstance(custom_first, dict):
|
||||
from jituan.services.club_penalty import resolve_gsfenhong_club_id
|
||||
guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
|
||||
# 获取当前所有首次定制记录
|
||||
existing_first = DuociFenhong.query.filter(
|
||||
club_id=guanshi_club, yonghuid=yonghuid, cishu=1,
|
||||
# 批量预取当前所有首次定制记录(避免循环内 N+1)
|
||||
existing_first_list = list(
|
||||
DuociFenhong.query.filter(
|
||||
club_id=guanshi_club, yonghuid=yonghuid, cishu=1,
|
||||
)
|
||||
)
|
||||
_existing_map = {r.huiyuan: r for r in existing_first_list}
|
||||
_to_create = []
|
||||
_to_update = []
|
||||
_to_delete_ids = []
|
||||
# 前端传来的定制字典 {会员ID: 金额}
|
||||
for huiyuan_id, amount in custom_first.items():
|
||||
try:
|
||||
@@ -722,24 +754,34 @@ class UpdateGuanliView(APIView):
|
||||
except:
|
||||
continue
|
||||
if amount <= 0:
|
||||
DuociFenhong.query.filter(
|
||||
club_id=guanshi_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1,
|
||||
).delete()
|
||||
rec = _existing_map.get(huiyuan_id)
|
||||
if rec is not None:
|
||||
_to_delete_ids.append(rec.id)
|
||||
else:
|
||||
DuociFenhong.query.update_or_create(
|
||||
club_id=guanshi_club,
|
||||
yonghuid=yonghuid,
|
||||
huiyuan=huiyuan_id,
|
||||
cishu=1,
|
||||
defaults={
|
||||
'guanshi_fenhong': amount,
|
||||
'zuzhang_fenhong': Decimal('0'),
|
||||
},
|
||||
)
|
||||
rec = _existing_map.get(huiyuan_id)
|
||||
if rec is not None:
|
||||
rec.guanshi_fenhong = amount
|
||||
rec.zuzhang_fenhong = Decimal('0')
|
||||
_to_update.append(rec)
|
||||
else:
|
||||
_to_create.append(DuociFenhong(
|
||||
club_id=guanshi_club,
|
||||
yonghuid=yonghuid,
|
||||
huiyuan=huiyuan_id,
|
||||
cishu=1,
|
||||
guanshi_fenhong=amount,
|
||||
zuzhang_fenhong=Decimal('0'),
|
||||
))
|
||||
# 删除那些在前端字典中不存在的首次定制记录(即已取消定制)
|
||||
for record in existing_first:
|
||||
if record.huiyuan not in custom_first:
|
||||
record.delete()
|
||||
for rec in existing_first_list:
|
||||
if rec.huiyuan not in custom_first and rec.id not in _to_delete_ids:
|
||||
_to_delete_ids.append(rec.id)
|
||||
if _to_create:
|
||||
DuociFenhong.objects.bulk_create(_to_create)
|
||||
if _to_update:
|
||||
DuociFenhong.objects.bulk_update(_to_update, ['guanshi_fenhong', 'zuzhang_fenhong'])
|
||||
if _to_delete_ids:
|
||||
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
|
||||
|
||||
# 4.3 额外次数分红(cishu >= 2)
|
||||
if extra_map is not None and isinstance(extra_map, dict):
|
||||
@@ -747,6 +789,8 @@ class UpdateGuanliView(APIView):
|
||||
guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
|
||||
# 收集需要保留的记录
|
||||
keep_set = set()
|
||||
# 解析前端数据,先汇总 keep_set 与目标金额
|
||||
_extra_targets = [] # [(cishu, huiyuan_id, amount)]
|
||||
for times_str, members in extra_map.items():
|
||||
try:
|
||||
cishu = int(times_str)
|
||||
@@ -767,24 +811,42 @@ class UpdateGuanliView(APIView):
|
||||
except:
|
||||
continue
|
||||
keep_set.add((cishu, huiyuan_id))
|
||||
# 更新或创建
|
||||
DuociFenhong.query.update_or_create(
|
||||
_extra_targets.append((cishu, huiyuan_id, amount))
|
||||
# 批量预取已有额外次数记录
|
||||
existing_extra_list = list(
|
||||
DuociFenhong.query.filter(
|
||||
club_id=guanshi_club, yonghuid=yonghuid, cishu__gte=2,
|
||||
)
|
||||
)
|
||||
_extra_map = {(r.cishu, r.huiyuan): r for r in existing_extra_list}
|
||||
_to_create = []
|
||||
_to_update = []
|
||||
_to_delete_ids = []
|
||||
for cishu, huiyuan_id, amount in _extra_targets:
|
||||
rec = _extra_map.get((cishu, huiyuan_id))
|
||||
if rec is not None:
|
||||
rec.guanshi_fenhong = amount
|
||||
rec.zuzhang_fenhong = Decimal('0')
|
||||
_to_update.append(rec)
|
||||
else:
|
||||
_to_create.append(DuociFenhong(
|
||||
club_id=guanshi_club,
|
||||
yonghuid=yonghuid,
|
||||
huiyuan=huiyuan_id,
|
||||
cishu=cishu,
|
||||
defaults={
|
||||
'guanshi_fenhong': amount,
|
||||
'zuzhang_fenhong': Decimal('0')
|
||||
}
|
||||
)
|
||||
guanshi_fenhong=amount,
|
||||
zuzhang_fenhong=Decimal('0'),
|
||||
))
|
||||
# 删除不在 keep_set 中的额外次数记录
|
||||
existing_extra = DuociFenhong.query.filter(
|
||||
club_id=guanshi_club, yonghuid=yonghuid, cishu__gte=2,
|
||||
)
|
||||
for record in existing_extra:
|
||||
if (record.cishu, record.huiyuan) not in keep_set:
|
||||
record.delete()
|
||||
for rec in existing_extra_list:
|
||||
if (rec.cishu, rec.huiyuan) not in keep_set:
|
||||
_to_delete_ids.append(rec.id)
|
||||
if _to_create:
|
||||
DuociFenhong.objects.bulk_create(_to_create)
|
||||
if _to_update:
|
||||
DuociFenhong.objects.bulk_update(_to_update, ['guanshi_fenhong', 'zuzhang_fenhong'])
|
||||
if _to_delete_ids:
|
||||
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
|
||||
|
||||
dividend_parts = []
|
||||
if perm_enabled is not None:
|
||||
@@ -808,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,
|
||||
@@ -342,6 +498,12 @@ class KefuGetDashouDetailView(APIView):
|
||||
member_records = Huiyuangoumai.query.filter(
|
||||
yonghu_id=uid
|
||||
).order_by('-CreateTime')
|
||||
# 批量预取会员信息
|
||||
_huiyuan_ids = [r.huiyuan_id for r in member_records]
|
||||
_huiyuan_map = {
|
||||
h.huiyuan_id: h.jieshao
|
||||
for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao')
|
||||
}
|
||||
member_list = []
|
||||
for record in member_records:
|
||||
# 检查是否过期
|
||||
@@ -351,11 +513,7 @@ class KefuGetDashouDetailView(APIView):
|
||||
is_expired = record.daoqi_time < timezone.now() if record.daoqi_time else True
|
||||
|
||||
if not is_expired:
|
||||
try:
|
||||
huiyuan = Huiyuan.query.get(huiyuan_id=record.huiyuan_id)
|
||||
huiyuan_name = huiyuan.jieshao
|
||||
except Huiyuan.DoesNotExist:
|
||||
huiyuan_name = '未知会员'
|
||||
huiyuan_name = _huiyuan_map.get(record.huiyuan_id, '未知会员')
|
||||
member_list.append({
|
||||
'id': record.id,
|
||||
'huiyuan_name': huiyuan_name,
|
||||
@@ -367,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, # 返回当前客服的权限列表
|
||||
}
|
||||
@@ -457,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,
|
||||
@@ -473,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)}})
|
||||
@@ -504,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,
|
||||
@@ -520,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)}})
|
||||
@@ -575,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:
|
||||
@@ -671,11 +856,10 @@ class KefuUpdateDashouView(APIView):
|
||||
return Response({'code': 403, 'msg': '无权限给打手减会员'})
|
||||
if not huiyuan_id:
|
||||
return Response({'code': 400, 'msg': '缺少会员ID'})
|
||||
try:
|
||||
record = Huiyuangoumai.query.get(yonghu_id=dashou_id, huiyuan_id=huiyuan_id)
|
||||
except Huiyuangoumai.DoesNotExist:
|
||||
return Response({'code': 404, 'msg': '该打手未购买此会员'})
|
||||
record.delete()
|
||||
from jituan.services.member_recharge import revoke_member_entitlement_admin
|
||||
removed, err = revoke_member_entitlement_admin(dashou_id, huiyuan_id)
|
||||
if err:
|
||||
return Response({'code': 404, 'msg': err})
|
||||
write_xiugai_log(
|
||||
yonghuid=dashou_id,
|
||||
xiugaiid=kefu.user.Phone,
|
||||
@@ -828,8 +1012,12 @@ class KefuGetShangjiaListView(APIView):
|
||||
return Response({'code': 400, 'msg': '商家状态格式错误'})
|
||||
|
||||
# 5. 统计总商家数(应用筛选后)
|
||||
total = shangjia_qs.count()
|
||||
valid_merchant_count = shangjia_qs.filter(zhuangtai=1).count()
|
||||
_agg = shangjia_qs.aggregate(
|
||||
total=Count('id'),
|
||||
valid=Count('id', filter=Q(zhuangtai=1)),
|
||||
)
|
||||
total = _agg['total']
|
||||
valid_merchant_count = _agg['valid']
|
||||
|
||||
# 6. 分页
|
||||
offset = (page - 1) * page_size
|
||||
@@ -929,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 '',
|
||||
}
|
||||
@@ -962,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参数无效'})
|
||||
|
||||
# 权限校验
|
||||
@@ -971,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)'})
|
||||
@@ -985,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')
|
||||
@@ -1002,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,
|
||||
@@ -1010,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)}})
|
||||
@@ -1097,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):
|
||||
@@ -1127,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 = ''
|
||||
@@ -1145,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': '移除成功'})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ class UpdateMemberView(APIView):
|
||||
'trial_zuzhangfc': request.data.get('trial_zuzhangfc'),
|
||||
'card_image': request.data.get('card_image'),
|
||||
'card_bg': request.data.get('card_bg'),
|
||||
'included_huiyuan_ids': request.data.get('included_huiyuan_ids'),
|
||||
}
|
||||
data, err = update_member_for_club(
|
||||
request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=extra,
|
||||
@@ -270,7 +271,10 @@ class AddMemberView(APIView):
|
||||
'msg': '仅集团汇总视图可新建全局会员类型;俱乐部请从会员 catalog 上架',
|
||||
})
|
||||
|
||||
data, err = add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao)
|
||||
data, err = add_global_member(
|
||||
jieshao, jiage, guanshifc, zuzhangfc, jtjieshao,
|
||||
is_bundle=bool(request.data.get('is_bundle')),
|
||||
)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 0, 'msg': '添加成功', 'data': data})
|
||||
@@ -349,5 +353,105 @@ class ClubMemberDisableView(APIView):
|
||||
return Response({'code': 0, 'msg': '已下架', 'data': data})
|
||||
|
||||
|
||||
class MemberCardImageUploadView(APIView):
|
||||
"""POST /jituan/houtai/hy-card-upload 上传会员充值卡片图"""
|
||||
permission_classes = []
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
username = (request.data.get('username') or request.data.get('phone') or '').strip()
|
||||
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
|
||||
field = (request.data.get('field') or 'card_image').strip()
|
||||
file_obj = request.FILES.get('file')
|
||||
if not username or not huiyuan_id or not file_obj:
|
||||
return Response({'code': 400, 'msg': '参数不完整'})
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '3300a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限'})
|
||||
from jituan.services.club_member_admin import upload_member_card_image
|
||||
data, err = upload_member_card_image(request, huiyuan_id, field, file_obj)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 0, 'msg': '上传成功', 'data': data})
|
||||
|
||||
|
||||
class MemberRechargeRecordsView(APIView):
|
||||
"""POST /jituan/houtai/hy-recharge-records — 会员充值记录明细 + 分红"""
|
||||
permission_classes = []
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
username = (request.data.get('username') or request.data.get('phone') or '').strip()
|
||||
if not username:
|
||||
return Response({'code': 401, 'msg': '缺少username'})
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '3300a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限查看会员充值记录'})
|
||||
|
||||
from jituan.services.member_recharge_records import build_member_recharge_records_payload
|
||||
|
||||
filters = request.data.get('filters') or {}
|
||||
for key in (
|
||||
'dingdan_id', 'yonghuid', 'buyer_keyword', 'huiyuan_id', 'is_trial',
|
||||
'zhuangtai', 'date_from', 'date_to', 'jine_min', 'jine_max',
|
||||
'guanshi_id', 'zuzhang_id', 'keyword', 'is_bundle', 'has_fenhong', 'club_id',
|
||||
):
|
||||
if request.data.get(key) not in (None, ''):
|
||||
filters[key] = request.data.get(key)
|
||||
|
||||
payload = build_member_recharge_records_payload(
|
||||
request,
|
||||
filters=filters,
|
||||
page=request.data.get('page', 1),
|
||||
page_size=request.data.get('page_size', 20),
|
||||
)
|
||||
return Response({'code': 0, 'data': payload})
|
||||
|
||||
|
||||
class MemberBundleCreateView(APIView):
|
||||
"""POST /jituan/houtai/hy-bundle-create — 俱乐部一键创建总会员并上架"""
|
||||
permission_classes = []
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
username = (request.data.get('username') or request.data.get('phone') or '').strip()
|
||||
if not username:
|
||||
return Response({'code': 401, 'msg': '缺少username'})
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '3300a' not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限'})
|
||||
|
||||
jieshao = (request.data.get('jieshao') or '').strip()
|
||||
if not jieshao:
|
||||
return Response({'code': 400, 'msg': '会员名称不能为空'})
|
||||
|
||||
try:
|
||||
jiage = Decimal(str(request.data.get('jiage')))
|
||||
guanshifc = Decimal(str(request.data.get('guanshifc')))
|
||||
zuzhangfc = Decimal(str(request.data.get('zuzhangfc')))
|
||||
except Exception:
|
||||
return Response({'code': 400, 'msg': '金额格式错误'})
|
||||
|
||||
from jituan.services.club_member_admin import create_bundle_member_for_club
|
||||
|
||||
data, err = create_bundle_member_for_club(
|
||||
request,
|
||||
jieshao=jieshao,
|
||||
jiage=jiage,
|
||||
guanshifc=guanshifc,
|
||||
zuzhangfc=zuzhangfc,
|
||||
jtjieshao=(request.data.get('jtjieshao') or '').strip(),
|
||||
formal_days=int(request.data.get('formal_days') or 30),
|
||||
included_huiyuan_ids=request.data.get('included_huiyuan_ids') or [],
|
||||
)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 0, 'msg': '总会员创建并上架成功', 'data': data})
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -518,13 +518,15 @@ class FaKuanChuangJianView(APIView):
|
||||
user=request.user,
|
||||
),
|
||||
)
|
||||
for relative_url in uploaded_urls:
|
||||
PenaltyAppealImage.query.create(
|
||||
PenaltyAppealImage.objects.bulk_create([
|
||||
PenaltyAppealImage(
|
||||
Penalty=penalty,
|
||||
PenalizedUserID=PenalizedUserID,
|
||||
ImageURL=relative_url,
|
||||
Purpose=1
|
||||
Purpose=1,
|
||||
)
|
||||
for relative_url in uploaded_urls
|
||||
])
|
||||
from users.fadan_fenhong_utils import lock_penalty_bonus
|
||||
lock_penalty_bonus(penalty)
|
||||
|
||||
@@ -581,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)
|
||||
@@ -608,34 +615,57 @@ 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(
|
||||
order=order,
|
||||
request=request,
|
||||
user=request.user,
|
||||
applicant_id=getattr(request.user, 'UserUID', None),
|
||||
),
|
||||
)
|
||||
for relative_url in uploaded_urls:
|
||||
PenaltyAppealImage.query.create(
|
||||
Penalty=penalty,
|
||||
PenalizedUserID=dashou_id,
|
||||
ImageURL=relative_url,
|
||||
Purpose=1
|
||||
)
|
||||
from orders.services.team_recruit import maybe_split_penalties
|
||||
from users.fadan_fenhong_utils import lock_penalty_bonus
|
||||
lock_penalty_bonus(penalty)
|
||||
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(
|
||||
Penalty=penalty,
|
||||
PenalizedUserID=dashou_id,
|
||||
ImageURL=relative_url,
|
||||
Purpose=1
|
||||
)
|
||||
lock_penalty_bonus(penalty)
|
||||
|
||||
logger.info(f"客服罚款申请成功: 订单 {OrderID}, 打手 {dashou_id}, 金额 {FineAmount}")
|
||||
return Response({'code': 0, 'msg': '罚款已生成', 'data': {'id': penalty.id}})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -141,59 +141,110 @@ 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 升序)
|
||||
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
||||
type_list = []
|
||||
for t in type_qs:
|
||||
# 🔥 修改:统计该类型下的公共商品数量(店铺为空且店铺商品类型为空)
|
||||
product_count = Shangpin.query.filter(
|
||||
leixing_id=t.id,
|
||||
dianpu__isnull=True, # 所属店铺为空
|
||||
dianpu_leixing__isnull=True # 店铺商品类型为空
|
||||
).count()
|
||||
type_list.append({
|
||||
'id': t.id,
|
||||
'jieshao': t.jieshao or '',
|
||||
'tupian_url': t.tupian_url or '',
|
||||
'paixu': t.paixu,
|
||||
'shenhezhuangtai': t.shenhezhuangtai,
|
||||
'product_count': product_count,
|
||||
})
|
||||
# 获取所有商品类型(按 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
|
||||
|
||||
# 获取所有专区(按 paixu 升序)
|
||||
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu')
|
||||
zone_list = []
|
||||
for z in zone_qs:
|
||||
# 🔥 修改:统计该专区下的公共商品数量(店铺为空且店铺商品类型为空)
|
||||
product_count = Shangpin.query.filter(
|
||||
zhuanqu_id=z.id,
|
||||
dianpu__isnull=True, # 所属店铺为空
|
||||
dianpu_leixing__isnull=True # 店铺商品类型为空
|
||||
).count()
|
||||
zone_list.append({
|
||||
'id': z.id,
|
||||
'mingzi': z.mingzi or '',
|
||||
'leixing_id': z.leixing_id,
|
||||
'paixu': z.paixu,
|
||||
'shenhezhuangtai': z.shenhezhuangtai,
|
||||
'product_count': product_count,
|
||||
})
|
||||
|
||||
# 获取所有会员(只需 ID、介绍、价格)
|
||||
member_list = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'))
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'type_list': type_list,
|
||||
'zone_list': zone_list,
|
||||
'member_list': member_list,
|
||||
try:
|
||||
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
||||
# 批量聚合商品数量,避免循环内 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 _public_sp.values('leixing_id').annotate(cnt=Count('id'))
|
||||
}
|
||||
})
|
||||
type_list = []
|
||||
for t in type_qs:
|
||||
product_count = _type_counts.get(t.id, 0)
|
||||
type_list.append({
|
||||
'id': t.id,
|
||||
'jieshao': t.jieshao or '',
|
||||
'tupian_url': t.tupian_url or '',
|
||||
'paixu': t.paixu,
|
||||
'shenhezhuangtai': t.shenhezhuangtai,
|
||||
'product_count': product_count,
|
||||
})
|
||||
|
||||
# 获取专区(按俱乐部隔离;集团汇总看全部)
|
||||
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 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 = []
|
||||
for z in zone_qs:
|
||||
product_count = _zone_counts.get(z.id, 0)
|
||||
zone_list.append({
|
||||
'id': z.id,
|
||||
'mingzi': z.mingzi or '',
|
||||
'leixing_id': z.leixing_id,
|
||||
'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 = []
|
||||
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,
|
||||
'data': {
|
||||
'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': '获取商品基础数据失败,请稍后重试'})
|
||||
|
||||
|
||||
|
||||
@@ -232,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')
|
||||
@@ -254,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:
|
||||
@@ -320,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({
|
||||
@@ -362,16 +414,17 @@ 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')
|
||||
# 批量聚合商品数量,避免循环内 N+1 count
|
||||
_type_counts = {
|
||||
item['leixing_id']: item['cnt']
|
||||
for item in Shangpin.query.filter(leixing_id__isnull=False).values('leixing_id').annotate(cnt=Count('id'))
|
||||
}
|
||||
type_list = []
|
||||
for t in type_qs:
|
||||
# 统计该类型下的商品数量(忽略审核状态?通常统计所有商品,可根据需求调整)
|
||||
product_count = Shangpin.query.filter(leixing_id=t.id).count()
|
||||
product_count = _type_counts.get(t.id, 0)
|
||||
type_list.append({
|
||||
'id': t.id,
|
||||
'jieshao': t.jieshao or '',
|
||||
@@ -383,9 +436,13 @@ class GetProductListView(APIView):
|
||||
|
||||
# 获取所有专区(按 paixu 升序)
|
||||
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu')
|
||||
_zone_counts = {
|
||||
item['zhuanqu_id']: item['cnt']
|
||||
for item in Shangpin.query.filter(zhuanqu_id__isnull=False).values('zhuanqu_id').annotate(cnt=Count('id'))
|
||||
}
|
||||
zone_list = []
|
||||
for z in zone_qs:
|
||||
product_count = Shangpin.query.filter(zhuanqu_id=z.id).count()
|
||||
product_count = _zone_counts.get(z.id, 0)
|
||||
zone_list.append({
|
||||
'id': z.id,
|
||||
'mingzi': z.mingzi or '',
|
||||
@@ -393,6 +450,7 @@ class GetProductListView(APIView):
|
||||
'paixu': z.paixu,
|
||||
'shenhezhuangtai': z.shenhezhuangtai,
|
||||
'product_count': product_count,
|
||||
'tupian_url': getattr(z, 'tupian_url', '') or '',
|
||||
})
|
||||
|
||||
# 获取所有会员(只需 ID、介绍、价格)
|
||||
@@ -443,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')
|
||||
@@ -567,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'):
|
||||
@@ -632,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()
|
||||
@@ -647,7 +696,10 @@ 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_PlayerCommission_str = request.data.get('ewai_PlayerCommission', '0')
|
||||
# 兼容列表页 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()
|
||||
# 图片文件
|
||||
@@ -696,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': '商品专区不存在'})
|
||||
|
||||
@@ -763,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,
|
||||
@@ -782,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)
|
||||
@@ -832,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:
|
||||
@@ -897,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:
|
||||
@@ -935,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,
|
||||
@@ -981,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():
|
||||
@@ -1059,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()
|
||||
@@ -1135,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': '服务器错误'})
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -144,8 +144,8 @@ class GetRolePermissionView(APIView):
|
||||
if kefu is None:
|
||||
return permissions # 直接返回错误响应
|
||||
|
||||
# 2. 检查是否有 000001 权限
|
||||
if '000001' not in permissions:
|
||||
from jituan.services.admin_context import can_access_admin_user_management
|
||||
if not can_access_admin_user_management(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||
|
||||
# 3. 获取所有权限
|
||||
@@ -157,19 +157,29 @@ class GetRolePermissionView(APIView):
|
||||
kefu_user_uuids = list(
|
||||
User.objects.filter(KefuProfile__isnull=False).values_list('UserUUID', flat=True)
|
||||
)
|
||||
roles = filter_roles_for_request(request)
|
||||
roles = list(filter_roles_for_request(request))
|
||||
# 批量预取 RolePermission + Permission(避免循环内 N+1)
|
||||
_role_uuids = [r.RoleUUID for r in roles]
|
||||
_rp_pairs = list(
|
||||
RolePermission.objects.filter(RoleUUID__in=_role_uuids)
|
||||
.values_list('RoleUUID', 'PermUUID')
|
||||
) if _role_uuids else []
|
||||
_perm_uuids = list({p for _, p in _rp_pairs})
|
||||
_perm_rows_by_uuid = {
|
||||
p['PermUUID']: p
|
||||
for p in Permission.objects.filter(PermUUID__in=_perm_uuids)
|
||||
.values('PermUUID', 'PermCode', 'PermName', 'PermDesc')
|
||||
} if _perm_uuids else {}
|
||||
_perms_by_role = {}
|
||||
for role_uuid, perm_uuid in _rp_pairs:
|
||||
perm_row = _perm_rows_by_uuid.get(perm_uuid)
|
||||
if perm_row is not None:
|
||||
_perms_by_role.setdefault(role_uuid, []).append(_serialize_permission_row(perm_row))
|
||||
|
||||
roles_data = []
|
||||
for role in roles:
|
||||
user_count = count_role_users_in_scope(role, request, kefu_user_uuids)
|
||||
role_perm_uuids = RolePermission.objects.filter(
|
||||
RoleUUID=role.RoleUUID
|
||||
).values_list('PermUUID', flat=True)
|
||||
perms_of_role = [
|
||||
_serialize_permission_row(p)
|
||||
for p in Permission.objects.filter(
|
||||
PermUUID__in=list(role_perm_uuids)
|
||||
).values('PermCode', 'PermName', 'PermDesc')
|
||||
]
|
||||
perms_of_role = _perms_by_role.get(role.RoleUUID, [])
|
||||
roles_data.append({
|
||||
'role_code': role.RoleName,
|
||||
'role_name': role.RoleName,
|
||||
@@ -208,15 +218,23 @@ class ModifyRolePermissionView(APIView):
|
||||
if '000001' not in permissions:
|
||||
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
||||
|
||||
# 获取角色(需要角色名称)
|
||||
# 获取角色(按当前俱乐部解析,避免多俱乐部同名角色冲突)
|
||||
role_code = request.data.get('role_code')
|
||||
if not role_code:
|
||||
return Response({'code': 400, 'msg': '缺少角色编码'})
|
||||
|
||||
try:
|
||||
role = Role.objects.get(RoleName=role_code)
|
||||
except Role.DoesNotExist:
|
||||
return Response({'code': 404, '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': '角色不存在或与当前俱乐部不匹配'})
|
||||
|
||||
# 超级管理员角色的特殊限制
|
||||
if role.RoleName == '管理员':
|
||||
@@ -275,8 +293,11 @@ class ModifyRolePermissionView(APIView):
|
||||
if role.RoleName == '管理员':
|
||||
role_name = role.RoleName
|
||||
elif role_name and role_name != role.RoleName:
|
||||
if Role.objects.filter(RoleName=role_name).exclude(RoleUUID=role.RoleUUID).exists():
|
||||
return Response({'code': 400, 'msg': '角色名称已存在'})
|
||||
role_club_id = getattr(role, 'ClubID', '') or ''
|
||||
if Role.objects.filter(
|
||||
RoleName=role_name, ClubID=role_club_id,
|
||||
).exclude(RoleUUID=role.RoleUUID).exists():
|
||||
return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'})
|
||||
with transaction.atomic():
|
||||
if role.RoleName != '管理员':
|
||||
if role_name:
|
||||
@@ -339,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': '该俱乐部下角色名称已存在'})
|
||||
|
||||
@@ -395,7 +422,8 @@ class GetAdminUserListView(APIView):
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '000001' not in permissions:
|
||||
from jituan.services.admin_context import can_access_admin_user_management
|
||||
if not can_access_admin_user_management(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||
|
||||
phone = request.data.get('phone', '').strip()
|
||||
@@ -410,8 +438,10 @@ class GetAdminUserListView(APIView):
|
||||
KefuProfile__isnull=False
|
||||
).select_related('KefuProfile')
|
||||
|
||||
from jituan.services.club_rbac import filter_kefu_users_qs
|
||||
users = filter_kefu_users_qs(users, request)
|
||||
from jituan.models import AdminAssignment, Club
|
||||
from jituan.constants import ADMIN_ROLE_LABELS
|
||||
from jituan.services.club_rbac import filter_kefu_users_by_admin_scope
|
||||
users = filter_kefu_users_by_admin_scope(users, request)
|
||||
|
||||
if phone:
|
||||
users = users.filter(Phone__icontains=phone)
|
||||
@@ -420,7 +450,9 @@ class GetAdminUserListView(APIView):
|
||||
|
||||
# 关键修正:先求值为列表,再传入 phone__in
|
||||
if role_code:
|
||||
target_role = Role.objects.filter(RoleName=role_code).first()
|
||||
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
||||
filter_club_id = resolve_role_club_id(request)
|
||||
target_role = resolve_role_by_name(role_code, filter_club_id)
|
||||
if target_role:
|
||||
user_uuids = list(
|
||||
UserRole.objects.filter(RoleUUID=target_role.RoleUUID)
|
||||
@@ -434,24 +466,85 @@ class GetAdminUserListView(APIView):
|
||||
paginator = Paginator(users, page_size)
|
||||
page_obj = paginator.get_page(page)
|
||||
|
||||
data_list = []
|
||||
for user in page_obj:
|
||||
# 通过 gvsdsdk UserRole 获取用户角色
|
||||
user_role_uuids = list(
|
||||
UserRole.objects.filter(UserUUID=user.UserUUID)
|
||||
.values_list('RoleUUID', flat=True)
|
||||
)
|
||||
user_roles = Role.objects.filter(RoleUUID__in=user_role_uuids)
|
||||
roles_data = [
|
||||
{'role_code': r.RoleName, 'role_name': r.RoleName}
|
||||
for r in user_roles
|
||||
# 批量预取本页所有用户的 UserRole + Role(避免循环内 N+1)
|
||||
_page_users = list(page_obj)
|
||||
_page_user_uuids = [u.UserUUID for u in _page_users]
|
||||
_ur_pairs = list(
|
||||
UserRole.objects.filter(UserUUID__in=_page_user_uuids)
|
||||
.values_list('UserUUID', 'RoleUUID')
|
||||
) if _page_user_uuids else []
|
||||
_role_uuids_in_page = list({r for _, r in _ur_pairs})
|
||||
_role_info_by_uuid = {
|
||||
r.RoleUUID: {'role_name': r.RoleName, 'club_id': getattr(r, 'ClubID', '') or ''}
|
||||
for r in Role.objects.filter(RoleUUID__in=_role_uuids_in_page)
|
||||
} if _role_uuids_in_page else {}
|
||||
_roles_by_user = {}
|
||||
for user_uuid, role_uuid in _ur_pairs:
|
||||
info = _role_info_by_uuid.get(role_uuid)
|
||||
if info is not None:
|
||||
_roles_by_user.setdefault(user_uuid, []).append({
|
||||
'role_code': info['role_name'],
|
||||
'role_name': info['role_name'],
|
||||
'club_id': info['club_id'],
|
||||
})
|
||||
|
||||
_page_yonghuids = [u.UserUID for u in _page_users]
|
||||
_club_name_map = {c.club_id: c.name for c in Club.query.filter(status=1)}
|
||||
_assignments_by_user = {}
|
||||
if _page_yonghuids:
|
||||
for a in AdminAssignment.query.filter(yonghuid__in=_page_yonghuids, status=1):
|
||||
if a.club_id:
|
||||
club_label = _club_name_map.get(a.club_id, a.club_id)
|
||||
else:
|
||||
club_label = '集团(全部子公司)'
|
||||
_assignments_by_user.setdefault(a.yonghuid, []).append({
|
||||
'id': a.id,
|
||||
'club_id': a.club_id,
|
||||
'club_label': club_label,
|
||||
'role_code': a.role_code,
|
||||
'role_name': ADMIN_ROLE_LABELS.get(a.role_code, a.role_code),
|
||||
'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_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_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 '',
|
||||
@@ -459,13 +552,30 @@ class GetAdminUserListView(APIView):
|
||||
'UpdateTime': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '',
|
||||
})
|
||||
|
||||
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': {
|
||||
'list': data_list,
|
||||
'total': paginator.count,
|
||||
'page': page,
|
||||
'pageSize': page_size
|
||||
'pageSize': page_size,
|
||||
'can_manage_club_assignments': can_manage_admin_assignments(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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -487,11 +597,27 @@ class GetAdminRolesView(APIView):
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '000001' not in permissions:
|
||||
from jituan.services.admin_context import can_access_admin_user_management
|
||||
if not can_access_admin_user_management(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||
|
||||
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
|
||||
roles = filter_roles_for_request(request).values('RoleName', 'ClubID')
|
||||
|
||||
scope = resolve_club_scope(request)
|
||||
all_clubs = request.data.get('all_clubs') in (True, 1, '1', 'true', 'yes')
|
||||
# 仅集团汇总视图 + 集团任职管理权 才可拉全量子公司角色;
|
||||
# 顶栏在具体俱乐部时(含集团账号切到某店),绝不容许其他俱乐部角色泄露。
|
||||
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:
|
||||
roles = filter_roles_for_request(request).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
@@ -523,7 +649,8 @@ class GetAdminRolesView(APIView):
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '000001' not in permissions:
|
||||
from jituan.services.admin_context import can_access_admin_user_management
|
||||
if not can_access_admin_user_management(request.user, permissions):
|
||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||
|
||||
# 筛选条件
|
||||
@@ -593,7 +720,24 @@ class ModifyAdminUserView(APIView):
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if '000001' not in permissions:
|
||||
|
||||
from jituan.services.admin_context import (
|
||||
can_manage_admin_assignments,
|
||||
can_manage_roles_for_club,
|
||||
can_access_admin_user_management,
|
||||
)
|
||||
|
||||
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': '无权分配俱乐部任职(需集团权限)'})
|
||||
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:
|
||||
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
||||
|
||||
target_yonghuid = request.data.get('yonghuid', '').strip()
|
||||
@@ -620,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位'})
|
||||
@@ -638,14 +792,27 @@ 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
|
||||
club_id = getattr(target_user, 'ClubID', '') or ''
|
||||
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:
|
||||
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:
|
||||
role = resolve_role_by_name(rc, club_id)
|
||||
role = resolve_role_by_name(rc, bind_club_id)
|
||||
if not role:
|
||||
missing.append(rc)
|
||||
continue
|
||||
role_club = (getattr(role, 'ClubID', None) or '').strip()
|
||||
if role_club and role_club != bind_club_id:
|
||||
missing.append(rc)
|
||||
continue
|
||||
_, created = UserRole.objects.get_or_create(
|
||||
UserUUID=target_user.UserUUID,
|
||||
RoleUUID=role.RoleUUID,
|
||||
@@ -674,14 +841,59 @@ 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
|
||||
club_id = getattr(target_user, 'ClubID', '') or ''
|
||||
role = resolve_role_by_name(role_code, club_id)
|
||||
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:
|
||||
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:
|
||||
return Response({'code': 404, 'msg': '角色不存在或与用户俱乐部不匹配'})
|
||||
return Response({'code': 404, 'msg': '角色不存在或与所选俱乐部不匹配'})
|
||||
UserRole.objects.filter(UserUUID=target_user.UserUUID, RoleUUID=role.RoleUUID).delete()
|
||||
return Response({'code': 0, 'msg': '角色移除成功'})
|
||||
|
||||
elif action == 'add_club_assignment':
|
||||
club_id = (request.data.get('club_id') or '').strip()
|
||||
if not club_id:
|
||||
return Response({'code': 400, 'msg': '请选择俱乐部'})
|
||||
from jituan.services.admin_assignments import create_or_reactivate_assignment
|
||||
from jituan.constants import DATA_SCOPE_SINGLE
|
||||
role_code = (request.data.get('role_code') or 'CLUB_ADMIN').strip()
|
||||
is_primary = bool(request.data.get('is_primary', False))
|
||||
_, err = create_or_reactivate_assignment(
|
||||
yonghuid=target_user.UserUID,
|
||||
club_id=club_id,
|
||||
role_code=role_code,
|
||||
data_scope=DATA_SCOPE_SINGLE,
|
||||
is_primary=is_primary,
|
||||
granted_by=request.user.UserUID,
|
||||
)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 0, 'msg': '俱乐部任职已添加'})
|
||||
|
||||
elif action == 'remove_club_assignment':
|
||||
assignment_id = request.data.get('assignment_id')
|
||||
if not assignment_id:
|
||||
return Response({'code': 400, 'msg': '缺少任职 id'})
|
||||
from jituan.services.admin_assignments import deactivate_assignment
|
||||
from jituan.models import AdminAssignment
|
||||
row = AdminAssignment.query.filter(
|
||||
id=assignment_id, yonghuid=target_user.UserUID, status=1,
|
||||
).first()
|
||||
if not row:
|
||||
return Response({'code': 404, 'msg': '任职记录不存在'})
|
||||
ok, err = deactivate_assignment(assignment_id)
|
||||
if not ok:
|
||||
return Response({'code': 404, 'msg': err or '停用失败'})
|
||||
return Response({'code': 0, 'msg': '俱乐部任职已移除'})
|
||||
|
||||
else:
|
||||
return Response({'code': 400, 'msg': '无效的action'})
|
||||
|
||||
@@ -701,8 +913,24 @@ class AddAdminUserView(APIView):
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
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_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': '您无权在该俱乐部添加用户'})
|
||||
|
||||
phone = request.data.get('phone', '').strip()
|
||||
password = request.data.get('password', '').strip()
|
||||
@@ -715,11 +943,8 @@ class AddAdminUserView(APIView):
|
||||
if len(password) < 6 or len(erjimima) < 6:
|
||||
return Response({'code': 400, 'msg': '密码和二级密码至少6位'})
|
||||
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
from jituan.services.club_rbac import resolve_roles_for_binding
|
||||
|
||||
target_club_id = resolve_club_id_from_request(request)
|
||||
|
||||
# 校验手机号是否已被后台客服占用(同俱乐部优先)
|
||||
existing_kefu = User.query.filter(
|
||||
Phone=phone, KefuProfile__isnull=False
|
||||
@@ -729,11 +954,13 @@ class AddAdminUserView(APIView):
|
||||
|
||||
if not isinstance(role_codes, list):
|
||||
return Response({'code': 400, 'msg': 'role_codes 格式错误'})
|
||||
if not role_codes:
|
||||
return Response({'code': 400, 'msg': '请至少选择一个角色'})
|
||||
|
||||
# 分配角色(默认客服 + 所选角色,去重;任一角色找不到则整单拒绝)
|
||||
# 仅绑定前端所选角色(各俱乐部角色名不同,勿硬编码「客服」)
|
||||
role_names_to_bind = []
|
||||
seen_names = set()
|
||||
for name in (['客服'] if '客服' not in role_codes else []) + list(role_codes):
|
||||
for name in role_codes:
|
||||
n = (name or '').strip()
|
||||
if n and n not in seen_names:
|
||||
seen_names.add(n)
|
||||
@@ -795,6 +1022,17 @@ class AddAdminUserView(APIView):
|
||||
},
|
||||
)
|
||||
|
||||
from jituan.services.admin_assignments import create_or_reactivate_assignment
|
||||
from jituan.constants import DATA_SCOPE_SINGLE
|
||||
create_or_reactivate_assignment(
|
||||
yonghuid=yonghuid,
|
||||
club_id=target_club_id,
|
||||
role_code='CLUB_ADMIN',
|
||||
data_scope=DATA_SCOPE_SINGLE,
|
||||
is_primary=True,
|
||||
granted_by=request.user.UserUID,
|
||||
)
|
||||
|
||||
return Response({'code': 0, 'msg': '添加成功'})
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -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 = []
|
||||
@@ -173,19 +173,27 @@ class HqbkxxView(APIView):
|
||||
price_map[row.huiyuan_id] = row
|
||||
|
||||
huiyuan_list = []
|
||||
for hy in Huiyuan.query.all():
|
||||
row = price_map.get(hy.huiyuan_id)
|
||||
# 在受限 scope 下预过滤,避免 Huiyuan 全表遍历
|
||||
# 注:当 scope != DATA_SCOPE_ALL 且 club_id != CLUB_ID_DEFAULT 时,
|
||||
# 只有 price_map 中存在记录的会员才可能被保留(无记录的会被跳过)
|
||||
huiyuan_qs = Huiyuan.query.all()
|
||||
if scope != DATA_SCOPE_ALL and club_id != CLUB_ID_DEFAULT:
|
||||
huiyuan_qs = huiyuan_qs.filter(huiyuan_id__in=list(price_map.keys()))
|
||||
# 仅查询所需字段,减少内存占用(dict 比 model 实例更轻)
|
||||
huiyuan_qs = huiyuan_qs.values('huiyuan_id', 'jieshao', 'bankuai_id', 'jiage', 'guanshifc', 'zuzhangfc')
|
||||
for hy in huiyuan_qs:
|
||||
row = price_map.get(hy['huiyuan_id'])
|
||||
if scope != DATA_SCOPE_ALL and club_id != CLUB_ID_DEFAULT and row is None:
|
||||
continue
|
||||
if row is not None and not row.is_enabled:
|
||||
continue
|
||||
item = {
|
||||
'huiyuan_id': hy.huiyuan_id,
|
||||
'jieshao': hy.jieshao,
|
||||
'bankuai_id': hy.bankuai_id,
|
||||
'jiage': str(row.jiage) if row else str(hy.jiage),
|
||||
'guanshifc': str(row.guanshifc) if row else str(hy.guanshifc),
|
||||
'zuzhangfc': str(row.zuzhangfc) if row else str(hy.zuzhangfc),
|
||||
'huiyuan_id': hy['huiyuan_id'],
|
||||
'jieshao': hy['jieshao'],
|
||||
'bankuai_id': hy['bankuai_id'],
|
||||
'jiage': str(row.jiage) if row else str(hy['jiage']),
|
||||
'guanshifc': str(row.guanshifc) if row else str(hy['guanshifc']),
|
||||
'zuzhangfc': str(row.zuzhangfc) if row else str(hy['zuzhangfc']),
|
||||
}
|
||||
huiyuan_list.append(item)
|
||||
|
||||
@@ -233,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')
|
||||
@@ -271,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)
|
||||
|
||||
@@ -50,6 +50,7 @@ from products.utils import update_shangpin_daily_stat
|
||||
from orders.notice_tasks import dingdan_guangbo
|
||||
from ..utils import (
|
||||
verify_kefu_permission,
|
||||
has_perm_code,
|
||||
PERMISSION_TO_SHENFEN,
|
||||
SHENFEN_PROFILE_MAP,
|
||||
has_fadan_view_permission,
|
||||
@@ -113,6 +114,7 @@ from ..serializers import PopupPageSerializer
|
||||
# 全局常量、日志对象
|
||||
logger = logging.getLogger('houtai')
|
||||
SHOP_PRODUCT_PERMS = ['1199ab', '1199abc', '1199abd']
|
||||
SHOP_MANAGE_PERMS = ('999a', '999b', '999c', '999d', '999e')
|
||||
|
||||
class ShopListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -125,8 +127,7 @@ class ShopListView(APIView):
|
||||
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
|
||||
|
||||
# 2. 检查是否拥有所需权限(999a~999e 任意一个)
|
||||
required_perms = {'999a', '999b', '999c', '999d', '999e'}
|
||||
if not required_perms.intersection(set(permissions)):
|
||||
if not has_perm_code(permissions, *SHOP_MANAGE_PERMS):
|
||||
return Response({'code': 403, 'msg': '您没有权限访问店铺管理功能'})
|
||||
|
||||
# 3. 解析请求参数
|
||||
@@ -141,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)
|
||||
@@ -188,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,不存在则创建)
|
||||
@@ -220,21 +224,19 @@ class ShopModifyView(APIView):
|
||||
if kefu is None:
|
||||
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
|
||||
|
||||
# 转换权限为集合方便判断
|
||||
user_perms = set(permissions)
|
||||
action = request.data.get('action', '')
|
||||
|
||||
# 2. 根据 action 分派任务
|
||||
if action == 'update_dianpu':
|
||||
return self._update_dianpu(request, user_perms)
|
||||
return self._update_dianpu(request, permissions)
|
||||
elif action == 'add_dianpu':
|
||||
return self._add_dianpu(request, user_perms)
|
||||
return self._add_dianpu(request, permissions)
|
||||
elif action == 'update_default':
|
||||
return self._update_default(request, user_perms)
|
||||
return self._update_default(request, permissions)
|
||||
else:
|
||||
return Response({'code': 400, 'msg': '未知操作类型'})
|
||||
|
||||
def _update_dianpu(self, request, user_perms):
|
||||
def _update_dianpu(self, request, permissions):
|
||||
"""修改店铺信息,精确权限控制"""
|
||||
dianpu_id = request.data.get('dianpu_id')
|
||||
if not dianpu_id:
|
||||
@@ -246,7 +248,7 @@ class ShopModifyView(APIView):
|
||||
|
||||
# 基础信息修改 (999e)
|
||||
if any(k in request.data for k in ['dianpu_mingcheng', 'lianxi_dianhua', 'weixinhao']):
|
||||
if '999e' not in user_perms:
|
||||
if not has_perm_code(permissions, '999e'):
|
||||
return Response({'code': 403, 'msg': '无权限修改店铺基本信息(999e)'})
|
||||
if 'dianpu_mingcheng' in request.data:
|
||||
dianpu.dianpu_mingcheng = request.data['dianpu_mingcheng']
|
||||
@@ -257,7 +259,7 @@ class ShopModifyView(APIView):
|
||||
|
||||
# 店铺状态 (999a)
|
||||
if 'zhuangtai' in request.data:
|
||||
if '999a' not in user_perms:
|
||||
if not has_perm_code(permissions, '999a'):
|
||||
return Response({'code': 403, 'msg': '无权限修改店铺状态(999a)'})
|
||||
new_status = int(request.data['zhuangtai'])
|
||||
if new_status not in (0, 1):
|
||||
@@ -266,13 +268,13 @@ class ShopModifyView(APIView):
|
||||
|
||||
# 可提现余额 (999b)
|
||||
if 'ketixian_yue' in request.data:
|
||||
if '999b' not in user_perms:
|
||||
if not has_perm_code(permissions, '999b'):
|
||||
return Response({'code': 403, 'msg': '无权限修改可提现余额(999b)'})
|
||||
dianpu.ketixian_yue = request.data['ketixian_yue']
|
||||
|
||||
# 提现限额相关 (999c)
|
||||
if 'kaiqi_meiri_xiane' in request.data or 'meiri_tixian_xiane' in request.data:
|
||||
if '999c' not in user_perms:
|
||||
if not has_perm_code(permissions, '999c'):
|
||||
return Response({'code': 403, 'msg': '无权限修改提现限额配置(999c)'})
|
||||
if 'kaiqi_meiri_xiane' in request.data:
|
||||
dianpu.kaiqi_meiri_xiane = request.data['kaiqi_meiri_xiane']
|
||||
@@ -281,7 +283,7 @@ class ShopModifyView(APIView):
|
||||
|
||||
# 抽成相关 (999d)
|
||||
if 'kaiqi_shouyi_choucheng' in request.data or 'shouyi_choucheng_feilv' in request.data:
|
||||
if '999d' not in user_perms:
|
||||
if not has_perm_code(permissions, '999d'):
|
||||
return Response({'code': 403, 'msg': '无权限修改抽成配置(999d)'})
|
||||
if 'kaiqi_shouyi_choucheng' in request.data:
|
||||
dianpu.kaiqi_shouyi_choucheng = request.data['kaiqi_shouyi_choucheng']
|
||||
@@ -297,9 +299,9 @@ class ShopModifyView(APIView):
|
||||
logger.exception("修改店铺失败")
|
||||
return Response({'code': 500, 'msg': '修改失败,请稍后重试'})
|
||||
|
||||
def _add_dianpu(self, request, user_perms):
|
||||
def _add_dianpu(self, request, permissions):
|
||||
"""添加店铺,需要权限 999b"""
|
||||
if '999b' not in user_perms:
|
||||
if not has_perm_code(permissions, '999b'):
|
||||
return Response({'code': 403, 'msg': '无权限添加店铺(999b)'})
|
||||
|
||||
yonghu_id = request.data.get('yonghu_id')
|
||||
@@ -328,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,
|
||||
@@ -337,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:
|
||||
@@ -346,16 +350,16 @@ class ShopModifyView(APIView):
|
||||
logger.exception("创建店铺失败")
|
||||
return Response({'code': 500, 'msg': '创建失败,请稍后重试'})
|
||||
|
||||
def _update_default(self, request, user_perms):
|
||||
def _update_default(self, request, permissions):
|
||||
"""修改默认配置,按修改内容校验权限"""
|
||||
feilv = request.data.get('moren_choucheng_feilv')
|
||||
xiane = request.data.get('moren_tixian_xiane')
|
||||
modify_commission = feilv is not None
|
||||
modify_withdraw = xiane is not None
|
||||
|
||||
if modify_commission and '999d' not in user_perms:
|
||||
if modify_commission and not has_perm_code(permissions, '999d'):
|
||||
return Response({'code': 403, 'msg': '无权限修改默认抽成费率(999d)'})
|
||||
if modify_withdraw and '999c' not in user_perms:
|
||||
if modify_withdraw and not has_perm_code(permissions, '999c'):
|
||||
return Response({'code': 403, 'msg': '无权限修改默认可提现限额(999c)'})
|
||||
|
||||
try:
|
||||
@@ -394,7 +398,7 @@ class ShopPublicTypeAndAuditView(APIView):
|
||||
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
|
||||
|
||||
# 拥有 1199ab/1199abc/1199abd 任一个即可访问
|
||||
if not set(permissions).intersection(SHOP_PRODUCT_PERMS):
|
||||
if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS):
|
||||
return Response({'code': 403, 'msg': '无权访问店铺商品管理功能'})
|
||||
|
||||
# ---------- 查询公共商品类型(仅正常审核状态的) ----------
|
||||
@@ -417,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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -444,7 +471,7 @@ class ShopListForProductView(APIView):
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return Response({'code': 403, 'msg': '身份验证失败'})
|
||||
if not set(permissions).intersection(SHOP_PRODUCT_PERMS):
|
||||
if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS):
|
||||
return Response({'code': 403, 'msg': '无权访问店铺列表'})
|
||||
|
||||
# ---------- 安全解析分页参数 ----------
|
||||
@@ -459,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:
|
||||
@@ -520,7 +549,7 @@ class ShopProductTypeMappingView(APIView):
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return Response({'code': 403, 'msg': '身份验证失败'})
|
||||
if not set(permissions).intersection(SHOP_PRODUCT_PERMS):
|
||||
if not has_perm_code(permissions, *SHOP_PRODUCT_PERMS):
|
||||
return Response({'code': 403, 'msg': '无权访问商品类型映射'})
|
||||
|
||||
# ---------- 安全解析参数 ----------
|
||||
@@ -611,11 +640,13 @@ class ShopProductModifyView(APIView):
|
||||
if kefu is None:
|
||||
return Response({'code': 403, 'msg': '身份验证失败'})
|
||||
|
||||
user_perms = set(permissions)
|
||||
user_perms = permissions
|
||||
action = request.data.get('action')
|
||||
|
||||
if action == 'update_audit_mode':
|
||||
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:
|
||||
@@ -623,22 +654,61 @@ class ShopProductModifyView(APIView):
|
||||
|
||||
@transaction.atomic # ✅ 事务修复点
|
||||
def _update_audit_mode(self, request, user_perms):
|
||||
if '1199ab' not in user_perms:
|
||||
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')
|
||||
@@ -657,25 +727,25 @@ class ShopProductModifyView(APIView):
|
||||
# 权限判断
|
||||
if fengjin is not None:
|
||||
if fengjin:
|
||||
if '1199abc' not in user_perms:
|
||||
if not has_perm_code(user_perms, '1199abc'):
|
||||
return Response({'code': 403, 'msg': '无权限封禁'})
|
||||
else:
|
||||
if '1199abd' not in user_perms:
|
||||
if not has_perm_code(user_perms, '1199abd'):
|
||||
return Response({'code': 403, 'msg': '无权限解封'})
|
||||
mapping.fengjin_zhuangtai = bool(fengjin)
|
||||
|
||||
if shangjia is not None:
|
||||
if not shangjia:
|
||||
if '1199abc' not in user_perms:
|
||||
if not has_perm_code(user_perms, '1199abc'):
|
||||
return Response({'code': 403, 'msg': '无权限下架'})
|
||||
else:
|
||||
if '1199abd' not in user_perms:
|
||||
if not has_perm_code(user_perms, '1199abd'):
|
||||
return Response({'code': 403, 'msg': '无权限上架'})
|
||||
mapping.shangjia_zhuangtai = bool(shangjia)
|
||||
|
||||
if shenhe is not None:
|
||||
if shenhe:
|
||||
if '1199abd' not in user_perms:
|
||||
if not has_perm_code(user_perms, '1199abd'):
|
||||
return Response({'code': 403, 'msg': '无权限审核通过'})
|
||||
mapping.shenhe_zhuangtai = bool(shenhe)
|
||||
|
||||
@@ -706,7 +776,7 @@ class ProductListView(APIView):
|
||||
if kefu is None:
|
||||
return Response({'code': 403, 'msg': '身份验证失败'})
|
||||
|
||||
if not set(permissions).intersection(PRODUCT_PERMS):
|
||||
if not has_perm_code(permissions, *PRODUCT_PERMS):
|
||||
return Response({'code': 403, 'msg': '无商品管理权限'})
|
||||
|
||||
# ----- 安全解析分页与筛选参数 -----
|
||||
@@ -844,16 +914,15 @@ class ProductModifyView(APIView):
|
||||
if kefu is None:
|
||||
return Response({'code': 403, 'msg': '身份验证失败'})
|
||||
|
||||
user_perms = set(permissions)
|
||||
action = request.data.get('action', '')
|
||||
|
||||
if action != 'update_product':
|
||||
return Response({'code': 400, 'msg': '未知操作'})
|
||||
|
||||
return self._update_product(request, user_perms)
|
||||
return self._update_product(request, permissions)
|
||||
|
||||
@transaction.atomic
|
||||
def _update_product(self, request, user_perms):
|
||||
def _update_product(self, request, permissions):
|
||||
"""执行商品修改,严格校验权限"""
|
||||
product_id = request.data.get('product_id')
|
||||
if not product_id:
|
||||
@@ -877,18 +946,18 @@ class ProductModifyView(APIView):
|
||||
# ---------- 权限分类检查 ----------
|
||||
# 1. 封禁/下架操作 (消极操作) -> 1199abc
|
||||
if any(status_changes.get(k) is not None and status_changes[k] == False for k in ['shangjia', 'shenhe']):
|
||||
if '1199abc' not in user_perms:
|
||||
if not has_perm_code(permissions, '1199abc'):
|
||||
return Response({'code': 403, 'msg': '无权限执行下架/封禁/驳回审核操作(1199abc)'})
|
||||
if status_changes.get('fengjin') is not None and status_changes['fengjin'] == True:
|
||||
if '1199abc' not in user_perms:
|
||||
if not has_perm_code(permissions, '1199abc'):
|
||||
return Response({'code': 403, 'msg': '无权限执行封禁操作(1199abc)'})
|
||||
|
||||
# 2. 解封/上架/过审 (积极操作) -> 1199abd
|
||||
if any(status_changes.get(k) is not None and status_changes[k] == True for k in ['shangjia', 'shenhe']):
|
||||
if '1199abd' not in user_perms:
|
||||
if not has_perm_code(permissions, '1199abd'):
|
||||
return Response({'code': 403, 'msg': '无权限执行上架/解封/审核通过操作(1199abd)'})
|
||||
if status_changes.get('fengjin') is not None and status_changes['fengjin'] == False:
|
||||
if '1199abd' not in user_perms:
|
||||
if not has_perm_code(permissions, '1199abd'):
|
||||
return Response({'code': 403, 'msg': '无权限执行解封操作(1199abd)'})
|
||||
|
||||
# 3. 修改其他数据(非状态字段)-> 1199abe
|
||||
@@ -896,7 +965,7 @@ class ProductModifyView(APIView):
|
||||
other_fields = [k for k in request.data.keys() if k not in ['action', 'product_id', 'username',
|
||||
'shangjia_zhuangtai', 'fengjin_zhuangtai',
|
||||
'shenhe_zhuangtai']]
|
||||
if other_fields and '1199abe' not in user_perms:
|
||||
if other_fields and not has_perm_code(permissions, '1199abe'):
|
||||
return Response({'code': 403, 'msg': '无权限修改商品基本信息(1199abe)'})
|
||||
|
||||
# ---------- 应用修改 ----------
|
||||
@@ -931,3 +1000,45 @@ class ProductModifyView(APIView):
|
||||
product.save(update_fields=update_fields)
|
||||
return Response({'code': 0, 'msg': '商品修改成功'})
|
||||
|
||||
|
||||
class ShopCopyCatalogView(APIView):
|
||||
"""
|
||||
复制店铺商品目录(类型 + 专区 + 商品,含 OSS 图片独立拷贝)。
|
||||
请求路径: /houtai/htfzdpspsj
|
||||
权限: 店铺管理权限 999a~999e 任一
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
username = request.data.get('username')
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
|
||||
|
||||
if not has_perm_code(permissions, *SHOP_MANAGE_PERMS):
|
||||
return Response({'code': 403, 'msg': '您没有权限执行店铺商品复制操作'})
|
||||
|
||||
source_user_uid = str(request.data.get('source_user_uid', '')).strip()
|
||||
target_user_uid = str(request.data.get('target_user_uid', '')).strip()
|
||||
copy_mode = str(request.data.get('copy_mode', 'append')).strip().lower()
|
||||
|
||||
if not source_user_uid or not target_user_uid:
|
||||
return Response({'code': 400, 'msg': '请填写源店铺与目标店铺的用户ID'})
|
||||
|
||||
from backend.services.shop_copy_service import copy_shop_catalog, ShopCopyError
|
||||
|
||||
try:
|
||||
result = copy_shop_catalog(source_user_uid, target_user_uid, copy_mode)
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': (
|
||||
f'复制成功:商品类型 {result["copied_types"]} 个,'
|
||||
f'专区 {result["copied_zones"]} 个,商品 {result["copied_products"]} 个'
|
||||
),
|
||||
'data': result,
|
||||
})
|
||||
except ShopCopyError as e:
|
||||
return Response({'code': e.code, 'msg': e.message})
|
||||
except Exception:
|
||||
logger.exception('复制店铺商品失败')
|
||||
return Response({'code': 500, 'msg': '复制失败,请稍后重试'})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -236,60 +236,85 @@ 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})
|
||||
|
||||
# 查询所有商品类型(含专区数量)
|
||||
types = ShangpinLeixing.query.all().order_by('-paixu') # paixu 越大越靠前
|
||||
type_list = []
|
||||
for t in types:
|
||||
# 统计该类型下的专区数量
|
||||
zone_count = ShangpinZhuanqu.query.filter(leixing_id=t.id).count()
|
||||
type_list.append({
|
||||
'id': t.id,
|
||||
'jieshao': t.jieshao or '',
|
||||
'tupian_url': t.tupian_url or '',
|
||||
'yaoqiuleixing': t.yaoqiuleixing,
|
||||
'huiyuan_id': t.huiyuan_id or '',
|
||||
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
|
||||
'shenhezhuangtai': t.shenhezhuangtai,
|
||||
'paixu': t.paixu,
|
||||
'product_count': zone_count,
|
||||
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
|
||||
|
||||
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 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 '',
|
||||
'tupian_url': t.tupian_url or '',
|
||||
'yaoqiuleixing': t.yaoqiuleixing,
|
||||
'huiyuan_id': t.huiyuan_id or '',
|
||||
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
|
||||
'shenhezhuangtai': t.shenhezhuangtai,
|
||||
'paixu': t.paixu,
|
||||
'product_count': zone_count_map.get(t.id, 0),
|
||||
})
|
||||
|
||||
zones = filter_club_char_field(
|
||||
ShangpinZhuanqu.query.all().order_by('-paixu'), request, field='club_id'
|
||||
)
|
||||
zone_list = [
|
||||
{
|
||||
'id': z.id,
|
||||
'mingzi': z.mingzi or '',
|
||||
'leixing_id': z.leixing_id,
|
||||
'shenhezhuangtai': z.shenhezhuangtai,
|
||||
'paixu': z.paixu,
|
||||
'club_id': getattr(z, 'club_id', '') or '',
|
||||
}
|
||||
for z in zones
|
||||
]
|
||||
|
||||
member_list = [
|
||||
{
|
||||
'huiyuan_id': m['huiyuan_id'],
|
||||
'jieshao': m['jieshao'] or '',
|
||||
'jiage': float(m['jiage'] or 0),
|
||||
}
|
||||
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
|
||||
]
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'type_list': type_list,
|
||||
'zone_list': zone_list,
|
||||
'member_list': member_list,
|
||||
'club_scoped': club_scoped,
|
||||
'club_id': club_id if club_scoped else '',
|
||||
}
|
||||
})
|
||||
|
||||
# 查询所有专区
|
||||
zones = ShangpinZhuanqu.query.all().order_by('-paixu')
|
||||
zone_list = [
|
||||
{
|
||||
'id': z.id,
|
||||
'mingzi': z.mingzi or '',
|
||||
'leixing_id': z.leixing_id,
|
||||
'shenhezhuangtai': z.shenhezhuangtai,
|
||||
'paixu': z.paixu,
|
||||
}
|
||||
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']),
|
||||
}
|
||||
for m in members
|
||||
]
|
||||
|
||||
return Response({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'type_list': type_list,
|
||||
'zone_list': zone_list,
|
||||
'member_list': member_list,
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception('GetProductTypeZoneView 失败')
|
||||
return Response({'code': 500, 'msg': '获取类型专区失败,请稍后重试'})
|
||||
|
||||
|
||||
|
||||
@@ -316,11 +341,12 @@ class ModifyProductTypeZoneView(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})
|
||||
|
||||
# 类型仍须集团汇总;专区允许在具体俱乐部操作
|
||||
if obj_type == 'leixing':
|
||||
scope_err = block_non_group_catalog_scope(request)
|
||||
if scope_err:
|
||||
return Response({'code': 400, 'msg': scope_err})
|
||||
return self.handle_leixing(request, action)
|
||||
elif obj_type == 'zhuanqu':
|
||||
return self.handle_zhuanqu(request, action)
|
||||
@@ -531,10 +557,11 @@ class ModifyProductTypeZoneView(APIView):
|
||||
# 删除该类型下的所有专区
|
||||
zones = ShangpinZhuanqu.query.filter(leixing_id=type_id)
|
||||
if delete_products:
|
||||
# 删除专区下的所有商品
|
||||
for zone in zones:
|
||||
Shangpin.query.filter(zhuanqu_id=zone.id).delete()
|
||||
zones.delete()
|
||||
# 收集 zone_ids 后批量删除,避免循环内 N+1 delete
|
||||
zone_ids = list(zones.values_list('id', flat=True))
|
||||
if zone_ids:
|
||||
Shangpin.query.filter(zhuanqu_id__in=zone_ids).delete()
|
||||
ShangpinZhuanqu.query.filter(id__in=zone_ids).delete()
|
||||
# 删除直接关联该类型的商品
|
||||
Shangpin.query.filter(leixing_id=type_id).delete()
|
||||
else:
|
||||
@@ -560,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')
|
||||
@@ -582,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):
|
||||
@@ -604,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:
|
||||
@@ -631,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': '未做任何修改'})
|
||||
|
||||
@@ -639,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):
|
||||
@@ -655,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()
|
||||
@@ -662,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': '删除成功'})
|
||||
|
||||
|
||||
@@ -928,7 +1038,8 @@ class PopupNoticeModifyAPIView(APIView):
|
||||
if cascade:
|
||||
# 级联删除:使用事务保证原子性
|
||||
with transaction.atomic():
|
||||
popups = page.popups.all()
|
||||
# 预加载 images 关联,避免循环内 N+1 访问 related manager
|
||||
popups = page.popups.prefetch_related('images')
|
||||
for popup in popups:
|
||||
# 删除弹窗下的所有图片文件
|
||||
for img in popup.images.all():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -19,7 +19,7 @@ from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import models, transaction, IntegrityError
|
||||
from django.db.models import Q, Count, Sum, F, Exists, OuterRef
|
||||
from django.db.models import Q, Count, Sum, F, Exists, OuterRef, Prefetch
|
||||
from gvsdsdk.fluent import db, func, FQ
|
||||
from django.db.models.functions import TruncDate, TruncMonth, TruncYear
|
||||
from django.contrib.auth.hashers import make_password
|
||||
@@ -132,13 +132,16 @@ class KhpzhqView(APIView):
|
||||
|
||||
bankuai_list = list(Bankuai.query.values('bankuai_id', 'mingcheng'))
|
||||
|
||||
chenghao_qs = Chenghao.query.prefetch_related('kaohecishufeiyong_set').all()
|
||||
chenghao_qs = Chenghao.query.prefetch_related(
|
||||
Prefetch('kaohecishufeiyong_set', queryset=KaoheCishuFeiyong.objects.order_by('cishu'))
|
||||
).all()
|
||||
chenghao_list = []
|
||||
for ch in chenghao_qs:
|
||||
# 获取费用:按次数排序,转为 {cishu: feiyong} 字典
|
||||
# prefetch_related 已用 Prefetch 排序,直接遍历不会绕过缓存
|
||||
feiyong_dict = {
|
||||
item.cishu: float(item.feiyong)
|
||||
for item in ch.kaohecishufeiyong_set.order_by('cishu')
|
||||
for item in ch.kaohecishufeiyong_set.all()
|
||||
}
|
||||
max_cishu = max(feiyong_dict.keys()) if feiyong_dict else 0
|
||||
|
||||
@@ -172,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):
|
||||
"""
|
||||
称号(标签)增删改查统一接口
|
||||
@@ -194,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)
|
||||
|
||||
@@ -235,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:
|
||||
@@ -243,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)
|
||||
|
||||
@@ -262,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,则全量替换费用
|
||||
@@ -281,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:
|
||||
@@ -292,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)
|
||||
@@ -305,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)
|
||||
@@ -322,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)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -351,9 +351,16 @@ class GetZuzhangDetailView(APIView):
|
||||
custom_first = {} # 首次分红定制(cishu=1)
|
||||
extra_map = {} # 其他次数 { cishu: { huiyuan_id: { zuzhang_fenhong, jieshao, jiage } } }
|
||||
|
||||
# 批量预取会员信息
|
||||
_huiyuan_ids = [r.huiyuan for r in duoci_records]
|
||||
_huiyuan_map = {
|
||||
h.huiyuan_id: h
|
||||
for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao', 'jiage')
|
||||
}
|
||||
|
||||
for record in duoci_records:
|
||||
# 获取会员信息
|
||||
member = Huiyuan.query.filter(huiyuan_id=record.huiyuan).first()
|
||||
member = _huiyuan_map.get(record.huiyuan)
|
||||
if not member:
|
||||
continue
|
||||
member_info = {
|
||||
@@ -498,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}元'
|
||||
@@ -512,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,
|
||||
@@ -520,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}'
|
||||
),
|
||||
)
|
||||
|
||||
@@ -580,9 +591,16 @@ class UpdateZuzhangView(APIView):
|
||||
if custom_first is not None and isinstance(custom_first, dict):
|
||||
from jituan.services.club_penalty import resolve_gsfenhong_club_id
|
||||
zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
|
||||
existing_first = DuociFenhong.query.filter(
|
||||
club_id=zuzhang_club, yonghuid=yonghuid, cishu=1,
|
||||
# 批量预取当前所有首次定制记录(避免循环内 N+1)
|
||||
existing_first_list = list(
|
||||
DuociFenhong.query.filter(
|
||||
club_id=zuzhang_club, yonghuid=yonghuid, cishu=1,
|
||||
)
|
||||
)
|
||||
_existing_map = {r.huiyuan: r for r in existing_first_list}
|
||||
_to_create = []
|
||||
_to_update = []
|
||||
_to_delete_ids = []
|
||||
for huiyuan_id, amount in custom_first.items():
|
||||
try:
|
||||
amount = Decimal(str(amount))
|
||||
@@ -590,30 +608,41 @@ class UpdateZuzhangView(APIView):
|
||||
continue
|
||||
if amount <= 0:
|
||||
# 删除定制(恢复默认)
|
||||
DuociFenhong.query.filter(
|
||||
club_id=zuzhang_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1,
|
||||
).delete()
|
||||
rec = _existing_map.get(huiyuan_id)
|
||||
if rec is not None:
|
||||
_to_delete_ids.append(rec.id)
|
||||
else:
|
||||
DuociFenhong.query.update_or_create(
|
||||
club_id=zuzhang_club,
|
||||
yonghuid=yonghuid,
|
||||
huiyuan=huiyuan_id,
|
||||
cishu=1,
|
||||
defaults={
|
||||
'zuzhang_fenhong': amount,
|
||||
'guanshi_fenhong': Decimal('0') # 组长表只关心组长分红
|
||||
}
|
||||
)
|
||||
rec = _existing_map.get(huiyuan_id)
|
||||
if rec is not None:
|
||||
rec.zuzhang_fenhong = amount
|
||||
rec.guanshi_fenhong = Decimal('0') # 组长表只关心组长分红
|
||||
_to_update.append(rec)
|
||||
else:
|
||||
_to_create.append(DuociFenhong(
|
||||
club_id=zuzhang_club,
|
||||
yonghuid=yonghuid,
|
||||
huiyuan=huiyuan_id,
|
||||
cishu=1,
|
||||
zuzhang_fenhong=amount,
|
||||
guanshi_fenhong=Decimal('0'),
|
||||
))
|
||||
# 删除那些在前端字典中不存在的首次定制记录(即已取消定制)
|
||||
for record in existing_first:
|
||||
if record.huiyuan not in custom_first:
|
||||
record.delete()
|
||||
for rec in existing_first_list:
|
||||
if rec.huiyuan not in custom_first and rec.id not in _to_delete_ids:
|
||||
_to_delete_ids.append(rec.id)
|
||||
if _to_create:
|
||||
DuociFenhong.objects.bulk_create(_to_create)
|
||||
if _to_update:
|
||||
DuociFenhong.objects.bulk_update(_to_update, ['zuzhang_fenhong', 'guanshi_fenhong'])
|
||||
if _to_delete_ids:
|
||||
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
|
||||
|
||||
# 4.3 额外次数分红(cishu >= 2)
|
||||
if extra_map is not None and isinstance(extra_map, dict):
|
||||
from jituan.services.club_penalty import resolve_gsfenhong_club_id
|
||||
zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid)
|
||||
keep_set = set()
|
||||
_extra_targets = [] # [(cishu, huiyuan_id, amount)]
|
||||
for times_str, members in extra_map.items():
|
||||
try:
|
||||
cishu = int(times_str)
|
||||
@@ -634,23 +663,42 @@ class UpdateZuzhangView(APIView):
|
||||
except:
|
||||
continue
|
||||
keep_set.add((cishu, huiyuan_id))
|
||||
DuociFenhong.query.update_or_create(
|
||||
_extra_targets.append((cishu, huiyuan_id, amount))
|
||||
# 批量预取已有额外次数记录
|
||||
existing_extra_list = list(
|
||||
DuociFenhong.query.filter(
|
||||
club_id=zuzhang_club, yonghuid=yonghuid, cishu__gte=2,
|
||||
)
|
||||
)
|
||||
_extra_map = {(r.cishu, r.huiyuan): r for r in existing_extra_list}
|
||||
_to_create = []
|
||||
_to_update = []
|
||||
_to_delete_ids = []
|
||||
for cishu, huiyuan_id, amount in _extra_targets:
|
||||
rec = _extra_map.get((cishu, huiyuan_id))
|
||||
if rec is not None:
|
||||
rec.zuzhang_fenhong = amount
|
||||
rec.guanshi_fenhong = Decimal('0')
|
||||
_to_update.append(rec)
|
||||
else:
|
||||
_to_create.append(DuociFenhong(
|
||||
club_id=zuzhang_club,
|
||||
yonghuid=yonghuid,
|
||||
huiyuan=huiyuan_id,
|
||||
cishu=cishu,
|
||||
defaults={
|
||||
'zuzhang_fenhong': amount,
|
||||
'guanshi_fenhong': Decimal('0')
|
||||
}
|
||||
)
|
||||
zuzhang_fenhong=amount,
|
||||
guanshi_fenhong=Decimal('0'),
|
||||
))
|
||||
# 删除不在 keep_set 中的额外次数记录
|
||||
existing_extra = DuociFenhong.query.filter(
|
||||
club_id=zuzhang_club, yonghuid=yonghuid, cishu__gte=2,
|
||||
)
|
||||
for record in existing_extra:
|
||||
if (record.cishu, record.huiyuan) not in keep_set:
|
||||
record.delete()
|
||||
for rec in existing_extra_list:
|
||||
if (rec.cishu, rec.huiyuan) not in keep_set:
|
||||
_to_delete_ids.append(rec.id)
|
||||
if _to_create:
|
||||
DuociFenhong.objects.bulk_create(_to_create)
|
||||
if _to_update:
|
||||
DuociFenhong.objects.bulk_update(_to_update, ['zuzhang_fenhong', 'guanshi_fenhong'])
|
||||
if _to_delete_ids:
|
||||
DuociFenhong.objects.filter(id__in=_to_delete_ids).delete()
|
||||
|
||||
dividend_parts = []
|
||||
if perm_enabled is not None:
|
||||
|
||||
@@ -175,7 +175,7 @@ class ZxkfghdsView(APIView):
|
||||
shangjia_ext = old_order.shangjia_kuozhan
|
||||
shangjia_id = shangjia_ext.MerchantID
|
||||
# 获取商家昵称用于新订单扩展表
|
||||
shangjia_user = User.query.get(UserUID=shangjia_id)
|
||||
shangjia_user = User.query.select_related('ShopProfile').get(UserUID=shangjia_id)
|
||||
shangjia_nicheng = shangjia_user.ShopProfile.nicheng or f"商家{shangjia_id}"
|
||||
except (ObjectDoesNotExist, UserShangjia.DoesNotExist):
|
||||
logger.error(f"商家信息缺失,订单: {OrderID}")
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""检查生产库中 UserUID 为空的用户"""
|
||||
import pymysql
|
||||
|
||||
conn = pymysql.connect(
|
||||
host='gvsds.com', port=50030,
|
||||
user='root', password='sajksh.sdfGH3YUge.wjkd+',
|
||||
database='xaio_cheng_xu', charset='utf8mb4'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# UserUID 为 NULL 的用户数
|
||||
cur.execute("SELECT COUNT(*) FROM user WHERE UserUID IS NULL OR UserUID = ''")
|
||||
print(f'UserUID为空的用户数: {cur.fetchone()[0]}')
|
||||
|
||||
# 总用户数
|
||||
cur.execute("SELECT COUNT(*) FROM user")
|
||||
print(f'总用户数: {cur.fetchone()[0]}')
|
||||
|
||||
# 抽样 UserUID 为 NULL 的用户
|
||||
cur.execute("SELECT UserUUID, UserName, OpenID, Phone FROM user WHERE UserUID IS NULL OR UserUID = '' LIMIT 5")
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
print('\nUserUID为空的用户示例:')
|
||||
for r in rows:
|
||||
print(f' UserName={r[1]}, OpenID={r[2]}, Phone={r[3]}')
|
||||
|
||||
conn.close()
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import io
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
@@ -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. 返回响应
|
||||
@@ -394,40 +402,46 @@ class AdminUpdateConfigView(APIView):
|
||||
# 🔴 修复:更新QQ群配置 - 确保根据peizhiid正确更新对应的记录
|
||||
qq_groups = request.data.get('qq_groups')
|
||||
if qq_groups and isinstance(qq_groups, list):
|
||||
# 收集所有 peizhiid,批量预取已有记录(避免循环内 N+1)
|
||||
_peizhiids = [g.get('peizhiid') for g in qq_groups if g.get('peizhiid')]
|
||||
_existing_map = {}
|
||||
if _peizhiids:
|
||||
for q in Qunpeizhi.query.filter(GroupType__in=_peizhiids):
|
||||
# 如果存在多个相同 peizhiid 的记录,取第一个(应该只有一个)
|
||||
if q.GroupType not in _existing_map:
|
||||
_existing_map[q.GroupType] = q
|
||||
|
||||
_to_create = []
|
||||
_to_update = []
|
||||
updated_groups = []
|
||||
for group_data in qq_groups:
|
||||
peizhiid = group_data.get('peizhiid')
|
||||
if not peizhiid:
|
||||
continue
|
||||
|
||||
# 🔴 修复:使用filter()确保查询到正确的记录
|
||||
qun_configs = Qunpeizhi.query.filter(GroupType=peizhiid)
|
||||
|
||||
if qun_configs.exists():
|
||||
# 如果存在多个相同peizhiid的记录,取第一个(应该只有一个)
|
||||
qun_config = qun_configs.first()
|
||||
|
||||
# 更新字段
|
||||
neirong = group_data.get('neirong')
|
||||
qunid = group_data.get('qunid')
|
||||
jieshao = group_data.get('jieshao')
|
||||
neirong = group_data.get('neirong')
|
||||
qunid = group_data.get('qunid')
|
||||
jieshao = group_data.get('jieshao')
|
||||
|
||||
qun_config = _existing_map.get(peizhiid)
|
||||
if qun_config is not None:
|
||||
# 更新已有记录
|
||||
if neirong is not None:
|
||||
qun_config.GroupContent = neirong or ''
|
||||
if qunid is not None:
|
||||
qun_config.GroupID = qunid or ''
|
||||
if jieshao is not None:
|
||||
qun_config.Description = jieshao or ''
|
||||
|
||||
qun_config.save()
|
||||
_to_update.append(qun_config)
|
||||
else:
|
||||
# 如果不存在,创建新的(使用提供的peizhiid)
|
||||
qun_config = Qunpeizhi.query.create(
|
||||
# 不存在则创建新的
|
||||
qun_config = Qunpeizhi(
|
||||
GroupType=peizhiid,
|
||||
GroupContent=group_data.get('neirong', '') or '',
|
||||
GroupID=group_data.get('qunid', '') or '',
|
||||
Description=group_data.get('jieshao', '') or ''
|
||||
GroupContent=neirong or '',
|
||||
GroupID=qunid or '',
|
||||
Description=jieshao or '',
|
||||
)
|
||||
_to_create.append(qun_config)
|
||||
|
||||
updated_groups.append({
|
||||
'peizhiid': qun_config.GroupType,
|
||||
@@ -436,6 +450,13 @@ class AdminUpdateConfigView(APIView):
|
||||
'jieshao': qun_config.Description
|
||||
})
|
||||
|
||||
if _to_create:
|
||||
Qunpeizhi.objects.bulk_create(_to_create)
|
||||
if _to_update:
|
||||
Qunpeizhi.objects.bulk_update(
|
||||
_to_update, ['GroupContent', 'GroupID', 'Description']
|
||||
)
|
||||
|
||||
if updated_groups:
|
||||
response_data['qq_groups'] = updated_groups
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import io
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
@@ -270,6 +270,13 @@ class DashouPaihangView(APIView):
|
||||
# 第一名数量不足,不展示
|
||||
return [], False
|
||||
|
||||
# 批量预取老板扩展表昵称
|
||||
_boss_user_ids = [g.user_id for g in guanshi_list]
|
||||
_boss_nicheng_map = {
|
||||
b.user_id: b.nickname
|
||||
for b in UserBoss.query.filter(user_id__in=_boss_user_ids).only('user_id', 'nickname')
|
||||
}
|
||||
|
||||
# 构建返回数据
|
||||
paihang_list = []
|
||||
for index, guanshi in enumerate(guanshi_list):
|
||||
@@ -277,8 +284,7 @@ class DashouPaihangView(APIView):
|
||||
user_main = guanshi.user
|
||||
|
||||
# 管事的昵称需要查询老板扩展表
|
||||
boss_profile = UserBoss.query.filter(user=user_main).first()
|
||||
guanshi_nicheng = boss_profile.nickname if boss_profile else '未设置昵称'
|
||||
guanshi_nicheng = _boss_nicheng_map.get(guanshi.user_id, '未设置昵称')
|
||||
|
||||
# 获取头像(相对URL)
|
||||
touxiang_url = user_main.Avatar or ''
|
||||
@@ -337,6 +343,13 @@ class DashouPaihangView(APIView):
|
||||
# 第一名流水不足,不展示
|
||||
return [], False
|
||||
|
||||
# 批量预取老板扩展表昵称
|
||||
_boss_user_ids = [s.user_id for s in shangjia_list]
|
||||
_boss_nicheng_map = {
|
||||
b.user_id: b.nickname
|
||||
for b in UserBoss.query.filter(user_id__in=_boss_user_ids).only('user_id', 'nickname')
|
||||
}
|
||||
|
||||
# 构建返回数据
|
||||
paihang_list = []
|
||||
for index, shangjia in enumerate(shangjia_list):
|
||||
@@ -344,8 +357,7 @@ class DashouPaihangView(APIView):
|
||||
user_main = shangjia.user
|
||||
|
||||
# 商家的昵称需要查询老板扩展表
|
||||
boss_profile = UserBoss.query.filter(user=user_main).first()
|
||||
shangjia_nicheng = boss_profile.nickname if boss_profile else '未设置昵称'
|
||||
shangjia_nicheng = _boss_nicheng_map.get(shangjia.user_id, '未设置昵称')
|
||||
|
||||
# 获取头像(相对URL)
|
||||
touxiang_url = user_main.Avatar or ''
|
||||
@@ -604,21 +616,21 @@ class GetXiugaiJiluView(APIView):
|
||||
records = cursor.fetchall()
|
||||
|
||||
# 🔥 6. 处理查询结果(严格按照字段类型处理)
|
||||
# 预查询修改者账号信息(循环外执行一次,避免循环内重复查询同一 SQL)
|
||||
xiugaizhe_zhanghao = ''
|
||||
with connection.cursor() as cursor2:
|
||||
cursor2.execute(
|
||||
"SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1",
|
||||
[xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid]
|
||||
)
|
||||
xiugaizhe_info = cursor2.fetchone()
|
||||
if xiugaizhe_info:
|
||||
xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \
|
||||
xiugaizhe_info[1] else xiugaizhe_zhanghaoid
|
||||
|
||||
jilu_list = []
|
||||
for record in records:
|
||||
# 获取修改者账号信息(都是字符串)
|
||||
xiugaizhe_zhanghao = ''
|
||||
with connection.cursor() as cursor2:
|
||||
# 🔥 phone和yonghuid都是CharField,用字符串查询
|
||||
cursor2.execute(
|
||||
"SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1",
|
||||
[xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid]
|
||||
)
|
||||
xiugaizhe_info = cursor2.fetchone()
|
||||
if xiugaizhe_info:
|
||||
# 🔥 都是字符串
|
||||
xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \
|
||||
xiugaizhe_info[1] else xiugaizhe_zhanghaoid
|
||||
# 修改者账号信息已预查询,直接使用
|
||||
|
||||
# 获取会员名称(如果有)
|
||||
huiyuan_mingzi = ''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import io
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# 商家扩展表
|
||||
@@ -508,7 +509,7 @@ class KehuGetDingdanLianjieView(APIView):
|
||||
# 获取商家昵称
|
||||
shangjia_nicheng = ''
|
||||
try:
|
||||
user_main = User.query.get(UserUID=lianjie_obj.UserID)
|
||||
user_main = User.query.select_related('ShopProfile').get(UserUID=lianjie_obj.UserID)
|
||||
shangjia_obj = user_main.ShopProfile
|
||||
shangjia_nicheng = shangjia_obj.nicheng
|
||||
except (User.DoesNotExist, UserShangjia.DoesNotExist):
|
||||
@@ -816,7 +817,7 @@ class KehuTianxieDingdanView(APIView):
|
||||
# 验证打手是否存在且有效
|
||||
try:
|
||||
# 先查询用户主表
|
||||
dashou_user = User.query.get(UserUID=zhiding_dashou)
|
||||
dashou_user = User.query.select_related('DashouProfile').get(UserUID=zhiding_dashou)
|
||||
logger.info(f"找到打手用户 - 用户ID: {zhiding_dashou}")
|
||||
|
||||
# 通过反向关系获取打手扩展信息
|
||||
@@ -922,7 +923,7 @@ class KehuTianxieDingdanView(APIView):
|
||||
|
||||
# 获取商家信息用于返回
|
||||
try:
|
||||
user_main = User.query.get(UserUID=lianjie_obj.UserID)
|
||||
user_main = User.query.select_related('ShopProfile').get(UserUID=lianjie_obj.UserID)
|
||||
shangjia_obj = user_main.ShopProfile
|
||||
shangjia_mingcheng = shangjia_obj.nicheng
|
||||
except:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import io
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
@@ -142,17 +142,21 @@ class ShangjiaMobanListView(APIView):
|
||||
return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400)
|
||||
|
||||
current_page = paginator.page(page)
|
||||
# 批量预取所有称号,避免循环内 N+1
|
||||
_mobans_list = list(current_page.object_list)
|
||||
_title_ids = [m.TitleID for m in _mobans_list if m.TitleID]
|
||||
_ch_map = {
|
||||
ch.id: ch
|
||||
for ch in Chenghao.query.filter(id__in=_title_ids).only('id', 'mingcheng', 'texiao_miaoshu')
|
||||
} if _title_ids else {}
|
||||
formatted = []
|
||||
for moban in current_page.object_list:
|
||||
for moban in _mobans_list:
|
||||
texiao_json = ''
|
||||
label_name = ''
|
||||
if moban.TitleID:
|
||||
try:
|
||||
ch = Chenghao.query.get(id=moban.TitleID)
|
||||
label_name = ch.mingcheng
|
||||
texiao_json = ch.texiao_miaoshu or ''
|
||||
except Chenghao.DoesNotExist:
|
||||
pass
|
||||
ch = _ch_map.get(moban.TitleID)
|
||||
if ch:
|
||||
label_name = ch.mingcheng
|
||||
texiao_json = ch.texiao_miaoshu or ''
|
||||
formatted.append({
|
||||
'mobanId': moban.id,
|
||||
'shangpinTypeId': moban.ProductTypeID,
|
||||
|
||||
@@ -252,10 +252,34 @@ class CheckPhoneAuthView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
@staticmethod
|
||||
def _phone_auth_completed(user):
|
||||
"""是否已完成手机号认证(兼容 PhoneVerified 未写入的老数据)。"""
|
||||
if bool(getattr(user, 'PhoneVerified', False)):
|
||||
return True
|
||||
phone = (getattr(user, 'Phone', None) or '').strip()
|
||||
return len(phone) >= 11
|
||||
|
||||
if getattr(user, 'PhoneVerified', False):
|
||||
def post(self, request):
|
||||
uid = getattr(request.user, 'UserUID', None)
|
||||
user = request.user
|
||||
if uid:
|
||||
try:
|
||||
user = User.query.get(UserUID=uid)
|
||||
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})
|
||||
|
||||
if self._dashou_meets_threshold(user):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
-- ============================================================
|
||||
-- RBAC 表创建脚本
|
||||
-- 仅创建生产数据库中缺失的表,已存在的表会被跳过
|
||||
-- 执行方式: mysql -u root -p xaio_cheng_xu < create_rbac_tables.sql
|
||||
-- 注意: 索引和外键单独处理,避免重复创建报错
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- Part 1: backend RBAC 表(小写表名,verify_kefu_permission 使用)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `role` (
|
||||
`role_id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
|
||||
`role_code` varchar(50) NOT NULL UNIQUE,
|
||||
`role_name` varchar(50) NOT NULL,
|
||||
`description` varchar(200) NOT NULL,
|
||||
`create_time` datetime(6) NOT NULL,
|
||||
`update_time` datetime(6) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `permission` (
|
||||
`perm_id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
|
||||
`perm_code` varchar(100) NOT NULL UNIQUE,
|
||||
`perm_name` varchar(100) NOT NULL,
|
||||
`description` varchar(200) NOT NULL,
|
||||
`create_time` datetime(6) NOT NULL,
|
||||
`update_time` datetime(6) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_role` (
|
||||
`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
|
||||
`account_id` varchar(11) NOT NULL,
|
||||
`create_time` datetime(6) NOT NULL,
|
||||
`update_time` datetime(6) NOT NULL,
|
||||
`role_id` integer NOT NULL,
|
||||
UNIQUE KEY `user_role_account_id_role_id_uniq` (`account_id`, `role_id`)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `role_permission` (
|
||||
`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
|
||||
`create_time` datetime(6) NOT NULL,
|
||||
`permission_id` integer NOT NULL,
|
||||
`role_id` integer NOT NULL,
|
||||
UNIQUE KEY `role_permission_role_id_permission_id_uniq` (`role_id`, `permission_id`)
|
||||
);
|
||||
|
||||
-- backend 表索引(忽略已存在的错误)
|
||||
CREATE INDEX `user_role_account_id_idx` ON `user_role` (`account_id`);
|
||||
|
||||
-- backend 外键约束
|
||||
ALTER TABLE `user_role` ADD CONSTRAINT `user_role_role_fk` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`);
|
||||
ALTER TABLE `role_permission` ADD CONSTRAINT `role_permission_permission_fk` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`perm_id`);
|
||||
ALTER TABLE `role_permission` ADD CONSTRAINT `role_permission_role_fk` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`);
|
||||
|
||||
-- ============================================================
|
||||
-- Part 2: gvsdsdk RBAC 表(大写表名,UUID 主键)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `AccountActivation` (`AccountActivationCode` BINARY(6) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `EphemeralToken` (`ELTUUID` BINARY(16) NOT NULL PRIMARY KEY, `ELTUserUUID` BINARY(16) NOT NULL, `ELTCreateTime` datetime(6) NOT NULL, `ELTEndTime` datetime(6) NOT NULL, `ELTType` smallint NOT NULL, `ELTAllowIP` json NOT NULL, `ELTStatus` smallint NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `Password` (`UserUUID` BINARY(16) NOT NULL PRIMARY KEY, `Password` varchar(1024) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `Permission` (`PermUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `PermName` varchar(128) NOT NULL, `PermCode` varchar(128) NOT NULL, `PermCategory` varchar(64) NOT NULL, `PermStatus` smallint NOT NULL, `PermDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `Position` (`PositionUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `PositionName` varchar(128) NOT NULL, `PositionCode` varchar(64) NOT NULL, `PositionLevel` smallint NOT NULL, `PositionStatus` smallint NOT NULL, `PositionDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `PositionTemplate` (`PositionTemplateUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `TemplateName` varchar(128) NOT NULL, `TemplateCode` varchar(64) NOT NULL, `TemplateStatus` smallint NOT NULL, `TemplateDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `PositionTemplatePermission` (`PTPermUUID` BINARY(16) NOT NULL PRIMARY KEY, `PositionTemplateUUID` BINARY(16) NOT NULL, `PermUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `Role` (`RoleUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `RoleName` varchar(128) NOT NULL, `RoleType` smallint NOT NULL, `RoleStatus` smallint NOT NULL, `AssignScope` smallint NOT NULL, `AssignScopeUUID` BINARY(16) NULL, `RoleDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `RolePermission` (`RolePermUUID` BINARY(16) NOT NULL PRIMARY KEY, `RoleUUID` BINARY(16) NOT NULL, `PermUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `RoleShop` (`RoleShopUUID` BINARY(16) NOT NULL PRIMARY KEY, `RoleUUID` BINARY(16) NOT NULL, `ShopUUID` BINARY(16) NOT NULL, `IsMainShop` smallint NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `Shop` (`ShopUUID` BINARY(16) NOT NULL PRIMARY KEY, `CompanyUUID` BINARY(16) NOT NULL, `ShopName` varchar(128) NOT NULL, `ShopCode` varchar(64) NOT NULL, `ShopType` smallint NOT NULL, `ShopStatus` smallint NOT NULL, `ShopAddress` varchar(512) NOT NULL, `ShopContact` varchar(128) NOT NULL, `ShopPhone` varchar(32) NOT NULL, `ShopDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `User2FA` (`User2FAUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `SecretKey` varchar(64) NOT NULL, `IsEnabled` smallint NOT NULL, `Verified` smallint NOT NULL, `LastUsedAt` datetime(6) NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `UserABAC` (`UserABACUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `HierarchicalPermissionIdentifiers` varchar(256) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `UserAvatar` (`UserUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserAvatar` longblob NOT NULL, `AvatarUploadTime` datetime(6) NOT NULL, `AvatarFileType` varchar(16) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `UserDept` (`UserDeptUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `DeptUUID` BINARY(16) NOT NULL, `PositionUUID` BINARY(16) NULL, `IsMainDept` smallint NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `UserLoginLog` (`LogUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `LoginIP` varchar(39) NOT NULL, `LoginPlatform` varchar(32) NOT NULL, `LoginStatus` smallint NOT NULL, `LoginTime` datetime(6) NOT NULL, `UserAgent` varchar(512) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `UserPending2FA` (`PendingUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL UNIQUE, `SecretKey` varchar(64) NOT NULL, `ExpireAt` datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS `UserRole` (`UserRoleUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `RoleUUID` BINARY(16) NOT NULL, `CompanyUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL);
|
||||
|
||||
-- gvsdsdk 索引
|
||||
CREATE INDEX `EphemELTUserUUID_idx` ON `EphemeralToken` (`ELTUserUUID`);
|
||||
CREATE INDEX `EphemELTStatus_idx` ON `EphemeralToken` (`ELTStatus`);
|
||||
CREATE INDEX `User2FA_UserUUID_idx` ON `User2FA` (`UserUUID`);
|
||||
CREATE INDEX `UserLoginLog_UserUUID_idx` ON `UserLoginLog` (`UserUUID`);
|
||||
111
deploy/seed_shop_permissions.sql
Normal file
111
deploy/seed_shop_permissions.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- ============================================================
|
||||
-- 店铺相关权限:查询 + 缺失则插入
|
||||
-- 主权限表:Permission(gvsdsdk RBAC,角色管理页读取此表)
|
||||
-- 兼容旧表:permission(legacy,部分账号仍走 user_role.account_id=手机号)
|
||||
-- ============================================================
|
||||
|
||||
-- ---------- 1. 查询 gvsdsdk 主权限表 ----------
|
||||
SELECT PermCode, PermName, PermDesc, PermCategory, PermStatus
|
||||
FROM `Permission`
|
||||
WHERE PermCode IN (
|
||||
'999a', '999b', '999c', '999d', '999e',
|
||||
'1199ab', '1199abc', '1199abd', '1199abe'
|
||||
)
|
||||
ORDER BY PermCode;
|
||||
|
||||
-- ---------- 2. 查询 legacy 权限表(如仍在使用) ----------
|
||||
SELECT perm_id, perm_code, perm_name, description
|
||||
FROM `permission`
|
||||
WHERE perm_code IN (
|
||||
'999a', '999b', '999c', '999d', '999e',
|
||||
'1199ab', '1199abc', '1199abd', '1199abe'
|
||||
)
|
||||
ORDER BY perm_code;
|
||||
|
||||
-- ---------- 3. 缺失则写入 Permission(主表,推荐) ----------
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺状态管理', '999a', '店铺管理', 1, '修改店铺封禁/正常状态', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999a');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺余额与创建', '999b', '店铺管理', 1, '修改可提现余额、添加新店铺', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999b');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺提现限额', '999c', '店铺管理', 1, '修改每日提现限额及开关', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999c');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺抽成配置', '999d', '店铺管理', 1, '修改店铺订单收益抽成及默认抽成费率', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999d');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺基本信息', '999e', '店铺管理', 1, '修改店铺名称、电话、微信号', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '999e');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品审核模式', '1199ab', '店铺管理', 1, '查看/修改店铺商品全局审核开关', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199ab');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品封禁下架', '1199abc', '店铺管理', 1, '封禁、下架、驳回店铺商品/类型审核', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abc');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品上架解封', '1199abd', '店铺管理', 1, '上架、解封、审核通过店铺商品/类型', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abd');
|
||||
|
||||
INSERT INTO `Permission` (`PermUUID`, `TenantUUID`, `PermName`, `PermCode`, `PermCategory`, `PermStatus`, `PermDesc`, `CreateTime`)
|
||||
SELECT UUID_TO_BIN(UUID()), t.TenantUUID, '店铺商品信息修改', '1199abe', '店铺管理', 1, '修改店铺商品标题、价格、库存等基本信息', NOW()
|
||||
FROM (SELECT TenantUUID FROM `Permission` WHERE PermStatus = 1 LIMIT 1) t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `Permission` p WHERE p.PermCode = '1199abe');
|
||||
|
||||
-- ---------- 4. 可选:legacy permission 表(若你们仍在用旧角色表) ----------
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '999a', '店铺状态管理', '修改店铺封禁/正常状态', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999a');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '999b', '店铺余额与创建', '修改可提现余额、添加新店铺', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999b');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '999c', '店铺提现限额', '修改每日提现限额及开关', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999c');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '999d', '店铺抽成配置', '修改店铺订单收益抽成及默认抽成费率', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999d');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '999e', '店铺基本信息', '修改店铺名称、电话、微信号', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '999e');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '1199ab', '店铺商品审核模式', '查看/修改店铺商品全局审核开关', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199ab');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '1199abc', '店铺商品封禁下架', '封禁、下架、驳回店铺商品/类型审核', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abc');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '1199abd', '店铺商品上架解封', '上架、解封、审核通过店铺商品/类型', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abd');
|
||||
|
||||
INSERT INTO `permission` (`perm_code`, `perm_name`, `description`, `CreateTime`, `UpdateTime`)
|
||||
SELECT '1199abe', '店铺商品信息修改', '修改店铺商品标题、价格、库存等基本信息', NOW(), NOW()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `permission` WHERE perm_code = '1199abe');
|
||||
|
||||
-- ---------- 5. 插入后再查一遍确认 ----------
|
||||
SELECT PermCode, PermName, PermStatus FROM `Permission`
|
||||
WHERE PermCode IN ('999a','999b','999c','999d','999e','1199ab','1199abc','1199abd','1199abe')
|
||||
ORDER BY PermCode;
|
||||
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
217
jituan/management/commands/audit_member_bug_impact.py
Normal file
217
jituan/management/commands/audit_member_bug_impact.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
全面审计会员 sync Bug 影响范围(与 rollback 的 44 条不是同一口径)。
|
||||
|
||||
rollback 只收「当前仍错误有效」的记录。
|
||||
本命令额外统计:
|
||||
- 全库会员/充值单规模
|
||||
- 当前仍有效 vs 充值单原定期限已过(= rollback 第二类)
|
||||
- 疑似被 repair 写回过(含已再次过期、当前看不出的历史受害)
|
||||
|
||||
用法:
|
||||
python manage.py audit_member_bug_impact
|
||||
python manage.py audit_member_bug_impact --since 2026-06-15
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from jituan.services.huiyuan_bundle import (
|
||||
_normalize_datetime_for_compare,
|
||||
huiyuan_ids_match,
|
||||
)
|
||||
from jituan.services.member_recharge import (
|
||||
MEMBER_LEIXING,
|
||||
MEMBER_PAID_STATUS,
|
||||
_czjilu_entitlement_end,
|
||||
_czjilu_entitlement_revoked,
|
||||
_parse_since_option,
|
||||
club_huiyuan_sellable,
|
||||
_order_is_trial_like,
|
||||
)
|
||||
from products.models import Czjilu, Huiyuangoumai
|
||||
from users.models import Xiugaijilu
|
||||
|
||||
|
||||
def _order_purchase_days(order, club_id=None):
|
||||
cid = getattr(order, 'club_id', None) or club_id or 'xq'
|
||||
is_trial = _order_is_trial_like(order, cid)
|
||||
days = int(getattr(order, 'purchase_days', None) or 0)
|
||||
if days <= 0:
|
||||
_, _, days = club_huiyuan_sellable(cid, order.huiyuan_id, is_trial=is_trial)
|
||||
return max(int(days or 0), 1)
|
||||
|
||||
|
||||
def _paid_orders_for(yonghu_id, huiyuan_id):
|
||||
return [
|
||||
o for o in Czjilu.query.filter(
|
||||
yonghuid=yonghu_id,
|
||||
leixing=MEMBER_LEIXING,
|
||||
zhuangtai=MEMBER_PAID_STATUS,
|
||||
)
|
||||
if huiyuan_ids_match(o.huiyuan_id, huiyuan_id)
|
||||
]
|
||||
|
||||
|
||||
def _suspected_repair_restore(rec, since=None):
|
||||
"""
|
||||
repair 特征:UpdateTime 时点将 daoqi 设为 UpdateTime+days,
|
||||
且存在匹配充值单原定期限在 UpdateTime 之前已结束。
|
||||
"""
|
||||
updated = _normalize_datetime_for_compare(rec.UpdateTime)
|
||||
since_dt = _parse_since_option(since)
|
||||
if since_dt and updated < since_dt:
|
||||
return None
|
||||
daoqi = _normalize_datetime_for_compare(rec.daoqi_time)
|
||||
gap = daoqi - updated
|
||||
gap_days = gap.days
|
||||
if gap_days < 1 or gap_days > 400:
|
||||
return None
|
||||
|
||||
orders = _paid_orders_for(rec.yonghu_id, rec.huiyuan_id)
|
||||
if not orders:
|
||||
return None
|
||||
|
||||
for order in orders:
|
||||
if _czjilu_entitlement_revoked(order):
|
||||
continue
|
||||
days = _order_purchase_days(order, rec.club_id)
|
||||
if abs(days - gap_days) > 2:
|
||||
continue
|
||||
end = _czjilu_entitlement_end(order, rec.club_id)
|
||||
if end < updated:
|
||||
return order.dingdan_id
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '审计会员 sync Bug 影响规模(全库口径 vs rollback 口径)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--since',
|
||||
default='2026-06-01',
|
||||
help='疑似 repair 的 UpdateTime 起始日(默认 2026-06-01)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--sample',
|
||||
type=int,
|
||||
default=30,
|
||||
help='每类样例输出条数',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
since = options['since']
|
||||
sample_n = int(options['sample'] or 30)
|
||||
now = _normalize_datetime_for_compare(timezone.now())
|
||||
|
||||
total_hy = Huiyuangoumai.query.count()
|
||||
active_hy = Huiyuangoumai.query.filter(
|
||||
huiyuan_zhuangtai=1,
|
||||
daoqi_time__gt=timezone.now(),
|
||||
).count()
|
||||
|
||||
expired_czjilu_rows = 0
|
||||
expired_czjilu_users = set()
|
||||
for o in Czjilu.query.filter(leixing=MEMBER_LEIXING, zhuangtai=MEMBER_PAID_STATUS):
|
||||
end = _czjilu_entitlement_end(o)
|
||||
if end <= now:
|
||||
expired_czjilu_rows += 1
|
||||
expired_czjilu_users.add(o.yonghuid)
|
||||
|
||||
rollback_active_orphan = 0
|
||||
rollback_admin_still_active = 0
|
||||
suspected_all = []
|
||||
suspected_still_active = []
|
||||
suspected_already_expired = []
|
||||
|
||||
seen_admin = set()
|
||||
for log in Xiugaijilu.query.filter(huiyuan_id__isnull=False).exclude(huiyuan_id=''):
|
||||
note = log.qitashuoming or ''
|
||||
if '移除' not in note and '减会员' not in note:
|
||||
continue
|
||||
if not log.yonghuid or not log.huiyuan_id:
|
||||
continue
|
||||
pair = (log.yonghuid, str(log.huiyuan_id).strip())
|
||||
if pair in seen_admin:
|
||||
continue
|
||||
seen_admin.add(pair)
|
||||
for rec in Huiyuangoumai.query.filter(yonghu_id=log.yonghuid):
|
||||
if not huiyuan_ids_match(rec.huiyuan_id, log.huiyuan_id):
|
||||
continue
|
||||
if not rec.jiance_shifou_daoqi() and rec.huiyuan_zhuangtai == 1:
|
||||
rollback_admin_still_active += 1
|
||||
break
|
||||
|
||||
for rec in Huiyuangoumai.query.all():
|
||||
is_active = (
|
||||
rec.huiyuan_zhuangtai == 1
|
||||
and _normalize_datetime_for_compare(rec.daoqi_time) > now
|
||||
)
|
||||
|
||||
paid = _paid_orders_for(rec.yonghu_id, rec.huiyuan_id)
|
||||
if paid and is_active:
|
||||
has_live_czjilu = any(
|
||||
not _czjilu_entitlement_revoked(o)
|
||||
and _czjilu_entitlement_end(o, rec.club_id) > now
|
||||
for o in paid
|
||||
)
|
||||
if not has_live_czjilu:
|
||||
rollback_active_orphan += 1
|
||||
|
||||
dingdan_id = _suspected_repair_restore(rec, since=since)
|
||||
if dingdan_id:
|
||||
row = (
|
||||
rec.yonghu_id,
|
||||
rec.huiyuan_id,
|
||||
str(rec.daoqi_time),
|
||||
str(rec.UpdateTime),
|
||||
dingdan_id,
|
||||
'仍有效' if is_active else '已再次过期',
|
||||
)
|
||||
suspected_all.append(row)
|
||||
if is_active:
|
||||
suspected_still_active.append(row)
|
||||
else:
|
||||
suspected_already_expired.append(row)
|
||||
|
||||
self.stdout.write(self.style.WARNING('=== 全库规模(回答「几万条过期充值」)==='))
|
||||
self.stdout.write(f' huiyuangoumai 总记录数: {total_hy}')
|
||||
self.stdout.write(f' 当前仍有效会员记录: {active_hy}')
|
||||
self.stdout.write(
|
||||
f' 已付会员充值单且原定期限已过: {expired_czjilu_rows} 条,'
|
||||
f'{len(expired_czjilu_users)} 个用户',
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'\n=== rollback 命令口径(只收「当前仍错误有效」)===',
|
||||
))
|
||||
self.stdout.write(f' 后台已减仍有效: {rollback_admin_still_active}')
|
||||
self.stdout.write(f' 充值单全过期但仍有效: {rollback_active_orphan}')
|
||||
self.stdout.write(
|
||||
f' 合计约: {rollback_admin_still_active + rollback_active_orphan} 条',
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'\n=== 更广口径:疑似 repair 写回过(UpdateTime>={since})===',
|
||||
))
|
||||
self.stdout.write(f' 疑似受害记录总数: {len(suspected_all)}')
|
||||
self.stdout.write(f' 其中当前仍有效: {len(suspected_still_active)}')
|
||||
self.stdout.write(f' 其中已再次过期(rollback 收不到): {len(suspected_already_expired)}')
|
||||
|
||||
unique_users = {r[0] for r in suspected_all}
|
||||
self.stdout.write(f' 涉及不同用户数: {len(unique_users)}')
|
||||
|
||||
self.stdout.write('\n样例(疑似 repair,含已再次过期):')
|
||||
for row in suspected_all[:sample_n]:
|
||||
self.stdout.write(
|
||||
f' 用户{row[0]} 会员{row[1]} {row[5]} '
|
||||
f'到期{row[2]} 更新{row[3]} 依据单{row[4]}',
|
||||
)
|
||||
if len(suspected_all) > sample_n:
|
||||
self.stdout.write(f' ... 另有 {len(suspected_all) - sample_n} 条')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'\n说明: 几万条过期充值单是历史正常数据;Bug 不会批量改全库,'
|
||||
'只在用户打开抢单池时触发。rollback 只处理「此刻仍错误有效」的记录。',
|
||||
))
|
||||
52
jituan/management/commands/backfill_member_trial_flags.py
Normal file
52
jituan/management/commands/backfill_member_trial_flags.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""回填会员体验标记:czjilu.is_trial + huiyuangoumai.has_used_trial。"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from jituan.services.member_recharge import (
|
||||
_order_is_trial_like,
|
||||
has_paid_trial_purchase,
|
||||
)
|
||||
from products.models import Czjilu, Huiyuangoumai
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '回填体验会员订单/权益标记(修复重复购买体验、正式计次错误)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='仅统计,不写库',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = bool(options.get('dry_run'))
|
||||
czjilu_fixed = 0
|
||||
goumai_fixed = 0
|
||||
|
||||
member_orders = list(Czjilu.query.filter(leixing=1, zhuangtai=3))
|
||||
with transaction.atomic():
|
||||
for order in member_orders:
|
||||
club_id = getattr(order, 'club_id', None)
|
||||
if _order_is_trial_like(order, club_id) and not order.is_trial:
|
||||
czjilu_fixed += 1
|
||||
if not dry_run:
|
||||
Czjilu.query.filter(dingdan_id=order.dingdan_id).update(is_trial=True)
|
||||
|
||||
goumai_rows = list(Huiyuangoumai.query.all())
|
||||
for row in goumai_rows:
|
||||
if row.has_used_trial:
|
||||
continue
|
||||
if has_paid_trial_purchase(row.yonghu_id, row.huiyuan_id, row.club_id):
|
||||
goumai_fixed += 1
|
||||
if not dry_run:
|
||||
Huiyuangoumai.query.filter(id=row.id).update(has_used_trial=True)
|
||||
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
mode = 'DRY-RUN' if dry_run else 'APPLIED'
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'[{mode}] czjilu.is_trial 修复 {czjilu_fixed} 条,'
|
||||
f'huiyuangoumai.has_used_trial 修复 {goumai_fixed} 条',
|
||||
))
|
||||
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),仅审核模式可见;文案已避开代练代打。'
|
||||
))
|
||||
139
jituan/management/commands/rollback_erroneous_member_restore.py
Normal file
139
jituan/management/commands/rollback_erroneous_member_restore.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
收回因 dddhq 每次 sync 错误补开通的会员权益(保守策略,优先不误伤正常付费)。
|
||||
|
||||
安全规则(按优先级):
|
||||
1. 有任意未过期已付充值单支撑 → 绝不收回
|
||||
2. 无充值单的老数据 → 不自动处理
|
||||
3. 默认要求符合 bug repair 指纹(UpdateTime 写回 + 旧单原定期限已过)
|
||||
4. 后台减会员:仅当仍有效且无未过期充值单时才 revoke
|
||||
|
||||
用法:
|
||||
python manage.py rollback_erroneous_member_restore --dry-run
|
||||
python manage.py rollback_erroneous_member_restore
|
||||
python manage.py rollback_erroneous_member_restore --since 2026-06-01
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from jituan.services.huiyuan_bundle import huiyuan_ids_match
|
||||
from jituan.services.member_recharge import (
|
||||
expire_huiyuangoumai_record,
|
||||
revoke_member_entitlement_admin,
|
||||
should_rollback_erroneous_membership,
|
||||
user_has_active_czjilu_entitlement,
|
||||
)
|
||||
from products.models import Huiyuangoumai
|
||||
from users.models import Xiugaijilu
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '收回错误恢复的会员(保守:有未过期充值单绝不碰)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='仅统计,不写库',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--since',
|
||||
default='2026-06-01',
|
||||
help='repair 指纹 UpdateTime 起始日(默认 2026-06-01)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-repair-signature',
|
||||
action='store_true',
|
||||
help='不要求 repair 指纹(更激进,不推荐)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = bool(options.get('dry_run'))
|
||||
since = options.get('since')
|
||||
require_sig = not bool(options.get('no_repair_signature'))
|
||||
admin_revoke = 0
|
||||
expire_orphan = 0
|
||||
skipped_safe = 0
|
||||
samples = []
|
||||
|
||||
with transaction.atomic():
|
||||
seen_pairs = set()
|
||||
logs = Xiugaijilu.query.filter(
|
||||
huiyuan_id__isnull=False,
|
||||
).exclude(huiyuan_id='').order_by('-CreateTime')
|
||||
|
||||
for log in logs:
|
||||
note = log.qitashuoming or ''
|
||||
if '移除' not in note and '减会员' not in note:
|
||||
continue
|
||||
if not log.yonghuid or not log.huiyuan_id:
|
||||
continue
|
||||
pair = (log.yonghuid, str(log.huiyuan_id).strip())
|
||||
if pair in seen_pairs:
|
||||
continue
|
||||
seen_pairs.add(pair)
|
||||
|
||||
for rec in Huiyuangoumai.query.filter(yonghu_id=log.yonghuid):
|
||||
if not huiyuan_ids_match(rec.huiyuan_id, log.huiyuan_id):
|
||||
continue
|
||||
if rec.jiance_shifou_daoqi() or rec.huiyuan_zhuangtai != 1:
|
||||
continue
|
||||
if user_has_active_czjilu_entitlement(
|
||||
rec.yonghu_id, rec.huiyuan_id, rec.club_id,
|
||||
):
|
||||
skipped_safe += 1
|
||||
continue
|
||||
ok, detail = should_rollback_erroneous_membership(
|
||||
rec, since=since, require_repair_signature=require_sig,
|
||||
)
|
||||
if not ok:
|
||||
skipped_safe += 1
|
||||
continue
|
||||
|
||||
admin_revoke += 1
|
||||
samples.append(
|
||||
f'[后台已减仍有效] {log.yonghuid} 会员{log.huiyuan_id} '
|
||||
f'依据单{detail or "-"}',
|
||||
)
|
||||
if not dry_run:
|
||||
revoke_member_entitlement_admin(log.yonghuid, log.huiyuan_id)
|
||||
break
|
||||
|
||||
for rec in Huiyuangoumai.query.filter(huiyuan_zhuangtai=1):
|
||||
if rec.jiance_shifou_daoqi():
|
||||
continue
|
||||
ok, detail = should_rollback_erroneous_membership(
|
||||
rec, since=since, require_repair_signature=require_sig,
|
||||
)
|
||||
if not ok:
|
||||
if detail == '有未过期充值单支撑':
|
||||
skipped_safe += 1
|
||||
continue
|
||||
|
||||
expire_orphan += 1
|
||||
samples.append(
|
||||
f'[充值单已过期仍有效] {rec.yonghu_id} 会员{rec.huiyuan_id} '
|
||||
f'到期{rec.daoqi_time} 依据单{detail or "-"}',
|
||||
)
|
||||
if not dry_run:
|
||||
expire_huiyuangoumai_record(
|
||||
rec,
|
||||
reason='rollback_erroneous_member_restore',
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
mode = 'DRY-RUN' if dry_run else 'APPLIED'
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'[{mode}] 后台减会员仍有效收回 {admin_revoke} 条;'
|
||||
f'无有效充值单支撑过期 {expire_orphan} 条;'
|
||||
f'安全跳过 {skipped_safe} 条(含正常付费/不符合指纹)',
|
||||
))
|
||||
if require_sig:
|
||||
self.stdout.write(' 已启用 repair 指纹校验(推荐)')
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(' 未启用 repair 指纹(激进模式,慎用)'))
|
||||
for line in samples[:50]:
|
||||
self.stdout.write(f' {line}')
|
||||
if len(samples) > 50:
|
||||
self.stdout.write(f' ... 另有 {len(samples) - 50} 条')
|
||||
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,71 @@ EXTRA_PERMISSIONS = [
|
||||
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
|
||||
'会话管理',
|
||||
),
|
||||
(
|
||||
'gdck666',
|
||||
'投诉工单查看',
|
||||
'查看用户投诉工单列表与详情',
|
||||
'投诉工单',
|
||||
),
|
||||
(
|
||||
'gdcl666',
|
||||
'投诉工单处理',
|
||||
'处理/关闭用户投诉工单',
|
||||
'投诉工单',
|
||||
),
|
||||
(
|
||||
'sfbq666',
|
||||
'身份装饰标签管理',
|
||||
'管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)',
|
||||
'用户管理',
|
||||
),
|
||||
(
|
||||
'005bb',
|
||||
'提现审核(按俱乐部)',
|
||||
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
|
||||
'提现管理',
|
||||
),
|
||||
(
|
||||
'djcfg001',
|
||||
'资金冻结规则配置',
|
||||
'配置本俱乐部各角色冻结比例/分期/解冻条件',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'djled001',
|
||||
'资金冻结明细',
|
||||
'查看冻结流水并手动解冻',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'djrisk001',
|
||||
'罚款投诉风险指标',
|
||||
'查看罚款率/投诉率等解冻相关指标',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'xycfg001',
|
||||
'角色协议配置',
|
||||
'配置打手/管事/组长用户协议并发布版本',
|
||||
'小程序配置',
|
||||
),
|
||||
(
|
||||
'002ad',
|
||||
'修改抢单要求',
|
||||
'修改平台订单/本店商品的抢单要求类型(会员/押金门槛)。须显式绑定;本店超管 000001 不会自动拥有。',
|
||||
'订单管理',
|
||||
),
|
||||
# ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ----------
|
||||
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
|
||||
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),
|
||||
('999c', '店铺提现限额', '修改每日提现限额及开关', '店铺管理'),
|
||||
('999d', '店铺抽成配置', '修改店铺订单收益抽成及默认抽成费率', '店铺管理'),
|
||||
('999e', '店铺基本信息', '修改店铺名称、电话、微信号', '店铺管理'),
|
||||
# ---------- 店铺商品管理(1199 系列:商品/类型/审核) ----------
|
||||
('1199ab', '店铺商品审核模式', '查看/修改店铺商品全局审核开关', '店铺管理'),
|
||||
('1199abc', '店铺商品封禁下架', '封禁、下架、驳回店铺商品/类型审核', '店铺管理'),
|
||||
('1199abd', '店铺商品上架解封', '上架、解封、审核通过店铺商品/类型', '店铺管理'),
|
||||
('1199abe', '店铺商品信息修改', '修改店铺商品标题、价格、库存等基本信息', '店铺管理'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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}'))
|
||||
26
jituan/migrations/0010_club_huiyuan_bundle_include.py
Normal file
26
jituan/migrations/0010_club_huiyuan_bundle_include.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0009_club_huiyuan_recharge_ui'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubHuiyuanBundleInclude',
|
||||
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)),
|
||||
('bundle_huiyuan_id', models.CharField(db_index=True, max_length=6, verbose_name='总会员ID')),
|
||||
('included_huiyuan_id', models.CharField(db_index=True, max_length=6, verbose_name='包含的子会员ID')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'club_huiyuan_bundle_include',
|
||||
'unique_together': {('club_id', 'bundle_huiyuan_id', 'included_huiyuan_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
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='强制展示订单价格',
|
||||
),
|
||||
),
|
||||
]
|
||||
597
jituan/models.py
597
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:
|
||||
@@ -127,6 +226,19 @@ class ClubHuiyuanPrice(QModel):
|
||||
unique_together = [['club_id', 'huiyuan_id']]
|
||||
|
||||
|
||||
class ClubHuiyuanBundleInclude(QModel):
|
||||
"""俱乐部总会员包含的子会员类型(仅 is_bundle 会员使用)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
bundle_huiyuan_id = models.CharField(max_length=6, db_index=True, verbose_name='总会员ID')
|
||||
included_huiyuan_id = models.CharField(max_length=6, db_index=True, verbose_name='包含的子会员ID')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_huiyuan_bundle_include'
|
||||
unique_together = [['club_id', 'bundle_huiyuan_id', 'included_huiyuan_id']]
|
||||
|
||||
|
||||
class ClubShangpinLeixingConfig(QModel):
|
||||
"""各俱乐部小程序商品类型上架与图标(全局 ShangpinLeixing 由集团定义)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
@@ -134,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)
|
||||
|
||||
@@ -303,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')
|
||||
@@ -319,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,81 @@
|
||||
"""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):
|
||||
qs = AdminAssignment.query.filter(yonghuid=yonghuid, status=1, is_primary=True)
|
||||
if except_id:
|
||||
qs = qs.exclude(id=except_id)
|
||||
ids = list(qs.values_list('id', flat=True))
|
||||
if ids:
|
||||
AdminAssignment.objects.filter(pk__in=ids).update(
|
||||
is_primary=False,
|
||||
UpdateTime=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
@@ -30,56 +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, '该任职记录已存在且有效'
|
||||
|
||||
inactive = find_assignment(yonghuid, club_id, role_code)
|
||||
if inactive and inactive.status != 1:
|
||||
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
|
||||
junk.delete()
|
||||
|
||||
row = AdminAssignment.query.create(
|
||||
if is_primary:
|
||||
_clear_other_primary(yonghuid)
|
||||
|
||||
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:
|
||||
return True, None
|
||||
return False, '任职记录不存在'
|
||||
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
|
||||
|
||||
|
||||
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 = []
|
||||
@@ -90,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)
|
||||
|
||||
@@ -14,6 +14,55 @@ GROUP_MANAGE_ROLES = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def get_allowed_club_ids(user):
|
||||
"""用户可切换/访问的子公司 club_id 集合(不含集团汇总)。"""
|
||||
if is_system_super_admin(user):
|
||||
return {c.club_id for c in Club.query.filter(status=1)}
|
||||
assignments = AdminAssignment.query.filter(yonghuid=user.UserUID, status=1)
|
||||
has_group = assignments.filter(
|
||||
club_id__isnull=True
|
||||
).exists() or assignments.filter(data_scope=DATA_SCOPE_ALL).exists()
|
||||
if has_group:
|
||||
return {c.club_id for c in Club.query.filter(status=1)}
|
||||
allowed = {
|
||||
a.club_id for a in assignments if a.club_id
|
||||
}
|
||||
if not allowed:
|
||||
cid = (getattr(user, 'ClubID', None) or '').strip()
|
||||
if cid:
|
||||
allowed = {cid}
|
||||
return allowed
|
||||
|
||||
|
||||
def resolve_admin_effective_club(user, request):
|
||||
"""
|
||||
校验后台用户请求的俱乐部/范围,返回 (club_id, scope, error_msg)。
|
||||
无权限访问请求的俱乐部时,回退到主任职俱乐部(避免 localStorage 脏数据串俱乐部)。
|
||||
"""
|
||||
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||
|
||||
requested_club = resolve_club_id_from_request(request)
|
||||
requested_scope = resolve_club_scope(request)
|
||||
|
||||
if is_system_super_admin(user):
|
||||
return requested_club, requested_scope, None
|
||||
|
||||
ctx = build_admin_club_context(user)
|
||||
allowed = get_allowed_club_ids(user)
|
||||
|
||||
if requested_scope == DATA_SCOPE_ALL:
|
||||
if not ctx.get('is_group_admin'):
|
||||
primary = ctx.get('club_id') or CLUB_ID_DEFAULT
|
||||
return primary, DATA_SCOPE_SINGLE, None
|
||||
return ctx.get('club_id') or CLUB_ID_DEFAULT, DATA_SCOPE_ALL, None
|
||||
|
||||
if requested_club not in allowed:
|
||||
primary = ctx.get('club_id') or CLUB_ID_DEFAULT
|
||||
return primary, DATA_SCOPE_SINGLE, None
|
||||
|
||||
return requested_club, DATA_SCOPE_SINGLE, None
|
||||
|
||||
|
||||
def build_admin_club_context(user):
|
||||
"""
|
||||
根据 admin_assignment + 超管推断后台登录后的俱乐部上下文。
|
||||
@@ -50,13 +99,16 @@ def build_admin_club_context(user):
|
||||
)
|
||||
|
||||
if not assignments:
|
||||
allowed_ids = get_allowed_club_ids(user)
|
||||
visible_clubs = [c for c in all_clubs if c['club_id'] in allowed_ids]
|
||||
club_id = next(iter(allowed_ids), CLUB_ID_DEFAULT) if allowed_ids else CLUB_ID_DEFAULT
|
||||
return _pack(
|
||||
scope=DATA_SCOPE_SINGLE,
|
||||
club_id=CLUB_ID_DEFAULT,
|
||||
club_id=club_id,
|
||||
is_group_admin=False,
|
||||
assignments=[],
|
||||
clubs=all_clubs,
|
||||
can_switch_club=False,
|
||||
clubs=visible_clubs,
|
||||
can_switch_club=len(visible_clubs) > 1,
|
||||
role_code='CLUB_ADMIN',
|
||||
role_name='子公司客服(默认)',
|
||||
)
|
||||
@@ -66,18 +118,22 @@ def build_admin_club_context(user):
|
||||
a.club_id is None or a.data_scope == DATA_SCOPE_ALL
|
||||
for a in assignments
|
||||
)
|
||||
allowed_club_ids = {a.club_id for a in assignments if a.club_id}
|
||||
allowed_ids = get_allowed_club_ids(user)
|
||||
|
||||
if has_group:
|
||||
scope = DATA_SCOPE_ALL
|
||||
club_id = CLUB_ID_DEFAULT
|
||||
visible_clubs = all_clubs
|
||||
else:
|
||||
scope = DATA_SCOPE_SINGLE
|
||||
club_id = primary.club_id or CLUB_ID_DEFAULT
|
||||
|
||||
visible_clubs = all_clubs if has_group else [
|
||||
c for c in all_clubs if c['club_id'] in allowed_club_ids
|
||||
]
|
||||
primary_cid = primary.club_id or CLUB_ID_DEFAULT
|
||||
if primary_cid in allowed_ids:
|
||||
club_id = primary_cid
|
||||
elif allowed_ids:
|
||||
club_id = sorted(allowed_ids)[0]
|
||||
else:
|
||||
club_id = primary_cid
|
||||
visible_clubs = [c for c in all_clubs if c['club_id'] in allowed_ids]
|
||||
|
||||
role_code = primary.role_code or 'CLUB_ADMIN'
|
||||
return _pack(
|
||||
@@ -93,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':
|
||||
@@ -127,18 +207,76 @@ def can_manage_admin_assignments(user, permissions=None):
|
||||
)
|
||||
|
||||
|
||||
def get_switchable_club_ids(user):
|
||||
"""顶栏可切换俱乐部:有任职/数据范围即可切换;菜单按当前俱乐部功能角色展示。"""
|
||||
return get_allowed_club_ids(user)
|
||||
|
||||
|
||||
def can_manage_roles_for_club(user, permissions, club_id):
|
||||
"""
|
||||
是否可为指定俱乐部分配/移除功能角色、添加本店后台用户。
|
||||
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理(不必有集团权限)。
|
||||
"""
|
||||
if can_manage_admin_assignments(user, permissions):
|
||||
return True
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return False
|
||||
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, 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)}
|
||||
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):
|
||||
"""
|
||||
是否可访问「后台用户」页(列表、按俱乐部分配功能角色)。
|
||||
系统超管 / 000001 / 集团高管 / 任一俱乐部超管均可。
|
||||
"""
|
||||
if is_system_super_admin(user):
|
||||
return True
|
||||
if permissions and '000001' in permissions:
|
||||
return True
|
||||
if can_manage_admin_assignments(user, permissions):
|
||||
return True
|
||||
return bool(get_manageable_club_ids(user, permissions))
|
||||
|
||||
|
||||
def _pack(scope, club_id, is_group_admin, assignments, clubs, can_switch_club,
|
||||
role_code, role_name):
|
||||
allowed_club_ids = sorted({
|
||||
c['club_id'] for c in clubs if c.get('club_id')
|
||||
})
|
||||
return {
|
||||
'scope': scope,
|
||||
'club_id': club_id,
|
||||
'is_group_admin': is_group_admin,
|
||||
'role_code': role_code,
|
||||
'role_name': role_name,
|
||||
'allowed_club_ids': allowed_club_ids,
|
||||
'perm_source': 'gvsdsdk',
|
||||
'perm_note': (
|
||||
'【功能权限】在「角色管理」绑 gvsdsdk 角色(如 caiwu、dingdan、000001);'
|
||||
'【数据范围】在「数据范围配置」维护任职(能看/管哪个俱乐部)。两套独立。'
|
||||
'【功能权限】在「角色管理」为各俱乐部创建角色,在「管理员用户」绑定;'
|
||||
'切换俱乐部后菜单按该俱乐部角色生效。'
|
||||
'【数据范围】在本页添加任职,可分配多个俱乐部,无需集团权限即可切换。'
|
||||
),
|
||||
'assignments': [
|
||||
{
|
||||
|
||||
507
jituan/services/alipay_pay.py
Normal file
507
jituan/services/alipay_pay.py
Normal file
@@ -0,0 +1,507 @@
|
||||
"""支付宝支付(RSA2)配置、签名、验签与 wap.pay 跳转 URL 生成。
|
||||
|
||||
沙箱/生产由 settings.ALIPAY_SANDBOX 开关控制,配置存 app_secrets.py。
|
||||
多 club 暂回落全局配置(与 wechat_pay 一致),未来再迁移 Club 表。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ==================== 配置读取 ====================
|
||||
|
||||
def get_alipay_config(club_id=None):
|
||||
"""
|
||||
读取支付宝配置。
|
||||
根据 settings.ALIPAY_SANDBOX 开关返回沙箱或生产配置。
|
||||
club_id 暂未使用(预留多 club 扩展),统一回落全局配置。
|
||||
"""
|
||||
sandbox = getattr(settings, 'ALIPAY_SANDBOX', True)
|
||||
if sandbox:
|
||||
cfg = getattr(settings, 'ALIPAY_SANDBOX_CFG', {})
|
||||
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': order_notify,
|
||||
'NOTIFY_URL_CZJILU': czjilu_notify,
|
||||
'RETURN_URL': cfg.get('RETURN_URL', ''),
|
||||
'sandbox': sandbox,
|
||||
}
|
||||
|
||||
|
||||
def get_czjilu_notify_url(club_id=None):
|
||||
"""获取 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):
|
||||
"""生成 Czjilu 类充值的支付宝 wap.pay 二维码参数。
|
||||
|
||||
返回 {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(
|
||||
out_trade_no=dingdanid,
|
||||
amount=jine,
|
||||
subject=subject or '充值支付',
|
||||
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)
|
||||
return {
|
||||
'qr_code': pay_url,
|
||||
'qr_image': qr_image,
|
||||
'pay_method': 'alipay',
|
||||
}
|
||||
|
||||
|
||||
# ==================== 密钥加载 ====================
|
||||
|
||||
def _load_private_key(key_str):
|
||||
"""加载 RSA 私钥,兼容 PEM、PKCS#8 DER、PKCS#1、PKCS#8 多种格式。
|
||||
|
||||
沙箱私钥常为 PKCS#1 格式(裸 base64 前缀 MIIEowIBAAKCAQEA),
|
||||
生产私钥常为 PKCS#8 格式(裸 base64 前缀 MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcw),
|
||||
此处按顺序尝试多种加载方式以兼容两者。
|
||||
"""
|
||||
if isinstance(key_str, bytes):
|
||||
key_str = key_str.decode('utf-8')
|
||||
|
||||
key_str = key_str.strip()
|
||||
|
||||
# 1. 标准 PEM(含 -----BEGIN 头)
|
||||
if '-----BEGIN' in key_str:
|
||||
return serialization.load_pem_private_key(
|
||||
key_str.encode('utf-8'), password=None,
|
||||
)
|
||||
|
||||
raw = key_str.replace('\n', '').replace('\r', '').replace(' ', '')
|
||||
|
||||
# 2. PKCS#8 DER(裸 base64)
|
||||
try:
|
||||
der_bytes = base64.b64decode(raw)
|
||||
return serialization.load_der_private_key(der_bytes, password=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. PKCS#1 PEM 包装(-----BEGIN RSA PRIVATE KEY-----)
|
||||
pem_pkcs1 = _wrap_pem(raw, 'RSA PRIVATE KEY')
|
||||
try:
|
||||
return serialization.load_pem_private_key(pem_pkcs1.encode('utf-8'), password=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. PKCS#8 PEM 包装(-----BEGIN PRIVATE KEY-----)
|
||||
pem_pkcs8 = _wrap_pem(raw, 'PRIVATE KEY')
|
||||
return serialization.load_pem_private_key(pem_pkcs8.encode('utf-8'), password=None)
|
||||
|
||||
|
||||
def _load_public_key(key_str):
|
||||
"""加载支付宝公钥,兼容 PEM、X.509 DER 及 PEM 包装格式。
|
||||
|
||||
支付宝公钥标准为 X.509 SubjectPublicKeyInfo,裸 base64 前缀通常为
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A,load_der_public_key 原生支持;
|
||||
为稳妥起见追加 PEM 包装兜底。
|
||||
"""
|
||||
if not key_str:
|
||||
return None
|
||||
if isinstance(key_str, bytes):
|
||||
key_str = key_str.decode('utf-8')
|
||||
|
||||
key_str = key_str.strip()
|
||||
|
||||
# 1. 标准 PEM(含 -----BEGIN 头)
|
||||
if '-----BEGIN' in key_str:
|
||||
return serialization.load_pem_public_key(key_str.encode('utf-8'))
|
||||
|
||||
raw = key_str.replace('\n', '').replace('\r', '').replace(' ', '')
|
||||
|
||||
# 2. X.509 DER(裸 base64)
|
||||
try:
|
||||
der_bytes = base64.b64decode(raw)
|
||||
return serialization.load_der_public_key(der_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. PEM 包装兜底(-----BEGIN PUBLIC KEY-----)
|
||||
pem_pub = _wrap_pem(raw, 'PUBLIC KEY')
|
||||
return serialization.load_pem_public_key(pem_pub.encode('utf-8'))
|
||||
|
||||
|
||||
def _wrap_pem(raw_b64, label):
|
||||
"""将裸 base64 字符串包装为标准 PEM 格式(每行 64 字符)。"""
|
||||
lines = [raw_b64[i:i + 64] for i in range(0, len(raw_b64), 64)]
|
||||
return f'-----BEGIN {label}-----\n' + '\n'.join(lines) + f'\n-----END {label}-----'
|
||||
|
||||
|
||||
# ==================== 签名 / 验签 ====================
|
||||
|
||||
def _build_sign_content(params):
|
||||
"""构建待签名字符串:按 key 字典序排列,仅排除 sign,空值不参与。
|
||||
|
||||
注意:sign_type 必须参与签名(支付宝验签字符串包含 sign_type)。
|
||||
"""
|
||||
sorted_keys = sorted(k for k in params if k != 'sign')
|
||||
return '&'.join(f'{k}={params[k]}' for k in sorted_keys if params[k] != '')
|
||||
|
||||
|
||||
def sign_rsa2(content, private_key_str):
|
||||
"""RSA2 (SHA256WithRSA) 签名,返回 Base64 编码的签名字符串。"""
|
||||
private_key = _load_private_key(private_key_str)
|
||||
signature = private_key.sign(
|
||||
content.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return base64.b64encode(signature).decode('ascii')
|
||||
|
||||
|
||||
def verify_rsa2(content, sign_b64, public_key_str):
|
||||
"""使用支付宝公钥验证 RSA2 签名。"""
|
||||
public_key = _load_public_key(public_key_str)
|
||||
if public_key is None:
|
||||
return False
|
||||
try:
|
||||
signature = base64.b64decode(sign_b64)
|
||||
public_key.verify(
|
||||
signature,
|
||||
content.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning('支付宝验签失败: %s', e)
|
||||
return False
|
||||
|
||||
|
||||
def verify_notify_params(params, club_id=None):
|
||||
"""
|
||||
验证支付宝异步回调签名。
|
||||
params 为已解析的字典(来自 POST form 数据)。
|
||||
|
||||
注意:回调验签与请求签名规则不同!
|
||||
- 请求签名:sign_type 参与签名(只排除 sign)
|
||||
- 回调验签:sign_type 不参与签名(排除 sign 和 sign_type)
|
||||
支付宝官方文档:通知返回参数中除去 sign、sign_type 两个参数外,皆是待验签参数。
|
||||
"""
|
||||
cfg = get_alipay_config(club_id)
|
||||
sign = params.get('sign', '')
|
||||
if not sign:
|
||||
logger.warning('[ALIPAY_VERIFY_DEBUG] sign 为空')
|
||||
return False
|
||||
sign_type = params.get('sign_type', '')
|
||||
if sign_type and sign_type.upper() != 'RSA2':
|
||||
logger.warning(f'[ALIPAY_VERIFY_DEBUG] sign_type 非 RSA2: {sign_type}')
|
||||
return False
|
||||
# 回调验签:排除 sign 和 sign_type(支付宝官方规则)
|
||||
sorted_keys = sorted(k for k in params if k not in ('sign', 'sign_type'))
|
||||
sign_content = '&'.join(f'{k}={params[k]}' for k in sorted_keys if params[k] != '')
|
||||
logger.warning(f'[ALIPAY_VERIFY_DEBUG] sign_content(前300)={sign_content[:300]}')
|
||||
logger.warning(f'[ALIPAY_VERIFY_DEBUG] sign(前50)={sign[:50]}')
|
||||
result = verify_rsa2(sign_content, sign, cfg['ALIPAY_PUBLIC_KEY'])
|
||||
logger.warning(f'[ALIPAY_VERIFY_DEBUG] verify_rsa2 result={result}')
|
||||
return result
|
||||
|
||||
|
||||
# ==================== wap.pay 跳转 URL ====================
|
||||
|
||||
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 方式)。
|
||||
|
||||
参数:
|
||||
out_trade_no: 商户订单号(对应 Order.OrderID 或 Czjilu.dingdan_id)
|
||||
amount: 订单金额(Decimal 或字符串,单位元)
|
||||
subject: 订单标题
|
||||
body: 订单描述(可选)
|
||||
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 = {
|
||||
'out_trade_no': out_trade_no,
|
||||
'total_amount': str(amount),
|
||||
'subject': subject or '订单支付',
|
||||
'body': body or '',
|
||||
'product_code': 'QUICK_WAP_WAY',
|
||||
'timeout_express': f'{expire_minutes}m',
|
||||
}
|
||||
|
||||
params = {
|
||||
'app_id': cfg['APP_ID'],
|
||||
'method': 'alipay.trade.wap.pay',
|
||||
'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),
|
||||
'notify_url': notify_url or cfg['NOTIFY_URL'],
|
||||
'return_url': cfg['RETURN_URL'],
|
||||
}
|
||||
|
||||
# 签名
|
||||
sign_content = _build_sign_content(params)
|
||||
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
|
||||
|
||||
# 拼接 GET URL
|
||||
parts = []
|
||||
for k in sorted(params.keys()):
|
||||
parts.append(f'{k}={quote(str(params[k]), safe="")}')
|
||||
query_string = '&'.join(parts)
|
||||
|
||||
return f"{cfg['GATEWAY']}?{query_string}"
|
||||
|
||||
|
||||
# ==================== 当面付二维码(precreate)====================
|
||||
|
||||
def build_precreate_qr(out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30):
|
||||
"""
|
||||
调用 alipay.trade.precreate 生成当面付二维码链接。
|
||||
|
||||
与 wap.pay 不同:precreate 是服务器主动 POST 调用,返回 qr_code 链接,
|
||||
由商户自行生成二维码展示;用户用支付宝 APP 扫码完成支付。
|
||||
适用于微信小程序等无法跳转 alipay:// scheme 的环境。
|
||||
|
||||
返回:
|
||||
dict: {'qr_code': 'https://qr.alipay.com/...', 'out_trade_no': ...}
|
||||
失败抛异常。
|
||||
"""
|
||||
import requests
|
||||
|
||||
assert_alipay_allowed_for_club(club_id)
|
||||
cfg = get_alipay_config(club_id)
|
||||
|
||||
biz_content = {
|
||||
'out_trade_no': out_trade_no,
|
||||
'total_amount': str(amount),
|
||||
'subject': subject or '订单支付',
|
||||
'body': body or '',
|
||||
'timeout_express': f'{expire_minutes}m',
|
||||
}
|
||||
|
||||
params = {
|
||||
'app_id': cfg['APP_ID'],
|
||||
'method': 'alipay.trade.precreate',
|
||||
'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),
|
||||
'notify_url': cfg['NOTIFY_URL'],
|
||||
}
|
||||
|
||||
# 签名(sign_type 参与签名,与 wap.pay 一致)
|
||||
sign_content = _build_sign_content(params)
|
||||
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
|
||||
|
||||
# 服务器主动调用网关
|
||||
resp = requests.post(cfg['GATEWAY'], data=params, timeout=10)
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception:
|
||||
raise Exception(f"支付宝 precreate 返回非 JSON: {resp.text[:500]}")
|
||||
|
||||
biz_resp = result.get('alipay_trade_precreate_response', {})
|
||||
code = biz_resp.get('code', '')
|
||||
|
||||
if code == '10000':
|
||||
return {
|
||||
'qr_code': biz_resp.get('qr_code', ''),
|
||||
'out_trade_no': biz_resp.get('out_trade_no', out_trade_no),
|
||||
}
|
||||
|
||||
# 失败:拼接完整错误信息
|
||||
sub_code = biz_resp.get('sub_code', '')
|
||||
sub_msg = biz_resp.get('sub_msg', '')
|
||||
raise Exception(
|
||||
f"支付宝预下单失败: code={code}, msg={biz_resp.get('msg', '')}, "
|
||||
f"sub_code={sub_code}, sub_msg={sub_msg}"
|
||||
)
|
||||
|
||||
|
||||
def qr_link_to_base64(qr_link):
|
||||
"""
|
||||
把二维码链接内容转成 PNG base64 data URI(供前端 image 直接展示)。
|
||||
依赖 qrcode + Pillow;缺失时抛 ImportError 提示安装。
|
||||
"""
|
||||
import io
|
||||
import base64
|
||||
try:
|
||||
import qrcode
|
||||
except ImportError:
|
||||
raise ImportError("缺少 qrcode 库,请执行: pip install qrcode[pil]")
|
||||
|
||||
img = qrcode.make(qr_link)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
return 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('ascii')
|
||||
|
||||
|
||||
# ==================== 辅助 ====================
|
||||
|
||||
def club_id_from_order(order_id):
|
||||
"""从订单反查 club_id(与 wechat_pay 一致,暂回落默认)。"""
|
||||
from orders.models import Order
|
||||
try:
|
||||
row = Order.query.filter(OrderID=order_id).first()
|
||||
if row and getattr(row, 'ClubID', None):
|
||||
return row.ClubID
|
||||
except Exception:
|
||||
pass
|
||||
return CLUB_ID_DEFAULT
|
||||
|
||||
|
||||
def club_id_from_czjilu(dingdan_id):
|
||||
"""从 Czjilu 充值单反查 club_id(支付宝 czjilu 回调用)。"""
|
||||
from products.models import Czjilu
|
||||
try:
|
||||
row = Czjilu.query.filter(dingdan_id=dingdan_id).first()
|
||||
if row and getattr(row, 'club_id', None):
|
||||
return row.club_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 ''
|
||||
|
||||
18
jituan/services/api_compat.py
Normal file
18
jituan/services/api_compat.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""老小程序兼容:统一 JSON 错误格式(勿改前端时靠后端适配)。"""
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
def legacy_miniapp_error(code, message, http_status=status.HTTP_200_OK):
|
||||
"""
|
||||
业务错误返回 HTTP 200 + body.code。
|
||||
老端 request.js 会把 HTTP 403 当成「请先登录」,导致只显示「注册失败」。
|
||||
同时写 message 与 msg,兼容 dashouzhuce / wdlyhdl 两种字段名。
|
||||
"""
|
||||
text = message or '操作失败'
|
||||
return Response({
|
||||
'code': code,
|
||||
'message': text,
|
||||
'msg': text,
|
||||
'data': None,
|
||||
}, status=http_status)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""俱乐部维度财务统计(供 /jituan/houtai/caiwu,旧 /houtai/caiwu 不变)。"""
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.db.models import Count, Q, Sum
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.services.club_context import (
|
||||
@@ -61,31 +61,35 @@ def build_caiwu_payload(request):
|
||||
today_income_amount, today_income_count = today_income_for_dashboard(today, request)
|
||||
today_payout_amount, today_payout_count = sum_payout_stat_for_date(today, request)
|
||||
|
||||
today_orders = order_qs.filter(
|
||||
# 合并订单统计:6 次独立 count/aggregate → 1 次 aggregate
|
||||
_today_order_agg = order_qs.filter(
|
||||
CreateTime__gte=today_start,
|
||||
CreateTime__lt=today_end,
|
||||
Status__in=[1, 2, 3, 4, 5, 6, 7, 8],
|
||||
).aggregate(
|
||||
today_order_count=Count('id'),
|
||||
today_order_amount=Sum('Amount'),
|
||||
today_completed_count=Count('id', filter=Q(Status=3)),
|
||||
today_completed_amount=Sum('Amount', filter=Q(Status=3)),
|
||||
today_tuikuan_count=Count('id', filter=Q(Status=5)),
|
||||
today_tuikuan_amount=Sum('Amount', filter=Q(Status=5)),
|
||||
)
|
||||
today_order_count = today_orders.count()
|
||||
today_order_amount = float(today_orders.aggregate(total=Sum('Amount'))['total'] or 0.00)
|
||||
today_order_count = _today_order_agg['today_order_count'] or 0
|
||||
today_order_amount = float(_today_order_agg['today_order_amount'] or 0.00)
|
||||
today_completed_count = _today_order_agg['today_completed_count'] or 0
|
||||
today_completed_amount = float(_today_order_agg['today_completed_amount'] or 0.00)
|
||||
today_tuikuan_count = _today_order_agg['today_tuikuan_count'] or 0
|
||||
today_tuikuan_amount = float(_today_order_agg['today_tuikuan_amount'] or 0.00)
|
||||
|
||||
today_completed = order_qs.filter(
|
||||
CreateTime__gte=today_start, CreateTime__lt=today_end, Status=3,
|
||||
)
|
||||
today_completed_count = today_completed.count()
|
||||
today_completed_amount = float(today_completed.aggregate(total=Sum('Amount'))['total'] or 0.00)
|
||||
|
||||
today_tuikuan = order_qs.filter(
|
||||
CreateTime__gte=today_start, CreateTime__lt=today_end, Status=5,
|
||||
)
|
||||
today_tuikuan_count = today_tuikuan.count()
|
||||
today_tuikuan_amount = float(today_tuikuan.aggregate(total=Sum('Amount'))['total'] or 0.00)
|
||||
|
||||
today_chongzhi = cz_qs.filter(
|
||||
# 合并充值统计:2 次独立 count/aggregate → 1 次 aggregate
|
||||
_today_chongzhi_agg = cz_qs.filter(
|
||||
CreateTime__gte=today_start, CreateTime__lt=today_end, leixing=1, zhuangtai=3,
|
||||
).aggregate(
|
||||
count=Count('id'),
|
||||
total=Sum('jine'),
|
||||
)
|
||||
today_chongzhi_count = today_chongzhi.count()
|
||||
today_chongzhi_amount = float(today_chongzhi.aggregate(total=Sum('jine'))['total'] or 0.00)
|
||||
today_chongzhi_count = _today_chongzhi_agg['count'] or 0
|
||||
today_chongzhi_amount = float(_today_chongzhi_agg['total'] or 0.00)
|
||||
|
||||
new_huiyuan_user_ids = hy_qs.filter(
|
||||
CreateTime__gte=today_start, CreateTime__lt=today_end,
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user