Compare commits

..

196 Commits

Author SHA1 Message Date
jeremygan2021
0274e59fd9 score
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 15:51:59 +08:00
jeremygan2021
8bc06b0423 score
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-20 15:34:09 +08:00
jeremygan2021
98baa92e98 score
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 15:22:51 +08:00
jeremygan2021
06afd11f1c dimension——id
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 15:16:58 +08:00
jeremygan2021
4de4ff91f3 dimension——id
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-20 14:59:51 +08:00
jeremygan2021
b39e500307 new
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-20 14:23:18 +08:00
jeremygan2021
07006d46d9 new
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-20 14:08:51 +08:00
jeremygan2021
76bb5945ac new
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-20 13:54:51 +08:00
jeremygan2021
d76b5845a1 new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 13:51:44 +08:00
jeremygan2021
76f7b2bcbe new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-20 13:39:17 +08:00
jeremygan2021
c62c5b98ea new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 13:27:54 +08:00
jeremygan2021
0d7ba5d87c 算法
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-20 13:20:09 +08:00
jeremygan2021
98db4d6f75 算法
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-03-20 13:13:07 +08:00
jeremygan2021
02335d26c2 mode bug
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-18 00:38:58 +08:00
jeremygan2021
da235c3a82 mode bug
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-18 00:32:04 +08:00
jeremygan2021
f25c35af40 mode bug
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-17 23:56:19 +08:00
jeremygan2021
465ea34dcd audo url
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 23:48:08 +08:00
jeremygan2021
bd102cc71f peer review
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-17 22:32:28 +08:00
jeremygan2021
6a166c50eb new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-17 22:30:43 +08:00
jeremygan2021
75dbf22a43 new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-17 22:22:58 +08:00
jeremygan2021
7695ac3edf 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-17 21:43:33 +08:00
jeremygan2021
3d94a1f0de 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 21:36:12 +08:00
jeremygan2021
f72293eb76 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 21:22:38 +08:00
jeremygan2021
35d96588f9 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-17 21:15:48 +08:00
jeremygan2021
afab4933b4 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 20s
2026-03-17 21:11:19 +08:00
jeremygan2021
4d6f98080e admin 手机和用户名
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 20:59:24 +08:00
jeremygan2021
de1e409447 admin phone serch
All checks were successful
Deploy to Server / deploy (push) Successful in 1m53s
2026-03-17 19:21:32 +08:00
jeremygan2021
6aaddfbe9e 打分上传后台
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-12 13:49:47 +08:00
jeremygan2021
f23e477f57 打分上传后台
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-03-12 13:47:21 +08:00
jeremygan2021
1f693e0e8a 打分上传后台
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-12 13:34:47 +08:00
jeremygan2021
6129673ddc debug
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-12 12:36:25 +08:00
jeremygan2021
8b6773bb98 commit 2026-03-11 23:07:35 +08:00
jeremygan2021
d28ecf98ea commit
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-03-11 23:04:37 +08:00
jeremygan2021
8b11d0aab1 commit
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-03-11 22:56:03 +08:00
jeremygan2021
dbd752b833 commit
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-03-11 22:47:35 +08:00
jeremygan2021
5a87105ec9 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 22:35:37 +08:00
jeremygan2021
c32522857e tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-11 22:28:19 +08:00
jeremygan2021
c1fadf1344 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 22:25:58 +08:00
jeremygan2021
cb10c42d11 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-11 22:15:12 +08:00
jeremygan2021
758eee8ac6 tingwu_new 2026-03-11 22:10:17 +08:00
jeremygan2021
809aab9e02 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 21:58:09 +08:00
jeremygan2021
a346872a99 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 21:56:28 +08:00
jeremygan2021
373a82151f tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-11 21:45:28 +08:00
jeremygan2021
f14d52f69b tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 20s
2026-03-11 21:40:15 +08:00
jeremygan2021
791afa52eb tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-11 21:21:38 +08:00
jeremygan2021
2b7f0a6317 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-11 21:17:35 +08:00
jeremygan2021
071970e043 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-11 21:11:43 +08:00
jeremygan2021
599b3cded7 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-11 21:08:47 +08:00
jeremygan2021
852bc74bc1 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 21:01:28 +08:00
jeremygan2021
2c17e3bcd7 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-11 20:53:33 +08:00
jeremygan2021
9fac4c222e tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 20s
2026-03-11 20:52:15 +08:00
jeremygan2021
7612c09571 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 1m8s
2026-03-11 20:46:25 +08:00
jeremygan2021
f41fd01367 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-11 20:41:49 +08:00
jeremygan2021
b0aa902f89 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 21s
2026-03-11 20:31:09 +08:00
jeremygan2021
44d90e643f tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-11 20:17:38 +08:00
jeremygan2021
504db66b0b tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-11 20:12:14 +08:00
jeremygan2021
b0e97ed140 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-11 20:07:46 +08:00
jeremygan2021
59bd66459a tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-11 15:27:02 +08:00
jeremygan2021
886ffec374 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 14s
2026-03-11 15:24:39 +08:00
jeremygan2021
60423c4323 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-11 15:22:05 +08:00
jeremygan2021
188f1fd22d tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 14s
2026-03-11 15:19:37 +08:00
jeremygan2021
aa2d96b242 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-11 15:17:15 +08:00
jeremygan2021
926a9e7b5f tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 5s
2026-03-11 15:10:40 +08:00
jeremygan2021
290b345404 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 15:07:33 +08:00
jeremygan2021
ea1a5e8c59 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-03-11 15:03:02 +08:00
jeremygan2021
7e3600a6d2 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-03-11 15:01:02 +08:00
jeremygan2021
5b29396830 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-11 14:59:32 +08:00
jeremygan2021
6d5be796cd tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 13s
2026-03-11 14:55:37 +08:00
jeremygan2021
84e36bdd02 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 14:53:33 +08:00
jeremygan2021
d7f9d7ed8b tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-11 14:43:06 +08:00
jeremygan2021
c750dce569 tingwu
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-11 14:39:11 +08:00
jeremygan2021
26f9192ce5 tingwu
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 14:38:32 +08:00
jeremygan2021
860253bf40 tingwu
All checks were successful
Deploy to Server / deploy (push) Successful in 58s
2026-03-11 14:36:06 +08:00
jeremygan2021
1a30da74cf tingwu 2026-03-11 14:31:17 +08:00
jeremygan2021
6361b7a522 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-03-10 14:25:04 +08:00
jeremygan2021
03297f3d07 比赛 2026-03-10 14:11:43 +08:00
jeremygan2021
b74d0826ee 比赛 2026-03-10 14:11:39 +08:00
jeremygan2021
880192c358 比赛 2026-03-10 14:05:37 +08:00
jeremygan2021
3d74ccc04f 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-03-10 13:47:28 +08:00
jeremygan2021
af763b1bee 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-03-10 13:32:04 +08:00
jeremygan2021
3ada996915 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-03-10 12:35:41 +08:00
jeremygan2021
417cda952d 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-03-10 11:09:15 +08:00
jeremygan2021
724dd3857c 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-03-10 10:46:13 +08:00
jeremygan2021
00389e0709 比赛
All checks were successful
Deploy to Server / deploy (push) Successful in 7m23s
2026-03-10 10:37:56 +08:00
jeremygan2021
29e18e1288 n
Some checks failed
Deploy to Server / deploy (push) Has been cancelled
2026-03-10 10:34:56 +08:00
jeremygan2021
9916a6f4da 活动发布
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-03-04 13:36:25 +08:00
jeremygan2021
f4063096ab 活动发布
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-03-04 13:29:14 +08:00
jeremygan2021
1ebcc1d1e1 活动发布
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-03-04 13:26:05 +08:00
jeremygan2021
f4a50196d4 活动发布
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-03-04 13:20:16 +08:00
jeremygan2021
0e5f6ea592 活动发布
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-04 13:16:58 +08:00
jeremygan2021
064fe09318 活动发布
All checks were successful
Deploy to Server / deploy (push) Successful in 14s
2026-03-04 13:14:45 +08:00
jeremygan2021
d3dbaaa090 活动发布
All checks were successful
Deploy to Server / deploy (push) Successful in 1m44s
2026-03-04 13:07:54 +08:00
jeremygan2021
96ebd781b9 hart
All checks were successful
Deploy to Server / deploy (push) Successful in 29s
2026-03-02 20:37:02 +08:00
jeremygan2021
df1f9be029 hart
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-03-02 20:33:40 +08:00
jeremygan2021
2ef1771be0 heart
All checks were successful
Deploy to Server / deploy (push) Successful in 38s
2026-03-02 20:28:16 +08:00
jeremygan2021
e306ac6f61 wx登录
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-03-02 16:49:00 +08:00
jeremygan2021
f0c62eb57c wx登录
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-03-02 16:47:40 +08:00
jeremygan2021
5b4643c8f9 finish
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-03-01 18:18:46 +08:00
jeremygan2021
2c5ec64f8f 代码支持
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-03-01 17:40:31 +08:00
jeremygan2021
84e30d26af 代码支持
Some checks failed
Deploy to Server / deploy (push) Has been cancelled
2026-03-01 17:39:36 +08:00
jeremygan2021
b31e8fff09 debug
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-28 13:01:02 +08:00
jeremygan2021
b778fbb923 debug
All checks were successful
Deploy to Server / deploy (push) Successful in 14s
2026-02-28 11:51:29 +08:00
jeremygan2021
a280da80a7 csv
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-28 11:47:27 +08:00
jeremygan2021
a5a7e1e03a csv
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-28 11:39:40 +08:00
jeremygan2021
1b5751a065 csv
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-28 11:36:27 +08:00
jeremygan2021
d5ad54e380 csv
All checks were successful
Deploy to Server / deploy (push) Successful in 33s
2026-02-28 11:33:16 +08:00
jeremygan2021
dba9d4f724 csv
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-28 11:28:35 +08:00
jeremygan2021
4f4cfcd6f4 小程序登录
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-27 16:33:26 +08:00
jeremygan2021
53bf74893b t
All checks were successful
Deploy to Server / deploy (push) Successful in 40s
2026-02-27 15:24:20 +08:00
jeremygan2021
ccf978fbcc t 2026-02-27 15:19:54 +08:00
jeremygan2021
93ad681689 审核
All checks were successful
Deploy to Server / deploy (push) Successful in 39s
2026-02-27 14:44:04 +08:00
jeremygan2021
a58fc40e10 video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-02-27 14:30:56 +08:00
jeremygan2021
9123b929fd video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-27 14:26:54 +08:00
jeremygan2021
4f632defde video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-27 14:22:02 +08:00
jeremygan2021
27dcbef7d5 video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-27 14:18:18 +08:00
jeremygan2021
a47be29bf1 video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-27 14:09:11 +08:00
jeremygan2021
f9c104452b video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-27 13:54:22 +08:00
jeremygan2021
b58dc38a2b video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-27 13:49:57 +08:00
jeremygan2021
357bd75f24 video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-27 13:47:32 +08:00
jeremygan2021
f57edbd4ee debug
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-26 18:55:46 +08:00
jeremygan2021
6571d83912 debug
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-02-26 18:53:37 +08:00
jeremygan2021
6fee334ebf 小程序适配
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-26 15:32:45 +08:00
jeremygan2021
6910582b18 小程序适配
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-26 15:25:49 +08:00
jeremygan2021
1f1c92f03f 小程序适配
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-26 15:18:15 +08:00
jeremygan2021
591246e82a 小程序适配
All checks were successful
Deploy to Server / deploy (push) Successful in 29s
2026-02-26 15:14:39 +08:00
jeremygan2021
9215ec3b42 小程序适配
All checks were successful
Deploy to Server / deploy (push) Successful in 58s
2026-02-26 15:10:52 +08:00
jeremygan2021
66cfbdd75b px
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-25 01:33:08 +08:00
jeremygan2021
cf063707a3 px
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-25 01:28:08 +08:00
jeremygan2021
8298eb6add 排序
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-25 00:53:01 +08:00
jeremygan2021
05299060dc 排序
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-25 00:47:50 +08:00
jeremygan2021
96c12b9e58 new
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-25 00:33:34 +08:00
jeremygan2021
21f01fb0c4 new
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-25 00:28:31 +08:00
jeremygan2021
5916d7eb3a new
All checks were successful
Deploy to Server / deploy (push) Successful in 38s
2026-02-25 00:22:15 +08:00
jeremygan2021
15a2d66eae 解决登录问题
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-24 19:48:41 +08:00
jeremygan2021
4fe3351393 排序
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-24 19:37:17 +08:00
jeremygan2021
8b78deeb5a 排序
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-24 18:10:17 +08:00
jeremygan2021
46cf1727e1 专家
All checks were successful
Deploy to Server / deploy (push) Successful in 35s
2026-02-24 17:52:12 +08:00
jeremygan2021
aac110ba1e 专家头像
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-24 17:07:16 +08:00
jeremygan2021
fe0e0fa1ae 专家头像
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-02-24 17:04:40 +08:00
jeremygan2021
c5ac3e1445 frum
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-24 17:00:31 +08:00
jeremygan2021
bccbec4bb1 frum
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-24 16:40:59 +08:00
jeremygan2021
c07f7028fc markdown2
All checks were successful
Deploy to Server / deploy (push) Successful in 38s
2026-02-24 16:32:52 +08:00
jeremygan2021
009a2a59f3 markdown1
All checks were successful
Deploy to Server / deploy (push) Successful in 3m29s
2026-02-24 16:23:39 +08:00
jeremygan2021
139a484279 markdown1 2026-02-24 16:23:15 +08:00
jeremygan2021
0fa0fc615d debug
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-02-24 16:12:54 +08:00
jeremygan2021
9dff3c074d 活动倒计时 2026-02-24 16:12:06 +08:00
jeremygan2021
6161716d68 活动倒计时
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-24 16:10:19 +08:00
jeremygan2021
fd33201793 移动端
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-02-24 16:09:07 +08:00
jeremygan2021
76ce1225ec mini
All checks were successful
Deploy to Server / deploy (push) Successful in 39s
2026-02-24 00:35:52 +08:00
jeremygan2021
441e080328 mini
All checks were successful
Deploy to Server / deploy (push) Successful in 40s
2026-02-24 00:31:57 +08:00
jeremygan2021
0d01a5f2a8 VC课程页面
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-02-23 23:34:54 +08:00
jeremygan2021
3f363dbd8e 移动端适配
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-23 18:01:56 +08:00
jeremygan2021
52b16911b1 移动端适配
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-23 17:48:52 +08:00
jeremygan2021
cb66dd92c3 sms 活动短信
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-23 17:39:01 +08:00
jeremygan2021
21c1d6a22a sms 活动短信
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-23 17:36:25 +08:00
jeremygan2021
6b1fd43ec6 sms 活动短信
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-02-23 17:22:10 +08:00
jeremygan2021
204ec48933 sms 活动短信
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-23 17:15:53 +08:00
jeremygan2021
5095e27391 admin自动审核 2026-02-23 17:15:29 +08:00
jeremygan2021
e59ae5abbe admin自动审核
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-23 17:06:40 +08:00
jeremygan2021
1a003d93cf admin自动审核
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-02-23 17:00:35 +08:00
jeremygan2021
ea500391e8 admin自动审核
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-23 16:54:12 +08:00
jeremygan2021
2b2f586894 admin自动审核
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-23 16:41:37 +08:00
jeremygan2021
84a1428dba admin自动审核
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-23 16:39:38 +08:00
jeremygan2021
799965ee74 解决报名支付
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-23 16:31:34 +08:00
jeremygan2021
58176c6651 解决报名支付
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-23 16:20:34 +08:00
jeremygan2021
6bbbb49d90 解决报名支付
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-02-23 16:02:09 +08:00
jeremygan2021
0bf5f94483 报名表单
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-02-23 15:39:47 +08:00
jeremygan2021
c3fab398bb 报名表单
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-23 15:28:41 +08:00
jeremygan2021
c7e75de8be 报名表单
All checks were successful
Deploy to Server / deploy (push) Successful in 41s
2026-02-23 15:20:28 +08:00
jeremygan2021
059b57d86d 报名表单
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
2026-02-23 15:14:17 +08:00
jeremygan2021
ae91ac47c2 报名表单
All checks were successful
Deploy to Server / deploy (push) Successful in 37s
2026-02-23 15:08:43 +08:00
jeremygan2021
db7a3bd000 报名表单 2026-02-23 15:07:55 +08:00
jeremygan2021
6a391c5eab 报名表单
All checks were successful
Deploy to Server / deploy (push) Successful in 40s
2026-02-23 14:56:44 +08:00
jeremygan2021
84385488ae 小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-17 11:44:41 +08:00
jeremygan2021
1555bec154 小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-17 11:41:23 +08:00
jeremygan2021
094f9032c5 小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 44s
2026-02-17 11:34:57 +08:00
jeremygan2021
818b51a7ba 小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-17 11:32:44 +08:00
jeremygan2021
db401a7103 小程序分销 2026-02-17 11:32:16 +08:00
jeremygan2021
315f461a20 小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-17 11:25:03 +08:00
jeremygan2021
7114a33d1b 小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-17 11:22:50 +08:00
jeremygan2021
ac61a127ae 小程序分销
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-17 11:14:58 +08:00
jeremygan2021
321c57bee2 小程序转发
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-17 11:07:16 +08:00
jeremygan2021
eb3655acd1 debug
All checks were successful
Deploy to Server / deploy (push) Successful in 35s
2026-02-16 20:31:51 +08:00
jeremygan2021
b2f9545fdd sms
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-16 20:15:26 +08:00
jeremygan2021
91d82b78b5 sms
All checks were successful
Deploy to Server / deploy (push) Successful in 3s
2026-02-16 19:59:45 +08:00
jeremygan2021
481a1d24f0 pay
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-02-14 12:31:21 +08:00
jeremygan2021
a5527e8312 pay
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-14 12:27:49 +08:00
jeremygan2021
f7c033021e pay
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-14 12:22:22 +08:00
jeremygan2021
8edbcdf06f pay
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-14 12:17:52 +08:00
3df12af67d finish
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-14 00:30:32 +08:00
2afa3db780 new
All checks were successful
Deploy to Server / deploy (push) Successful in 25s
2026-02-14 00:22:58 +08:00
c92633279d new
All checks were successful
Deploy to Server / deploy (push) Successful in 24s
2026-02-14 00:20:44 +08:00
4a96a0c2e4 order
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-02-14 00:17:43 +08:00
fd1faf002a new
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-02-14 00:11:45 +08:00
f13d921625 order
All checks were successful
Deploy to Server / deploy (push) Successful in 46s
2026-02-14 00:09:06 +08:00
84e49740f3 new
All checks were successful
Deploy to Server / deploy (push) Successful in 35s
2026-02-13 23:59:02 +08:00
185 changed files with 15753 additions and 1146 deletions

View File

@@ -6,7 +6,8 @@ jobs:
runs-on: ubuntu runs-on: ubuntu
steps: steps:
- name: Deploy using SSH - name: Deploy using SSH
uses: appleboy/ssh-action@v1.0.3 # 使用 Gitea 官方镜像源,加速国内访问
uses: https://gitea.com/actions/appleboy-ssh-action@v1.0.3
with: with:
host: 6.6.6.66 host: 6.6.6.66
username: quant username: quant
@@ -22,22 +23,39 @@ jobs:
exit 1 exit 1
} }
# 2. 停止并移除 Docker 容器 # 2. 停止并移除 Docker 容器及镜像
echo -e "\n===== 停止 Docker 容器 =====" echo -e "\n===== 停止并清理 Docker ====="
# 移除 --rmi all保留镜像缓存加快构建速度同时避免误删基础镜像
echo $SUDO_PASSWORD | sudo -S docker compose down echo $SUDO_PASSWORD | sudo -S docker compose down
# 3. 删除 Docker 镜像 # 3. 拉取 Git 最新代码
echo -e "\n===== 删除 Docker 镜像 ====="
echo $SUDO_PASSWORD | sudo -S docker compose down --rmi all
# 4. 拉取 Git 最新代码
echo -e "\n===== 拉取 Git 代码 =====" echo -e "\n===== 拉取 Git 代码 ====="
git pull || { # 尝试拉取,如果失败则强制重置,增强鲁棒性
echo "警告Git pull 失败(可能是本地有未提交的修改),脚本继续执行..." if ! git pull; then
} echo "警告Git pull 失败,尝试强制同步远程代码..."
git fetch --all
# 获取当前分支名并重置
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
git reset --hard origin/$CURRENT_BRANCH
git pull
fi
# 5. 重新启动 Docker 容器 # 3.1 创建/更新 .env 文件 (从本地环境变量注入)
echo -e "\n===== 配置环境变量 ====="
cat > backend/.env <<EOF
# Aliyun OSS Configuration
ALIYUN_ACCESS_KEY_ID=LTAI5tE62GW8MKyoEaotzxXk
ALIYUN_ACCESS_KEY_SECRET=Zdzqo1fgj57DxxioXOotNKhJdSfVQW
ALIYUN_OSS_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com
ALIYUN_OSS_BUCKET_NAME=tangledup-ai-staging
ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com
# Aliyun Tingwu Configuration
ALIYUN_TINGWU_APP_KEY=6eOX7N3tKE0fDwb
DASHSCOPE_API_KEY=sk-84e9eef24a274f568d4fa15c97556c9f
EOF
# 4. 重新启动 Docker 容器
echo -e "\n===== 启动 Docker 容器 =====" echo -e "\n===== 启动 Docker 容器 ====="
echo $SUDO_PASSWORD | sudo -S docker compose up -d echo $SUDO_PASSWORD | sudo -S docker compose up -d --build
echo -e "\n===== 操作完成!=====" echo -e "\n===== 操作完成!====="

237
README 2.md Normal file
View File

@@ -0,0 +1,237 @@
# Quant Speed Market (量迹市场)
> 一个集成了电商、社区论坛、AI 服务与 AR/3D 模型展示的全栈应用平台。
![Project Logo](frontend/public/liangji_logo.svg)
## 📖 项目简介
npm run dev:weapp
Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨在为用户提供从商品购买、技术交流到 AI 工具使用的全方位体验。项目采用前后端分离架构,包含 Django 后端 API、React Web 管理端以及 Taro 微信小程序客户端。
## ✨ 功能特性
- **🛍️ 电商商城**:支持商品浏览、购物车、微信支付 (WeChat Pay V3)、订单管理。
- **💬 社区论坛**:支持发帖、回帖、话题分类、富文本编辑。
- **🤖 AI 服务**:集成 AI 工具,提供智能辅助服务。
- **🕶️ AR/3D 展示**:基于 Three.js 的 3D 模型预览与 AR 交互体验。
- **📱 多端适配**微信小程序原生体验Web 端响应式管理后台。
- **🔒 安全认证**微信一键登录、手机号绑定、JWT 认证。
## 🛠️ 技术栈与依赖
### Backend (后端)
- **Framework**: Django 6.0 + Django REST Framework 3.16
- **Database**: PostgreSQL (psycopg2)
- **Payment**: WeChat Pay V3 (wechatpayv3)
- **Documentation**: drf-spectacular (OpenAPI 3.0)
- **Deployment**: Docker, Gunicorn
### Frontend (Web 端)
- **Core**: React 19 + Vite 7
- **UI Library**: Ant Design 6
- **3D Engine**: Three.js + @react-three/fiber
- **Routing**: React Router v7
### Miniprogram (小程序)
- **Framework**: Taro 3.6 (React Flavor)
- **UI Library**: Taro UI
- **Styles**: SCSS
- **Platform**: WeChat Mini Program (可扩展至 H5/Alipay 等)
## 🚀 本地开发环境搭建
### 1. 系统要求
- **Node.js**: >= 18.0.0
- **Python**: >= 3.10
- **PostgreSQL**: >= 13
- **WeChat DevTools**: 最新版 (用于小程序开发)
### 2. 克隆仓库
```bash
git clone <repository-url>
cd market_page
```
### 3. 后端环境配置 (Backend)
```bash
cd backend
# 创建虚拟环境 (推荐)
python -m venv venv
# Windows 激活
venv\Scripts\activate
# macOS/Linux 激活
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 数据库迁移
python manage.py migrate
# 创建超级用户
python manage.py createsuperuser
# 启动开发服务器 (默认端口 8000)
python manage.py runserver
```
### 4. Web 前端配置 (Frontend)
```bash
cd ../frontend
# 安装依赖
npm install
# 启动开发服务器 (默认端口 5173)
npm run dev
```
### 5. 小程序配置 (Miniprogram)
```bash
cd ../miniprogram
# 安装依赖
npm install
# 编译并监听 (微信小程序)
npm run dev:weapp
```
*启动后,请打开微信开发者工具,导入 `miniprogram` 目录进行预览。*
## 📦 构建与运行
### Backend
```bash
# 收集静态文件
python manage.py collectstatic --noinput
# 使用 Gunicorn 运行 (生产环境)
gunicorn config.wsgi:application --bind 0.0.0.0:8000
```
### Frontend
```bash
# 构建生产版本
npm run build
# 预览构建产物
npm run preview
```
### Miniprogram
```bash
# 构建生产版本 (微信小程序)
npm run build:weapp
```
## 🧪 测试与覆盖率
### Backend
```bash
# 运行所有测试
python manage.py test
# 运行特定模块测试
python manage.py test shop.tests
```
### Frontend / Miniprogram
```bash
# 代码风格检查
npm run lint
```
## 🚢 部署指南
### Docker 部署 (推荐)
项目包含 `Dockerfile``docker-compose.yml` (根目录下),可一键启动。
```bash
# 在项目根目录
docker-compose up -d --build
```
*注意:请确保已在 `backend/config/settings.py` 或环境变量中配置好生产环境的数据库连接和密钥。*
## 🔌 API 接口示例
后端提供 RESTful API以下为核心接口示例
| 方法 | 路径 | 描述 |
| --- | --- | --- |
| POST | `/api/shop/wechat/login/` | 微信用户登录 (换取 JWT) |
| GET | `/api/shop/configs/` | 获取 ESP32/商品配置列表 |
| POST | `/api/shop/orders/` | 创建新订单 |
| POST | `/api/shop/pay/` | 发起微信支付 |
| GET | `/api/community/topics/` | 获取论坛话题列表 |
**API 文档**: 启动后端后访问 `http://localhost:8000/api/schema/swagger-ui/` 查看完整 Swagger 文档。
## 📂 目录结构说明
```
market_page/
├── backend/ # Django 后端源码
│ ├── community/ # 论坛社区模块
│ ├── shop/ # 电商与支付模块
│ ├── config/ # 项目核心配置
│ ├── uploads/ # 用户上传文件 (媒体资源)
│ ├── manage.py # Django 管理脚本
│ └── requirements.txt # Python 依赖
├── frontend/ # React Web 端源码
│ ├── src/
│ │ ├── components/ # 公共组件 (3D模型、弹窗等)
│ │ ├── pages/ # 页面路由 (Home, Forum, Payment)
│ │ └── assets/ # 静态资源
│ └── vite.config.js # Vite 配置
├── miniprogram/ # Taro 小程序源码
│ ├── src/
│ │ ├── pages/ # 小程序页面
│ │ ├── subpackages/ # 分包页面 (分销、论坛详情等)
│ │ └── components/ # 小程序组件
│ └── project.config.json # 微信小程序配置
└── docker-compose.yml # Docker 编排文件
```
## 🤝 贡献规范
欢迎提交 Pull Request请遵循以下规范
1. **分支管理**
- `main`: 主分支,保持稳定。
- `dev`: 开发分支。
- `feat/xxx`: 新功能分支。
- `fix/xxx`: Bug 修复分支。
2. **Commit 格式**
- `feat: 添加购物车功能`
- `fix: 修复支付回调失败问题`
- `docs: 更新 README`
- `style: 调整首页样式`
3. **PR 流程**
- Fork 本仓库。
- 创建特性分支。
- 提交代码并推送到您的 Fork。
- 提交 PR 至 `dev` 分支。
## ❓ 常见问题排查
- **Q: 后端启动报错 `psycopg2` 相关错误?**
- A: 请确保本地已安装 PostgreSQL 并且开发库 (`libpq-dev` 或 equivalent) 已就绪。
- **Q: 小程序报错 "appID 不合法"**
- A: 请在 `miniprogram/project.config.json` 中修改 `appid` 为您自己的测试 ID或在开发者工具中开启 "不校验合法域名"。
- **Q: 微信支付接口调用失败?**
- A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。
## 📜 许可证
本项目采用 [MIT License](LICENSE) 许可证。
## 📧 联系方式
- **作者**: (Your Name/Organization)
- **邮箱**: contact@example.com
- **项目主页**: https://github.com/yourusername/market-page

View File

@@ -10,12 +10,46 @@ Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨
## ✨ 功能特性 ## ✨ 功能特性
- **🛍️ 电商商城**:支持商品浏览、购物车、微信支付 (WeChat Pay V3)、订单管理。 ### 🛍️ 电商商城系统
- **💬 社区论坛**:支持发帖、回帖、话题分类、富文本编辑。 - **商品管理**ESP32硬件配置、库存管理、3D模型展示、产品特性标签
- **🤖 AI 服务**:集成 AI 工具,提供智能辅助服务。 - **订单管理**:多类型订单(硬件/课程/活动)、完整状态流转、物流跟踪
- **🕶️ AR/3D 展示**:基于 Three.js 的 3D 模型预览与 AR 交互体验。 - **支付系统**微信支付V3集成、多种支付方式、安全签名验证、支付回调处理
- **📱 多端适配**微信小程序原生体验Web 端响应式管理后台。 - **分销系统**二级分销体系、邀请机制、佣金计算一级10%/二级2%)、提现管理
- **🔒 安全认证**微信一键登录、手机号绑定、JWT 认证。 - **课程系统**:视频课程、固定时间课程、讲师管理、课程报名与咨询
### 💬 社区论坛系统
- **活动管理**:线上线下活动、报名表单自定义、支付状态同步、审核机制
- **论坛帖子**:技术讨论、求助问答、经验分享、官方公告四大分类
- **互动功能**:点赞、置顶、嵌套回复(楼中楼)、多媒体附件支持
- **公告系统**:时效控制、跳转链接、优先级排序、置顶功能
### 🤖 AI 服务系统
- **语音转写**:阿里云听悟集成、多格式音频支持、说话人分离、状态自动刷新
- **AI智能评估**多模型支持通义千问系列、模板化评估、0-100分制评分、详细评语生成
- **智能总结**:多类型总结(段落/对话/问答/思维导图、Markdown格式输出、异步生成机制
- **比赛集成**AI评委身份、评分维度映射、自动评分同步、人工干预支持
### 🏆 竞赛评审系统
- **比赛管理**:多状态流程(草稿→发布→报名→提交→评审→结束)、时间管理、可见性控制
- **项目管理**文件附件支持PPT/PDF/图片/视频)、封面展示、状态管理
- **评分系统**:多维度评分、权重配置、评委评语、防重复评分机制
- **权限控制**:选手/评委/嘉宾三角色体系、报名审核、角色权限管理
### 🕶️ AR/3D 展示
- **3D模型预览**基于Three.js的交互式3D模型展示
- **AR交互体验**:增强现实功能集成
- **多媒体支持**:图片、视频、文件等多格式媒体处理
### 📱 多端适配
- **微信小程序**Taro框架开发、原生小程序体验、分包优化
- **Web管理端**React + Ant Design、响应式设计、管理后台功能
- **跨平台支持**可扩展至H5、支付宝小程序等平台
### 🔒 安全认证
- **微信登录**小程序code换取session、OpenID/UnionID管理
- **手机验证**:验证码登录、手机号绑定、用户合并机制
- **JWT认证**Token-based身份验证、API访问控制
- **权限验证**:基于角色的访问控制、操作权限验证
## 🛠️ 技术栈与依赖 ## 🛠️ 技术栈与依赖
@@ -23,8 +57,11 @@ Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨
- **Framework**: Django 6.0 + Django REST Framework 3.16 - **Framework**: Django 6.0 + Django REST Framework 3.16
- **Database**: PostgreSQL (psycopg2) - **Database**: PostgreSQL (psycopg2)
- **Payment**: WeChat Pay V3 (wechatpayv3) - **Payment**: WeChat Pay V3 (wechatpayv3)
- **AI Services**: 阿里云听悟 (语音转写)、通义千问 (AI评估)
- **Cloud Storage**: 阿里云OSS (文件存储)
- **Documentation**: drf-spectacular (OpenAPI 3.0) - **Documentation**: drf-spectacular (OpenAPI 3.0)
- **Deployment**: Docker, Gunicorn - **Deployment**: Docker, Gunicorn
- **Authentication**: JWT + 微信OAuth2.0
### Frontend (Web 端) ### Frontend (Web 端)
- **Core**: React 19 + Vite 7 - **Core**: React 19 + Vite 7
@@ -164,6 +201,11 @@ docker-compose up -d --build
| POST | `/api/shop/orders/` | 创建新订单 | | POST | `/api/shop/orders/` | 创建新订单 |
| POST | `/api/shop/pay/` | 发起微信支付 | | POST | `/api/shop/pay/` | 发起微信支付 |
| GET | `/api/community/topics/` | 获取论坛话题列表 | | GET | `/api/community/topics/` | 获取论坛话题列表 |
| POST | `/api/ai/transcription/` | 创建语音转写任务 |
| GET | `/api/ai/transcription/{id}/` | 获取转写任务状态 |
| POST | `/api/competition/projects/` | 提交参赛项目 |
| GET | `/api/competition/projects/{id}/score/` | 获取项目评分 |
| POST | `/api/competition/scoring/` | 评委提交评分 |
**API 文档**: 启动后端后访问 `http://localhost:8000/api/schema/swagger-ui/` 查看完整 Swagger 文档。 **API 文档**: 启动后端后访问 `http://localhost:8000/api/schema/swagger-ui/` 查看完整 Swagger 文档。
@@ -172,25 +214,46 @@ docker-compose up -d --build
``` ```
market_page/ market_page/
├── backend/ # Django 后端源码 ├── backend/ # Django 后端源码
│ ├── ai_services/ # AI服务模块 (语音转写、AI评估)
│ │ ├── models.py # 转写任务、AI评估模板模型
│ │ ├── views.py # API接口 (转写、评估、总结)
│ │ └── services.py # 阿里云听悟、通义千问服务集成
│ ├── community/ # 论坛社区模块 │ ├── community/ # 论坛社区模块
│ │ ├── models.py # 活动、帖子、回复、公告模型
│ │ ├── views.py # 社区API接口
│ │ └── admin_actions.py # 后台管理动作
│ ├── competition/ # 竞赛评审模块
│ │ ├── models.py # 比赛、项目、评分、维度模型
│ │ ├── judge_views.py # 评委系统接口
│ │ └── templates/ # 评委系统前端页面
│ ├── shop/ # 电商与支付模块 │ ├── shop/ # 电商与支付模块
│ │ ├── models.py # 商品、订单、支付、用户模型
│ │ ├── services.py # 微信支付、短信服务
│ │ └── admin_actions.py # 订单管理动作
│ ├── config/ # 项目核心配置 │ ├── config/ # 项目核心配置
│ │ ├── settings.py # Django配置
│ │ └── urls.py # 主路由配置
│ ├── uploads/ # 用户上传文件 (媒体资源) │ ├── uploads/ # 用户上传文件 (媒体资源)
│ ├── manage.py # Django 管理脚本 │ ├── manage.py # Django 管理脚本
── requirements.txt # Python 依赖 ── requirements.txt # Python 依赖
│ └── Dockerfile # 后端容器配置
├── frontend/ # React Web 端源码 ├── frontend/ # React Web 端源码
│ ├── src/ │ ├── src/
│ │ ├── components/ # 公共组件 (3D模型、弹窗等) │ │ ├── components/ # 公共组件 (3D模型、弹窗等)
│ │ ├── pages/ # 页面路由 (Home, Forum, Payment) │ │ ├── pages/ # 页面路由 (Home, Forum, Payment)
│ │ ├── hooks/ # 自定义React Hooks
│ │ └── assets/ # 静态资源 │ │ └── assets/ # 静态资源
│ ├── public/ # 公共资源
│ └── vite.config.js # Vite 配置 │ └── vite.config.js # Vite 配置
├── miniprogram/ # Taro 小程序源码 ├── miniprogram/ # Taro 小程序源码
│ ├── src/ │ ├── src/
│ │ ├── pages/ # 小程序页面 │ │ ├── pages/ # 小程序页面
│ │ ├── subpackages/ # 分包页面 (分销、论坛详情等) │ │ ├── subpackages/ # 分包页面 (分销、论坛详情等)
│ │ ── components/ # 小程序组件 │ │ ── components/ # 小程序组件
│ │ └── utils/ # 工具函数
│ └── project.config.json # 微信小程序配置 │ └── project.config.json # 微信小程序配置
── docker-compose.yml # Docker 编排文件 ── docker-compose.yml # Docker 编排文件
└── README.md # 项目文档
``` ```
## 🤝 贡献规范 ## 🤝 贡献规范
@@ -226,6 +289,18 @@ market_page/
- **Q: 微信支付接口调用失败?** - **Q: 微信支付接口调用失败?**
- A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。 - A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。
- **Q: AI语音转写任务状态一直显示"处理中"**
- A: 检查阿里云听悟服务配置是否正确包括AccessKey、AppKey等参数。可通过`python manage.py check_aliyun_config`命令验证配置。
- **Q: AI评估功能无法正常使用**
- A: 确保通义千问API密钥已正确配置检查模型调用配额是否充足。评估模板中的提示词需要符合模型要求。
- **Q: 分销佣金没有正确计算?**
- A: 检查产品是否设置了独立分润比例,确认分销员状态为"正常",查看佣金日志了解具体计算过程。
- **Q: 竞赛项目无法提交?**
- A: 确认比赛状态为"作品提交中",检查是否已报名该比赛,确保每人每比赛只能提交一个项目。
## 📜 许可证 ## 📜 许可证
本项目采用 [MIT License](LICENSE) 许可证。 本项目采用 [MIT License](LICENSE) 许可证。

9
backend/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Aliyun OSS Configuration
ALIYUN_ACCESS_KEY_ID=LTAI5tE62GW8MKyoEaotzxXk
ALIYUN_ACCESS_KEY_SECRET=Zdzqo1fgj57DxxioXOotNKhJdSfVQW
ALIYUN_OSS_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com
ALIYUN_OSS_BUCKET_NAME=tangledup-ai-staging
ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com
# Aliyun Tingwu Configuration
ALIYUN_TINGWU_APP_KEY=6eOX7N3tKE0fDwb

54
backend/DEPLOY.md Normal file
View File

@@ -0,0 +1,54 @@
# 评委端系统部署说明
## 1. 系统概述
本系统为基于 Django 的后端渲染 HTML 评委端,提供评委登录、项目查看、打分点评、音频上传与 AI 服务管理功能。
## 2. 依赖环境
- Python 3.8+
- Django 3.2+
- Aliyun SDK (aliyun-python-sdk-core, aliyun-python-sdk-tingwu, oss2)
- requests
确保 `requirements.txt` 中包含以上依赖。
## 3. 环境变量
系统依赖以下环境变量(在 `backend/config/settings.py``.env` 文件中配置):
```bash
# 数据库配置
DB_NAME=your_db_name
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_HOST=your_db_host
DB_PORT=5432
# 阿里云配置 (用于音频上传与 AI 服务)
ALIYUN_ACCESS_KEY_ID=your_access_key_id
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
ALIYUN_OSS_BUCKET_NAME=your_bucket_name
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
ALIYUN_TINGWU_APP_KEY=your_tingwu_app_key
```
## 4. 启动脚本
使用提供的 `start_judge_system.sh` 启动服务。
```bash
chmod +x start_judge_system.sh
./start_judge_system.sh
```
该脚本将执行数据库迁移并启动 Django 开发服务器。生产环境建议使用 Gunicorn + Nginx。
## 5. 访问地址
- 评委端入口: `http://localhost:8000/competition/admin/` (自动跳转至登录或仪表盘)
- 评委端主页: `http://localhost:8000/judge/dashboard/`
- AI 管理页: `http://localhost:8000/judge/ai/manage/`
## 6. 审计日志
所有关键操作(登录、打分、上传、删除)均记录在项目根目录下的 `judge_audit.log` 文件中。格式如下:
`[YYYY-MM-DD HH:MM:SS] IP:127.0.0.1 | Phone:13800000000 | Action:LOGIN | Target:System | Result:SUCCESS | Details:...`
## 7. 注意事项
- 登录需使用已在后台绑定且角色为“评委”的手机号。
- 验证码在开发模式下通过控制台输出,或使用默认测试码 `8888`

View File

@@ -14,9 +14,13 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
# Copy project # Copy project
COPY . /app/ COPY . /app/
COPY .env /app/
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
# Volume for media files
VOLUME ["/app/media"]
# Run the application with gunicorn # Run the application with gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"] CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]

44
backend/TEST_REPORT.md Normal file
View File

@@ -0,0 +1,44 @@
# 评委端系统测试报告
## 1. 测试环境
- 系统版本: MacOS 14.5
- Python: 3.9
- Django: 3.2.20
- 数据库: PostgreSQL / SQLite (Development)
## 2. 功能测试
### 2.1 评委登录
- **场景**: 输入已绑定评委角色的手机号。
- **操作**: 点击“发送验证码”,输入控制台显示的验证码或默认测试码 `8888`
- **结果**: 成功登录,跳转至 `/judge/dashboard/`
- **异常场景**: 输入未绑定手机号、输入错误验证码,均提示相应错误信息。
### 2.2 项目列表 (仪表盘)
- **场景**: 登录后查看所负责比赛的项目。
- **结果**: 列表展示正确,包含封面、选手名、当前状态。点击“详情 & 评分”弹出模态框。
### 2.3 评分与点评
- **场景**: 在详情模态框中调整评分滑块,输入评语,点击提交。
- **结果**: 页面提示“已保存”,刷新后数据持久化。
- **审计日志**: `judge_audit.log` 记录 `SCORE_UPDATE` 操作。
### 2.4 音频上传
- **场景**: 点击“批量上传音频”,选择 MP3/MP4 文件,关联项目。
- **结果**: 进度条显示上传进度,完成后自动跳转至 AI 管理页面。
- **审计日志**: `judge_audit.log` 记录 `UPLOAD_AUDIO` 操作。
### 2.5 AI 服务管理
- **场景**: 在 AI 管理页面查看任务状态。
- **操作**: 点击“刷新状态”,如果任务完成,状态变更为“成功”,并可查看结果。
- **结果**: 成功展示 AI 生成的逐字稿、总结和评分。
- **删除操作**: 点击“删除”,确认后记录消失,审计日志记录 `DELETE_TASK`
## 3. 性能与兼容性
- **响应式**: 在 iPhone/iPad 模拟器下布局自适应,操作流畅。
- **并发**: 批量上传 5 个文件,均能正常创建任务并返回。
## 4. 安全性
- **权限控制**: 尝试访问非本人负责项目的详情 API返回 403 Forbidden。
- **Session**: 登出后 Session 清除,无法通过 URL 直接访问受保护页面。
- **CSRF**: 所有 POST 请求均携带 CSRF Token。

View File

View File

@@ -0,0 +1,47 @@
from django.contrib import admin
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from unfold.admin import StackedInline as UnfoldStackedInline
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
class AIEvaluationInline(UnfoldStackedInline):
model = AIEvaluation
extra = 0
can_delete = True
verbose_name = "AI评估结果"
verbose_name_plural = "AI评估结果"
readonly_fields = ['created_at', 'updated_at', 'raw_response', 'reasoning', 'template']
fields = ('template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message')
@admin.register(TranscriptionTask)
class TranscriptionTaskAdmin(UnfoldModelAdmin):
list_display = ['id', 'status', 'task_id', 'created_at']
list_filter = ['status', 'created_at']
search_fields = ['id', 'task_id', 'transcription', 'summary']
readonly_fields = ['id', 'created_at', 'updated_at', 'task_id']
inlines = [AIEvaluationInline]
@admin.register(AIEvaluationTemplate)
class AIEvaluationTemplateAdmin(UnfoldModelAdmin):
list_display = ['name', 'model_selection', 'score_dimension', 'is_default', 'is_active', 'created_at']
list_filter = ['is_active', 'is_default', 'model_selection', 'created_at']
search_fields = ['name', 'prompt']
@admin.register(AIEvaluation)
class AIEvaluationAdmin(UnfoldModelAdmin):
list_display = ['id', 'task', 'template', 'score', 'status', 'model_selection', 'created_at']
list_filter = ['status', 'model_selection', 'created_at', 'template']
search_fields = ['task__id', 'evaluation', 'reasoning']
readonly_fields = ['id', 'created_at', 'updated_at', 'raw_response']
fieldsets = (
(None, {
'fields': ('task', 'template', 'status', 'score', 'evaluation')
}),
('配置快照', {
'fields': ('model_selection', 'prompt'),
'classes': ('collapse',),
}),
('调试信息', {
'fields': ('raw_response', 'reasoning', 'error_message'),
'classes': ('collapse',),
}),
)

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AiServicesConfig(AppConfig):
name = 'ai_services'

View File

@@ -0,0 +1,323 @@
import logging
import json
import os
from django.conf import settings
from openai import OpenAI
from .models import AIEvaluation
logger = logging.getLogger(__name__)
class BailianService:
def __init__(self):
self.api_key = getattr(settings, 'DASHSCOPE_API_KEY', None)
if not self.api_key:
self.api_key = os.environ.get("DASHSCOPE_API_KEY")
if self.api_key:
self.client = OpenAI(
api_key=self.api_key,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
else:
self.client = None
logger.warning("DASHSCOPE_API_KEY not configured.")
def evaluate_task(self, evaluation: AIEvaluation):
"""
执行AI评估
"""
if not self.client:
evaluation.status = AIEvaluation.Status.FAILED
evaluation.error_message = "服务未配置 (DASHSCOPE_API_KEY missing)"
evaluation.save()
return
task = evaluation.task
if not task.transcription:
evaluation.status = AIEvaluation.Status.FAILED
evaluation.error_message = "关联任务无逐字稿内容"
evaluation.save()
return
evaluation.status = AIEvaluation.Status.PROCESSING
evaluation.save()
try:
prompt = evaluation.prompt
content = task.transcription
# 准备章节/时间戳数据以辅助分析发言节奏
chapter_context = ""
if task.auto_chapters_data:
try:
chapters_str = ""
# 处理特定的 AutoChapters 结构
# 格式: {"AutoChapters": [{"Id": 1, "Start": 740, "End": 203436, "Headline": "...", "Summary": "..."}, ...]}
if isinstance(task.auto_chapters_data, dict) and 'AutoChapters' in task.auto_chapters_data:
chapters = task.auto_chapters_data['AutoChapters']
if isinstance(chapters, list):
chapter_lines = []
for ch in chapters:
# 毫秒转 MM:SS
start_ms = ch.get('Start', 0)
end_ms = ch.get('End', 0)
start_str = f"{start_ms // 60000:02d}:{(start_ms // 1000) % 60:02d}"
end_str = f"{end_ms // 60000:02d}:{(end_ms // 1000) % 60:02d}"
headline = ch.get('Headline', '无标题')
summary = ch.get('Summary', '')
line = f"- [{start_str} - {end_str}] {headline}"
if summary:
line += f"\n 摘要: {summary}"
chapter_lines.append(line)
chapters_str = "\n".join(chapter_lines)
# 如果上面的解析为空(或者格式不匹配),回退到通用 JSON dump
if not chapters_str:
if isinstance(task.auto_chapters_data, (dict, list)):
chapters_str = json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2)
else:
chapters_str = str(task.auto_chapters_data)
chapter_context = f"\n\n【章节与时间戳信息】\n{chapters_str}\n\n(提示:请结合上述章节时间戳信息,分析发言者的语速、节奏变化及停顿情况。)"
except Exception as e:
logger.warning(f"Failed to process auto_chapters_data: {e}")
# 截断过长的内容以防止超出Token限制 (简单处理取前10000字)
if len(content) > 10000:
content = content[:10000] + "...(内容过长已截断)"
# Construct messages
messages = [
{'role': 'system', 'content': 'You are a helpful assistant designed to output JSON.'},
{'role': 'user', 'content': f"{prompt}\n\n以下是需要评估的内容:\n{content}{chapter_context}"}
]
# 增加重试机制 (最多重试3次)
completion = None
last_error = None
import time
for attempt in range(3):
try:
completion = self.client.chat.completions.create(
model=evaluation.model_selection,
messages=messages,
response_format={"type": "json_object"}
)
break # 成功则跳出循环
except Exception as e:
last_error = e
logger.warning(f"AI Evaluation attempt {attempt+1}/3 failed for eval {evaluation.id}: {e}")
if attempt < 2:
time.sleep(2 * (attempt + 1)) # 简单的指数退避
if not completion:
raise last_error or Exception("AI Service call failed after retries")
response_content = completion.choices[0].message.content
# Convert to dict for storage
raw_response = completion.model_dump()
evaluation.raw_response = raw_response
# Parse JSON
try:
result = json.loads(response_content)
evaluation.score = result.get('score')
evaluation.evaluation = result.get('evaluation') or result.get('comment')
# 尝试获取推理过程(如果模型返回了)
evaluation.reasoning = result.get('reasoning') or result.get('analysis')
if not evaluation.reasoning:
# 如果JSON里没有把整个JSON作为推理参考
evaluation.reasoning = json.dumps(result, ensure_ascii=False, indent=2)
evaluation.status = AIEvaluation.Status.COMPLETED
except json.JSONDecodeError:
evaluation.status = AIEvaluation.Status.FAILED
evaluation.error_message = f"无法解析JSON响应: {response_content}"
evaluation.reasoning = response_content
evaluation.save()
# 同步结果到参赛项目 (如果关联了)
self._sync_evaluation_to_project(evaluation)
return evaluation
except Exception as e:
logger.error(f"AI Evaluation failed: {e}")
evaluation.status = AIEvaluation.Status.FAILED
evaluation.error_message = str(e)
evaluation.save()
return evaluation
def _sync_evaluation_to_project(self, evaluation: AIEvaluation):
"""
将AI评估结果同步到关联的参赛项目评分和评语
"""
try:
task = evaluation.task
if not task.project:
return
project = task.project
competition = project.competition
# 1. 确定评委身份 (Based on Template)
# 用户要求:评委显示的是模板名称
template_name = evaluation.template.name if evaluation.template else "AI智能评委"
# 使用固定前缀 + template_id 确保唯一性,这样同一个模板在不同项目里是同一个评委
openid = f"ai_judge_{evaluation.template.id}" if evaluation.template else "ai_judge_default"
# 延迟导入以避免循环依赖
from shop.models import WeChatUser
from competition.models import CompetitionEnrollment, Score, Comment, ScoreDimension
# 获取或创建虚拟评委用户
user, created = WeChatUser.objects.get_or_create(
openid=openid,
defaults={
'nickname': template_name,
'avatar_url': 'https://ui-avatars.com/api/?name=AI&background=random&color=fff'
}
)
# 如果名字不匹配(比如模板改名了),更新它
if user.nickname != template_name:
user.nickname = template_name
user.save(update_fields=['nickname'])
# 2. 确保评委已报名 (Enrollment)
enrollment, _ = CompetitionEnrollment.objects.get_or_create(
competition=competition,
user=user,
defaults={
'role': 'judge',
'status': 'approved'
}
)
# 3. 同步评分 (Score)
if evaluation.score is not None:
# 尝试找到匹配的维度
dimensions = competition.score_dimensions.all()
target_dimension = None
# 0. 优先使用模板配置的维度
if evaluation.template and evaluation.template.score_dimension:
# 检查配置的维度是否属于当前比赛
if evaluation.template.score_dimension.competition_id == competition.id:
target_dimension = evaluation.template.score_dimension
else:
# 如果不属于当前比赛(跨比赛复用模板),尝试查找同名维度
target_dimension = dimensions.filter(name=evaluation.template.score_dimension.name).first()
# 1. 如果未配置或未找到,尝试匹配 "AI Rating" (用户指定默认值)
if not target_dimension:
target_dimension = dimensions.filter(name__iexact="AI Rating").first()
# 2. 尝试匹配包含 "AI" 的维度
if not target_dimension:
for dim in dimensions:
if "AI" in dim.name.upper():
target_dimension = dim
break
# 3. 尝试匹配模板名称
if not target_dimension:
target_dimension = dimensions.filter(name=template_name).first()
# 4. 最后兜底:使用第一个维度
if not target_dimension and dimensions.exists():
target_dimension = dimensions.first()
if target_dimension:
Score.objects.update_or_create(
project=project,
judge=enrollment,
dimension=target_dimension,
defaults={'score': evaluation.score}
)
logger.info(f"Synced AI score {evaluation.score} to project {project.id} dimension {target_dimension.name}")
# 4. 同步评语 (Comment)
if evaluation.evaluation:
# 检查是否已存在该评委的评语,避免重复
comment = Comment.objects.filter(project=project, judge=enrollment).first()
if comment:
comment.content = evaluation.evaluation
comment.save()
else:
Comment.objects.create(
project=project,
judge=enrollment,
content=evaluation.evaluation
)
logger.info(f"Synced AI comment to project {project.id}")
except Exception as e:
logger.error(f"Failed to sync evaluation to project: {e}")
def summarize_task(self, task):
"""
对转写任务进行总结
"""
if not self.client:
logger.warning("BailianService not initialized, skipping summary.")
return
if not task.transcription:
logger.warning(f"Task {task.id} has no transcription, skipping summary.")
return
try:
content = task.transcription
# 简单截断防止过长
if len(content) > 15000:
content = content[:15000] + "...(内容过长已截断)"
# 准备上下文数据
context_data = ""
if task.summary_data:
context_data += f"\n\n【总结原始数据】\n{json.dumps(task.summary_data, ensure_ascii=False, indent=2)}"
if task.auto_chapters_data:
context_data += f"\n\n【章节原始数据】\n{json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2)}"
system_prompt = f"""你是一个专业的会议/内容总结助手。请根据提供的【转写文本】、【总结原始数据】和【章节原始数据】,生成一份结构清晰、内容详实的总结报告。
请按照以下结构输出Markdown格式
1. **标题**:基于内容生成一个合适的标题。
2. **核心摘要**:简要概括主要内容。
3. **主要观点/话题**:结合思维导图数据,列出关键话题和层级。
4. **章节速览**:结合章节数据,列出时间点和主要内容。[HH:MM:SS]格式来把章节列出来
5. **问答精选**(如果有):基于问答总结数据,列出重要问答。
请确保语言通顺,重点突出,能够还原内容的逻辑结构。"""
user_content = f"以下是需要总结的内容:\n\n【转写文本】\n{content}{context_data}"
messages = [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_content}
]
# 使用 qwen-plus 作为默认模型
completion = self.client.chat.completions.create(
model="qwen-plus",
messages=messages
)
summary_content = completion.choices[0].message.content
task.summary = summary_content
task.save(update_fields=['summary'])
logger.info(f"Task {task.id} summary generated successfully.")
except Exception as e:
logger.error(f"Failed to generate summary for task {task.id}: {e}")

View File

@@ -0,0 +1,54 @@
from django.core.management.base import BaseCommand
from django.conf import settings
import oss2
from aliyunsdkcore.client import AcsClient
class Command(BaseCommand):
help = 'Check Aliyun configuration status'
def handle(self, *args, **options):
self.stdout.write("Checking Aliyun Configuration...")
configs = {
'ALIYUN_ACCESS_KEY_ID': settings.ALIYUN_ACCESS_KEY_ID,
'ALIYUN_ACCESS_KEY_SECRET': settings.ALIYUN_ACCESS_KEY_SECRET,
'ALIYUN_OSS_BUCKET_NAME': settings.ALIYUN_OSS_BUCKET_NAME,
'ALIYUN_OSS_ENDPOINT': settings.ALIYUN_OSS_ENDPOINT,
'ALIYUN_TINGWU_APP_KEY': settings.ALIYUN_TINGWU_APP_KEY,
}
all_valid = True
for key, value in configs.items():
if not value:
self.stdout.write(self.style.ERROR(f"[MISSING] {key} is not set or empty"))
all_valid = False
else:
masked_value = value[:4] + "****" + value[-4:] if len(value) > 8 else "****"
self.stdout.write(self.style.SUCCESS(f"[OK] {key}: {masked_value}"))
if not all_valid:
self.stdout.write(self.style.ERROR("\nConfiguration check FAILED. Some required settings are missing."))
return
# Test OSS Connection
self.stdout.write("\nTesting OSS Connection...")
try:
auth = oss2.Auth(configs['ALIYUN_ACCESS_KEY_ID'], configs['ALIYUN_ACCESS_KEY_SECRET'])
bucket = oss2.Bucket(auth, configs['ALIYUN_OSS_ENDPOINT'], configs['ALIYUN_OSS_BUCKET_NAME'])
bucket.get_bucket_info()
self.stdout.write(self.style.SUCCESS("[OK] OSS Connection successful"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"[FAILED] OSS Connection failed: {e}"))
# Test Tingwu Client Initialization
self.stdout.write("\nTesting Tingwu Client Initialization...")
try:
client = AcsClient(
configs['ALIYUN_ACCESS_KEY_ID'],
configs['ALIYUN_ACCESS_KEY_SECRET'],
"cn-beijing"
)
self.stdout.write(self.style.SUCCESS("[OK] Tingwu Client initialized"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"[FAILED] Tingwu Client init failed: {e}"))

View File

@@ -0,0 +1,63 @@
import time
import logging
from django.core.management.base import BaseCommand
from ai_services.models import TranscriptionTask
from ai_services.services import AliyunTingwuService
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Polls Aliyun Tingwu for transcription results every 10 seconds'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Starting polling service...'))
service = AliyunTingwuService()
while True:
try:
# Find tasks that are PENDING or PROCESSING
# Include PENDING because create() might set it to PENDING initially
# though usually it sets to PROCESSING if task_id is obtained.
# Just in case.
tasks = TranscriptionTask.objects.filter(
status__in=[TranscriptionTask.Status.PENDING, TranscriptionTask.Status.PROCESSING]
).exclude(task_id__isnull=True).exclude(task_id='')
count = tasks.count()
if count > 0:
self.stdout.write(f'Found {count} pending/processing tasks.')
for task in tasks:
self.stdout.write(f'Checking task {task.task_id} (Status: {task.status})...')
try:
result = service.get_task_info(task.task_id)
# Store old status to check for changes
old_status = task.status
service.parse_and_update_task(task, result)
# Re-fetch or check updated object
if task.status != old_status:
if task.status == TranscriptionTask.Status.SUCCEEDED:
self.stdout.write(self.style.SUCCESS(f'Task {task.task_id} SUCCEEDED'))
elif task.status == TranscriptionTask.Status.FAILED:
self.stdout.write(self.style.ERROR(f'Task {task.task_id} FAILED: {task.error_message}'))
else:
# Still processing
pass
except Exception as e:
logger.error(f"Error checking task {task.task_id}: {e}")
self.stdout.write(self.style.ERROR(f"Error checking task {task.task_id}: {e}"))
# Wait for 10 seconds
time.sleep(10)
except KeyboardInterrupt:
self.stdout.write(self.style.SUCCESS('Stopping polling service...'))
break
except Exception as e:
logger.error(f"Polling loop error: {e}")
self.stdout.write(self.style.ERROR(f"Polling loop error: {e}"))
time.sleep(10)

View File

@@ -0,0 +1,102 @@
import os
import sys
import django
import json
import logging
from django.conf import settings
# 设置 Django 环境
# 添加项目根目录到 sys.path
sys.path.append('/Volumes/data/Quant-Speed/market_page/backend')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') # 修正为正确的 settings 模块路径
django.setup()
from ai_services.services import AliyunTingwuService
from ai_services.models import TranscriptionTask
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def test_tingwu_transcription():
file_url = "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/Video/%E6%95%99%E5%AD%A6.mp4"
print(f"Testing transcription for: {file_url}")
service = AliyunTingwuService()
# 1. 创建任务
try:
print("Creating task...")
response = service.create_transcription_task(file_url)
print(f"Create task response: {json.dumps(response, indent=2, ensure_ascii=False)}")
if 'Data' in response and isinstance(response['Data'], dict):
task_id = response['Data'].get('TaskId')
else:
task_id = response.get('TaskId')
if not task_id:
print("Failed to get TaskId")
return
print(f"Task created with ID: {task_id}")
# 2. 轮询查询任务状态
import time
max_retries = 60 # 5 minutes
for i in range(max_retries):
print(f"Checking status (attempt {i+1}/{max_retries})...")
result = service.get_task_info(task_id)
# 解析结果
if isinstance(result, str):
try:
result = json.loads(result)
except:
pass
if isinstance(result, dict):
data_obj = result.get('Data', result)
else:
data_obj = result
task_status = data_obj.get('TaskStatus')
if not task_status:
task_status = data_obj.get('Status')
print(f"Current status: {task_status}")
if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']:
print("Task succeeded!")
print(f"Full Result: {json.dumps(data_obj, indent=2, ensure_ascii=False)}")
# 尝试解析 Transcription
task_result = data_obj.get('Result', {})
transcription_data = task_result.get('Transcription', {})
if isinstance(transcription_data, str) and transcription_data.startswith('http'):
import requests
print(f"Downloading transcription from {transcription_data}")
t_resp = requests.get(transcription_data)
if t_resp.status_code == 200:
content = t_resp.json()
print(f"Downloaded content structure keys: {content.keys()}")
# print(f"Content sample: {json.dumps(content, indent=2, ensure_ascii=False)[:500]}...")
else:
print(f"Failed to download: {t_resp.status_code}")
break
elif task_status == 'FAILED':
print(f"Task failed: {data_obj}")
break
time.sleep(5)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
test_tingwu_transcription()

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.1 on 2026-03-11 05:11
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='TranscriptionTask',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file_url', models.URLField(max_length=1024, verbose_name='文件链接')),
('task_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='听悟任务ID')),
('status', models.CharField(choices=[('PENDING', '等待中'), ('PROCESSING', '处理中'), ('SUCCEEDED', '成功'), ('FAILED', '失败')], default='PENDING', max_length=20, verbose_name='状态')),
('transcription', models.TextField(blank=True, null=True, verbose_name='逐字稿')),
('summary', models.TextField(blank=True, null=True, verbose_name='AI总结')),
('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '转写任务',
'verbose_name_plural': '转写任务',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-03-11 05:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_services', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='transcriptiontask',
name='evaluation',
field=models.TextField(blank=True, null=True, verbose_name='AI评语'),
),
migrations.AddField(
model_name='transcriptiontask',
name='score',
field=models.IntegerField(blank=True, help_text='基于转写内容的评分', null=True, verbose_name='AI评分'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.1 on 2026-03-11 12:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_services', '0002_transcriptiontask_evaluation_transcriptiontask_score'),
]
operations = [
migrations.AddField(
model_name='transcriptiontask',
name='auto_chapters_data',
field=models.JSONField(blank=True, help_text='阿里云返回的AutoChapters完整JSON', null=True, verbose_name='章节原始数据'),
),
migrations.AddField(
model_name='transcriptiontask',
name='summary_data',
field=models.JSONField(blank=True, help_text='阿里云返回的Summarization完整JSON', null=True, verbose_name='总结原始数据'),
),
migrations.AddField(
model_name='transcriptiontask',
name='transcription_data',
field=models.JSONField(blank=True, help_text='阿里云返回的Transcription完整JSON', null=True, verbose_name='转写原始数据'),
),
]

View File

@@ -0,0 +1,44 @@
# Generated by Django 6.0.1 on 2026-03-11 12:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_services', '0003_transcriptiontask_auto_chapters_data_and_more'),
]
operations = [
migrations.RemoveField(
model_name='transcriptiontask',
name='evaluation',
),
migrations.RemoveField(
model_name='transcriptiontask',
name='score',
),
migrations.CreateModel(
name='AIEvaluation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('score', models.IntegerField(blank=True, help_text='0-100分', null=True, verbose_name='AI评分')),
('evaluation', models.TextField(blank=True, null=True, verbose_name='AI评语')),
('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')),
('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容对内容质量、逻辑清晰度、语言表达等方面进行综合评分0-100分并给出详细的评语。请以JSON格式返回包含"score""evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')),
('raw_response', models.JSONField(blank=True, help_text='大模型返回的完整JSON', null=True, verbose_name='原始响应')),
('reasoning', models.TextField(blank=True, help_text='AI的推理过程如果有', null=True, verbose_name='推理过程')),
('status', models.CharField(choices=[('PENDING', '等待中'), ('PROCESSING', '生成中'), ('COMPLETED', '已完成'), ('FAILED', '失败')], default='PENDING', max_length=20, verbose_name='评估状态')),
('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluation', to='ai_services.transcriptiontask', verbose_name='关联任务')),
],
options={
'verbose_name': 'AI智能评估',
'verbose_name_plural': 'AI智能评估',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,55 @@
# Generated by Django 6.0.1 on 2026-03-11 13:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_services', '0004_remove_transcriptiontask_evaluation_and_more'),
]
operations = [
migrations.CreateModel(
name='AIEvaluationTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='例如:销售话术评分、逻辑性分析', max_length=100, verbose_name='模板名称')),
('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')),
('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容对内容质量、逻辑清晰度、语言表达等方面进行综合评分0-100分并给出详细的评语。请以JSON格式返回包含"score""evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')),
('is_active', models.BooleanField(default=True, help_text='启用后,新的转写任务完成后将自动使用此模板进行评估', verbose_name='是否启用')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': 'AI评估模板',
'verbose_name_plural': 'AI评估模板',
'ordering': ['-created_at'],
},
),
migrations.AlterModelOptions(
name='aievaluation',
options={'ordering': ['-created_at'], 'verbose_name': 'AI评估结果', 'verbose_name_plural': 'AI评估结果'},
),
migrations.AlterField(
model_name='aievaluation',
name='model_selection',
field=models.CharField(default='qwen-plus', max_length=50, verbose_name='模型选择'),
),
migrations.AlterField(
model_name='aievaluation',
name='prompt',
field=models.TextField(verbose_name='评分提示词'),
),
migrations.AlterField(
model_name='aievaluation',
name='task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluations', to='ai_services.transcriptiontask', verbose_name='关联任务'),
),
migrations.AddField(
model_name='aievaluation',
name='template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evaluations', to='ai_services.aievaluationtemplate', verbose_name='使用的模板'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-03-11 14:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_services', '0005_aievaluationtemplate_alter_aievaluation_options_and_more'),
('competition', '0003_competition_project_visibility'),
]
operations = [
migrations.AddField(
model_name='transcriptiontask',
name='project',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transcription_tasks', to='competition.project', verbose_name='关联参赛项目'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-03-11 15:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_services', '0006_transcriptiontask_project'),
('competition', '0003_competition_project_visibility'),
]
operations = [
migrations.AddField(
model_name='aievaluationtemplate',
name='score_dimension',
field=models.ForeignKey(blank=True, help_text='如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度', null=True, on_delete=django.db.models.deletion.SET_NULL, to='competition.scoredimension', verbose_name='关联评分维度'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-17 15:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_services', '0007_aievaluationtemplate_score_dimension'),
]
operations = [
migrations.AddField(
model_name='aievaluationtemplate',
name='is_default',
field=models.BooleanField(default=False, help_text='默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价', verbose_name='是否为默认模板'),
),
]

View File

@@ -0,0 +1,150 @@
import uuid
from django.db import models
from django.utils.translation import gettext_lazy as _
class TranscriptionTask(models.Model):
class Status(models.TextChoices):
PENDING = 'PENDING', _('等待中')
PROCESSING = 'PROCESSING', _('处理中')
SUCCEEDED = 'SUCCEEDED', _('成功')
FAILED = 'FAILED', _('失败')
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
file_url = models.URLField(verbose_name=_('文件链接'), max_length=1024)
task_id = models.CharField(verbose_name=_('听悟任务ID'), max_length=100, blank=True, null=True)
status = models.CharField(
verbose_name=_('状态'),
max_length=20,
choices=Status.choices,
default=Status.PENDING
)
# 存储阿里云听悟返回的原始 JSON 结构
transcription_data = models.JSONField(verbose_name=_('转写原始数据'), blank=True, null=True, help_text=_('阿里云返回的Transcription完整JSON'))
summary_data = models.JSONField(verbose_name=_('总结原始数据'), blank=True, null=True, help_text=_('阿里云返回的Summarization完整JSON'))
auto_chapters_data = models.JSONField(verbose_name=_('章节原始数据'), blank=True, null=True, help_text=_('阿里云返回的AutoChapters完整JSON'))
project = models.ForeignKey(
'competition.Project',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transcription_tasks',
verbose_name=_('关联参赛项目')
)
transcription = models.TextField(verbose_name=_('逐字稿'), blank=True, null=True)
summary = models.TextField(verbose_name=_('AI总结'), blank=True, null=True)
# 已解耦到 AIEvaluation 模型
# score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('基于转写内容的评分'))
# evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True)
error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True)
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
class Meta:
verbose_name = _('转写任务')
verbose_name_plural = _('转写任务')
ordering = ['-created_at']
def __str__(self):
return f"{self.id} - {self.get_status_display()}"
class AIEvaluationTemplate(models.Model):
name = models.CharField(verbose_name=_('模板名称'), max_length=100, help_text=_('例如:销售话术评分、逻辑性分析'))
model_selection = models.CharField(
verbose_name=_('模型选择'),
max_length=50,
default='qwen-plus',
help_text=_('例如: qwen-plus, qwen-turbo, qwen-max')
)
prompt = models.TextField(
verbose_name=_('评分提示词'),
default='你是一个专业的评分助手。请根据提供的转写内容对内容质量、逻辑清晰度、语言表达等方面进行综合评分0-100分并给出详细的评语。请以JSON格式返回包含"score""evaluation"字段。',
help_text=_('用于指导AI评分的提示词')
)
score_dimension = models.ForeignKey(
'competition.ScoreDimension',
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_('关联评分维度'),
help_text=_('如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度')
)
is_default = models.BooleanField(
verbose_name=_('是否为默认模板'),
default=False,
help_text=_('默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价')
)
is_active = models.BooleanField(verbose_name=_('是否启用'), default=True, help_text=_('启用后,新的转写任务完成后将自动使用此模板进行评估'))
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
class Meta:
verbose_name = _('AI评估模板')
verbose_name_plural = _('AI评估模板')
ordering = ['-created_at']
def __str__(self):
return self.name
class AIEvaluation(models.Model):
class Status(models.TextChoices):
PENDING = 'PENDING', _('等待中')
PROCESSING = 'PROCESSING', _('生成中')
COMPLETED = 'COMPLETED', _('已完成')
FAILED = 'FAILED', _('失败')
task = models.ForeignKey(
TranscriptionTask,
on_delete=models.CASCADE,
related_name='ai_evaluations',
verbose_name=_('关联任务')
)
template = models.ForeignKey(
AIEvaluationTemplate,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='evaluations',
verbose_name=_('使用的模板')
)
# 评分与评语
score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('0-100分'))
evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True)
# 记录当时的配置 (快照)
model_selection = models.CharField(
verbose_name=_('模型选择'),
max_length=50,
default='qwen-plus'
)
prompt = models.TextField(verbose_name=_('评分提示词'))
# 原始数据与推理
raw_response = models.JSONField(verbose_name=_('原始响应'), blank=True, null=True, help_text=_('大模型返回的完整JSON'))
reasoning = models.TextField(verbose_name=_('推理过程'), blank=True, null=True, help_text=_('AI的推理过程如果有'))
status = models.CharField(
verbose_name=_('评估状态'),
max_length=20,
choices=Status.choices,
default=Status.PENDING
)
error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True)
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
class Meta:
verbose_name = _('AI评估结果')
verbose_name_plural = _('AI评估结果')
ordering = ['-created_at']
def __str__(self):
return f"Evaluation for Task {self.task.id} ({self.template.name if self.template else 'Custom'})"

View File

@@ -0,0 +1,28 @@
from rest_framework import serializers
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
class AIEvaluationTemplateSerializer(serializers.ModelSerializer):
class Meta:
model = AIEvaluationTemplate
fields = ['id', 'name', 'model_selection', 'prompt', 'is_active', 'created_at']
class AIEvaluationSerializer(serializers.ModelSerializer):
template = AIEvaluationTemplateSerializer(read_only=True)
class Meta:
model = AIEvaluation
fields = ['id', 'template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message', 'created_at', 'updated_at']
class TranscriptionTaskSerializer(serializers.ModelSerializer):
ai_evaluations = AIEvaluationSerializer(many=True, read_only=True)
project_title = serializers.CharField(source='project.title', read_only=True)
class Meta:
model = TranscriptionTask
fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project', 'project_title']
read_only_fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project_title']
class TranscriptionUploadSerializer(serializers.Serializer):
file = serializers.FileField(help_text="上传的音频文件", required=False)
file_url = serializers.URLField(help_text="音频文件的URL地址", required=False)
project_id = serializers.IntegerField(help_text="关联的参赛项目ID", required=False)

View File

@@ -0,0 +1,420 @@
import json
import logging
import time
import uuid
import oss2
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.acs_exception.exceptions import ClientException, ServerException
# 尝试导入最新的 API 版本,如果有问题可能需要调整
try:
from aliyunsdktingwu.request.v20230930 import CreateTaskRequest, GetTaskInfoRequest
except ImportError:
# Fallback or error handling if version differs
pass
from django.conf import settings
logger = logging.getLogger(__name__)
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
class AliyunTingwuService:
def __init__(self):
self.access_key_id = settings.ALIYUN_ACCESS_KEY_ID
self.access_key_secret = settings.ALIYUN_ACCESS_KEY_SECRET
self.oss_bucket_name = settings.ALIYUN_OSS_BUCKET_NAME
self.oss_endpoint = settings.ALIYUN_OSS_ENDPOINT
self.tingwu_app_key = settings.ALIYUN_TINGWU_APP_KEY
self.region_id = "cn-shanghai" # 听悟服务区域根据文档应与OSS区域一致或者使用 'cn-beijing'
# 初始化 OSS Bucket
if self.access_key_id and self.access_key_secret and self.oss_endpoint:
auth = oss2.Auth(self.access_key_id, self.access_key_secret)
self.bucket = oss2.Bucket(auth, self.oss_endpoint, self.oss_bucket_name)
else:
self.bucket = None
logger.warning("Aliyun OSS configuration missing.")
# 初始化听悟 Client
if self.access_key_id and self.access_key_secret:
self.client = AcsClient(
self.access_key_id,
self.access_key_secret,
self.region_id
)
# 显式添加听悟服务的 Endpoint 映射,解决 EndpointResolvingError
# 听悟 API 的服务接入点通常是 tingwu.cn-beijing.aliyuncs.com
# 但新版听悟 API (tingwu.aliyuncs.com) 可能不同,需根据实际情况添加
# 这里添加一个通用的 Endpoint 映射
try:
# 尝试为 tingwu 产品设置 Endpoint
# 注意听悟服务主要部署在北京Endpoint 通常为 tingwu.cn-beijing.aliyuncs.com
# 如果您的服务在上海,也可能需要连接到北京的接入点
self.client.add_endpoint(self.region_id, "tingwu", "tingwu.cn-beijing.aliyuncs.com")
except Exception as e:
logger.warning(f"Failed to add endpoint: {e}")
else:
self.client = None
logger.warning("Aliyun AccessKey configuration missing.")
def upload_to_oss(self, file_obj, file_name, day=7):
"""
上传文件到 OSS 并返回带签名的 URL
默认生成有效期为 7 天 (3600 * 24 * day) 的签名URL方便评委在一段时间内都能播放。
"""
if not self.bucket:
raise Exception("OSS Client not initialized")
try:
# 上传文件
# file_obj 应该是打开的文件对象或字节流
self.bucket.put_object(file_name, file_obj)
# 生成签名 URL有效期 7 天 (3600 * 24 * 7 = 604800 秒)
url = self.bucket.sign_url('GET', file_name, 3600 * 24 * day)
return url
except Exception as e:
logger.error(f"OSS Upload failed: {e}")
raise e
def create_transcription_task(self, file_url, language="cn"):
"""
创建听悟转写任务
"""
if not self.client:
raise Exception("Tingwu Client not initialized")
request = CreateTaskRequest.CreateTaskRequest()
# 针对阿里云 SDK 不同版本的兼容性处理
# "type" 参数是听悟 API (ROA 风格) 的必填项,用于指定任务类型
# 根据官方文档,离线任务的 type 通常就是 'offline'
request.add_query_param('type', 'offline')
# 构造请求体 (Body)
# 根据听悟 API 文档AppKey, Input, Parameters 应位于 JSON Body 中
# 而不是 Query Parameter
body = {
"AppKey": self.tingwu_app_key,
"Input": {
"FileUrl": file_url,
"SourceLanguage": language,
"TaskKey": str(uuid.uuid4())
},
"Parameters": {
"Transcoding": {
"TargetAudioFormat": "mp3"
},
"Transcription": {
"DiarizationEnabled": True,
"ChannelId": 0
},
"TranscriptionEnabled": True,
"AutoChaptersEnabled": True,
"SummarizationEnabled": True,
"Summarization": {
"Types": ["Paragraph", "Conversational", "QuestionsAnswering", "MindMap"]
}
}
}
# 设置 Body 内容
request.set_content(json.dumps(body))
request.add_header('Content-Type', 'application/json')
# 强制设置 Endpoint避免 SDK.EndpointResolvingError
# 听悟目前主要服务点在北京
request.set_endpoint("tingwu.cn-beijing.aliyuncs.com")
# 显式设置 Method 为 PUT
request.set_method('PUT')
try:
response = self.client.do_action_with_exception(request)
return json.loads(response)
except (ClientException, ServerException) as e:
logger.error(f"Tingwu CreateTask failed: {e}")
raise e
def get_task_info(self, task_id):
"""
查询任务状态和结果
"""
if not self.client:
raise Exception("Tingwu Client not initialized")
request = GetTaskInfoRequest.GetTaskInfoRequest()
request.set_TaskId(task_id)
try:
response = self.client.do_action_with_exception(request)
return json.loads(response)
except (ClientException, ServerException) as e:
logger.error(f"Tingwu GetTaskInfo failed: {e}")
raise e
def parse_and_update_task(self, task, result):
"""
解析听悟结果并更新任务
:param task: TranscriptionTask 实例
:param result: get_task_info 返回的完整 JSON (或 Data 部分)
"""
# 记录之前的状态,用于判断是否是首次完成
previous_status = task.status
# 1. 提取 Data 对象
if isinstance(result, dict):
data_obj = result.get('Data', result)
else:
data_obj = result
if not isinstance(data_obj, dict):
logger.error(f"Unexpected data format: {type(data_obj)}")
return
# 2. 更新状态
task_status = data_obj.get('TaskStatus') or data_obj.get('Status')
if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']:
task.status = 'SUCCEEDED' # 使用字符串引用,避免导入模型循环引用
elif task_status == 'FAILED':
task.status = 'FAILED'
task.error_message = data_obj.get('TaskStatusText', data_obj.get('Message', 'Unknown error'))
task.save()
return
else:
# 仍在处理中,不更新内容
return
# 3. 解析结果
task_result = data_obj.get('Result', {})
# 兼容处理:如果 Result 为空,或者不存在,尝试直接使用 data_obj 作为结果源
# 某些情况下Summarization/AutoChapters 可能直接位于 Data 层级
if not task_result:
task_result = data_obj
# 辅助函数:从源字典或其 Result 子字典中获取字段
def get_data_field(source, key):
# 1. 尝试直接从 task_result 获取 (如果 task_result 就是 Data 本身,这里也会生效)
if isinstance(source, dict) and key in source:
return source[key]
# 2. 如果 source 是 Data尝试从 source['Result'] 获取
if isinstance(source, dict) and 'Result' in source and isinstance(source['Result'], dict):
if key in source['Result']:
return source['Result'][key]
return None
# --- A. 处理逐字稿 (Transcription) ---
transcription_data = get_data_field(task_result, 'Transcription') or get_data_field(data_obj, 'Transcription') or {}
# 处理 URL 下载
if isinstance(transcription_data, str) and transcription_data.startswith('http'):
try:
import requests
t_resp = requests.get(transcription_data)
if t_resp.status_code == 200:
transcription_data = t_resp.json()
except Exception as e:
logger.error(f"Download transcription failed: {e}")
transcription_data = {}
elif isinstance(transcription_data, dict) and 'TranscriptionUrl' in transcription_data:
try:
import requests
t_resp = requests.get(transcription_data['TranscriptionUrl'])
if t_resp.status_code == 200:
transcription_data = t_resp.json()
except Exception as e:
logger.error(f"Download transcription url failed: {e}")
# 保存原始数据
task.transcription_data = transcription_data
# 提取文本
# 结构: {"Transcription": {"Paragraphs": [{"Words": [{"Text": "..."}]}]}}
# 或直接 {"Paragraphs": ...}
content_source = transcription_data
if 'Transcription' in content_source and isinstance(content_source['Transcription'], dict):
content_source = content_source['Transcription']
paragraphs = content_source.get('Paragraphs', [])
full_text_lines = []
if paragraphs and isinstance(paragraphs, list):
for p in paragraphs:
# 尝试从 Words 中提取
words = p.get('Words', [])
if words:
line_text = "".join([str(w.get('Text', '')) for w in words])
full_text_lines.append(line_text)
# 兼容旧结构或直接 Text
elif 'Text' in p:
full_text_lines.append(p['Text'])
if full_text_lines:
task.transcription = "\n".join(full_text_lines)
# --- B. 处理 AI 总结 (Summarization) ---
summarization = get_data_field(task_result, 'Summarization') or get_data_field(data_obj, 'Summarization') or {}
# 处理 URL 下载
if isinstance(summarization, str) and summarization.startswith('http'):
try:
import requests
s_resp = requests.get(summarization)
if s_resp.status_code == 200:
summarization = s_resp.json()
except Exception as e:
logger.error(f"Download summarization failed: {e}")
summarization = {}
# 保存原始数据
task.summary_data = summarization
# 提取文本 (MindMapSummary)
# 结构: {"MindMapSummary": [{"Title": "...", "Topic": [...]}]}
# 移除了原先的 summary_text 拼接逻辑
# --- C. 处理章节 (AutoChapters) ---
auto_chapters = get_data_field(task_result, 'AutoChapters') or get_data_field(data_obj, 'AutoChapters') or []
# 处理 URL 下载
if isinstance(auto_chapters, str) and auto_chapters.startswith('http'):
try:
import requests
ac_resp = requests.get(auto_chapters)
if ac_resp.status_code == 200:
auto_chapters = ac_resp.json()
except Exception as e:
logger.error(f"Download auto chapters failed: {e}")
auto_chapters = []
# 保存原始数据
task.auto_chapters_data = auto_chapters
# 保存任务,确保原始数据已写入数据库
task.save()
# 调用大模型生成总结 (如果 summary_data 或 auto_chapters_data 存在)
if task.summary_data or task.auto_chapters_data:
try:
# 设置占位状态
task.summary = "AI总结生成当中..."
task.save(update_fields=['summary'])
# 异步执行总结
import threading
from .bailian_service import BailianService
def async_summarize_in_service(task_id):
try:
# 重新获取 task 以避免线程安全问题
from .models import TranscriptionTask
t = TranscriptionTask.objects.get(id=task_id)
bailian_service = BailianService()
bailian_service.summarize_task(t)
except Exception as e:
logger.error(f"Async summary generation failed in service: {e}")
threading.Thread(target=async_summarize_in_service, args=(task.id,)).start()
logger.info(f"Triggered async summary generation for task {task.id}")
except Exception as e:
logger.error(f"Failed to trigger AI summarization: {e}")
# 4. 自动触发 AI 评估 (如果任务首次成功且有启用的模板)
if previous_status != 'SUCCEEDED' and task.status == 'SUCCEEDED' and task.transcription:
# 同样改为异步触发,传递 task.id 以避免线程中的对象状态问题
import threading
threading.Thread(target=self.trigger_ai_evaluations, args=(task.id,)).start()
def trigger_ai_evaluations(self, task_id):
"""
根据启用的模板自动触发 AI 评估
逻辑:
1. 如果模板关联了评分维度(s score_dimension),只对关联了相同维度的比赛进行评估
2. 如果模板未关联评分维度:
- 如果是默认模板(is_default=True),评价所有比赛
- 否则不进行自动评价
"""
try:
# 在线程中重新获取 task 对象,并预加载 project避免懒加载导致的线程数据库连接问题
from .models import TranscriptionTask
task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id)
except Exception as e:
# 兼容处理:如果 task_id 其实是 task 对象(虽然我们上面改了,但防止其他地方调用传错)
if hasattr(task_id, 'id'):
try:
from .models import TranscriptionTask
task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id.id)
except:
task = task_id
else:
logger.error(f"Failed to retrieve task {task_id}: {e}")
return
active_templates = AIEvaluationTemplate.objects.filter(is_active=True)
if not active_templates.exists():
logger.info("No active AI evaluation templates found.")
return
from .bailian_service import BailianService
service = BailianService()
for template in active_templates:
# 检查是否已经存在相同的评估,避免重复创建
if AIEvaluation.objects.filter(task=task, template=template).exists():
logger.info(f"Evaluation for task {task.id} and template {template.name} already exists.")
continue
# 获取任务关联的比赛
task_competition = None
if task.project and task.project.competition:
task_competition = task.project.competition
# 判断是否应该对此任务进行评估
should_evaluate = False
if template.score_dimension:
# 模板关联了评分维度,只对关联了相同维度的比赛进行评估
if task_competition:
# 获取该比赛下所有关联了相同评分维度的比赛ID列表
from competition.models import ScoreDimension
related_competition_ids = ScoreDimension.objects.filter(
id=template.score_dimension.id
).values_list('competition_id', flat=True)
if task_competition.id in related_competition_ids:
should_evaluate = True
logger.info(f"Template '{template.name}' is linked to score_dimension, task's competition matches.")
else:
logger.info(f"Template '{template.name}' is linked to score_dimension, but task's competition does not match. Skipping.")
else:
logger.info(f"Task {task.id} has no associated competition. Skipping template '{template.name}'.")
else:
# 模板未关联评分维度,只有默认模板才评价所有比赛
if template.is_default:
should_evaluate = True
logger.info(f"Template '{template.name}' is default template, evaluating all competitions.")
else:
logger.info(f"Template '{template.name}' is not linked to score_dimension and is not default. Skipping.")
if not should_evaluate:
continue
# 创建评估记录
evaluation = AIEvaluation.objects.create(
task=task,
template=template,
model_selection=template.model_selection,
prompt=template.prompt,
status=AIEvaluation.Status.PENDING
)
# 触发评估
try:
service.evaluate_task(evaluation)
logger.info(f"Triggered evaluation {evaluation.id} for template {template.name}")
except Exception as e:
logger.error(f"Failed to trigger evaluation {evaluation.id}: {e}")

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TranscriptionTaskViewSet, tingwu_callback
router = DefaultRouter()
router.register(r'transcriptions', TranscriptionTaskViewSet)
urlpatterns = [
path('callback/', tingwu_callback, name='tingwu-callback'),
path('', include(router.urls)),
]

View File

@@ -0,0 +1,364 @@
import logging
import uuid
from rest_framework import viewsets, status
from rest_framework.decorators import action, api_view, permission_classes, parser_classes
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.permissions import AllowAny
from django.conf import settings
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from .models import TranscriptionTask, AIEvaluation
from .serializers import TranscriptionTaskSerializer, TranscriptionUploadSerializer, AIEvaluationSerializer
from .services import AliyunTingwuService
logger = logging.getLogger(__name__)
@api_view(['POST'])
@permission_classes([AllowAny])
def tingwu_callback(request):
"""
处理阿里云听悟的回调消息
"""
data = request.data
logger.info(f"收到听悟回调: {data}")
# 1. 处理连通性测试消息
# 格式: {"Code": "0", "Data": {"Test": "..."}, "Message": "success", "RequestId": "..."}
if isinstance(data, dict) and 'Data' in data and 'Test' in data['Data']:
logger.info("收到听悟连通性测试请求")
return Response({'message': 'success'}, status=status.HTTP_200_OK)
# 2. 处理任务完成消息 (根据实际文档或后续调试完善)
# 通常会包含 TaskId 和 Status
# 注意:阿里云听悟回调的结构可能在 Header 或 Body 中不同,需根据实际情况调整
# 这里是一个通用的处理逻辑
task_id = data.get('TaskId')
task_status = data.get('Status')
if task_id:
try:
task = TranscriptionTask.objects.filter(task_id=task_id).first()
if task:
if task_status == 'COMPLETE':
logger.info(f"任务 {task_id} 完成,等待下一次查询刷新")
# 可以在这里直接调用 get_task_info 刷新数据,但要注意超时
elif task_status == 'FAILED':
task.status = TranscriptionTask.Status.FAILED
task.error_message = data.get('StatusText', 'Callback reported failure')
task.save()
else:
logger.warning(f"回调收到未知任务ID: {task_id}")
except Exception as e:
logger.error(f"处理回调异常: {e}")
return Response({'message': 'success'}, status=status.HTTP_200_OK)
class TranscriptionTaskViewSet(viewsets.ModelViewSet):
queryset = TranscriptionTask.objects.all()
serializer_class = TranscriptionTaskSerializer
parser_classes = (MultiPartParser, FormParser)
@extend_schema(
request={
'multipart/form-data': {
'type': 'object',
'properties': {
'file': {
'type': 'string',
'format': 'binary'
},
'file_url': {
'type': 'string',
'description': '音频文件的URL地址'
},
'project_id': {
'type': 'integer',
'description': '关联的参赛项目ID'
}
}
}
},
responses={201: TranscriptionTaskSerializer}
)
def create(self, request, *args, **kwargs):
"""
上传音频文件并创建听悟转写任务
"""
file_obj = request.FILES.get('file')
file_url = request.data.get('file_url')
project_id = request.data.get('project_id')
if not file_obj and not file_url:
return Response({'error': '请提供文件或文件URL'}, status=status.HTTP_400_BAD_REQUEST)
service = AliyunTingwuService()
if not service.bucket or not service.client:
return Response({'error': '阿里云服务未配置'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
try:
oss_url = None
if file_obj:
# 1. 上传文件到 OSS
file_extension = file_obj.name.split('.')[-1]
file_name = f"transcription/{uuid.uuid4()}.{file_extension}"
# 使用服务上传
oss_url = service.upload_to_oss(file_obj, file_name)
else:
# 使用提供的 URL
oss_url = file_url
# 2. 创建数据库记录
task_data = {
'file_url': oss_url,
'status': TranscriptionTask.Status.PENDING
}
if project_id:
try:
p_id = int(project_id)
# 只有当 ID > 0 时才认为是有效的项目 ID
# 避免前端传递 0 或 Swagger 默认值导致的外键约束错误
if p_id > 0:
task_data['project_id'] = p_id
except (ValueError, TypeError):
pass # Ignore invalid project_id
task_record = TranscriptionTask.objects.create(**task_data)
logger.info(f"Created TranscriptionTask {task_record.id} with project_id={project_id}")
# 3. 调用听悟接口创建任务
try:
tingwu_response = service.create_transcription_task(oss_url)
# 兼容处理响应结构,通常为 {"Data": {"TaskId": "...", ...}}
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
task_id = tingwu_response['Data'].get('TaskId')
else:
task_id = tingwu_response.get('TaskId')
if task_id:
task_record.task_id = task_id
task_record.status = TranscriptionTask.Status.PROCESSING
task_record.save()
else:
task_record.status = TranscriptionTask.Status.FAILED
task_record.error_message = "未能获取 TaskId"
task_record.save()
return Response({'error': '未能获取 TaskId'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
task_record.status = TranscriptionTask.Status.FAILED
task_record.error_message = str(e)
task_record.save()
logger.error(f"创建听悟任务失败: {e}")
return Response({'error': f"创建听悟任务失败: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
serializer = self.get_serializer(task_record)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"处理上传请求失败: {e}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'])
@extend_schema(
request={
'application/json': {
'type': 'object',
'properties': {
'model_selection': {'type': 'string', 'description': '模型选择'},
'prompt': {'type': 'string', 'description': '评分提示词'},
}
}
},
responses={200: AIEvaluationSerializer(many=True)}
)
def evaluate(self, request, pk=None):
"""
触发AI评估
"""
task = self.get_object()
# 1. 如果有 active template触发所有 active template
# 2. 如果请求体提供了 custom prompt则创建一个 custom evaluation (no template)
from .models import AIEvaluationTemplate
from .bailian_service import BailianService
service = BailianService()
evaluations_to_process = []
# A. 如果指定了 Prompt/Model视为手动单次评估
model_selection = request.data.get('model_selection')
prompt = request.data.get('prompt')
if prompt:
# 创建一个不关联 Template 的评估
eval, _ = AIEvaluation.objects.get_or_create(
task=task,
template=None,
defaults={
'model_selection': model_selection or 'qwen-plus',
'prompt': prompt
}
)
# 更新配置
eval.model_selection = model_selection or eval.model_selection
eval.prompt = prompt
eval.save()
evaluations_to_process.append(eval)
else:
# B. 否则触发所有 Active Templates
active_templates = AIEvaluationTemplate.objects.filter(is_active=True)
if not active_templates.exists():
return Response({'message': 'No active templates and no custom prompt provided'}, status=status.HTTP_400_BAD_REQUEST)
for t in active_templates:
eval, _ = AIEvaluation.objects.get_or_create(
task=task,
template=t,
defaults={
'model_selection': t.model_selection,
'prompt': t.prompt
}
)
# 始终更新为模板最新配置? 或者保留历史? 用户意图似乎是"模版搭好...启用...生成几份"
# 这里假设触发时应用模板当前配置
eval.model_selection = t.model_selection
eval.prompt = t.prompt
eval.save()
evaluations_to_process.append(eval)
# 执行评估 (改为异步并发执行)
# 提取ID列表避免传递模型对象导致可能的线程问题
eval_ids = [e.id for e in evaluations_to_process]
if eval_ids:
import threading
from concurrent.futures import ThreadPoolExecutor
def run_evaluations_background(ids):
# 在后台线程中重新引入依赖
from .models import AIEvaluation
from .bailian_service import BailianService
# 为该线程创建独立的服务实例
local_service = BailianService()
# 获取最新的对象
target_evals = AIEvaluation.objects.filter(id__in=ids)
# 使用线程池并发执行
# max_workers=4 可以同时处理4个评估请求
with ThreadPoolExecutor(max_workers=4) as executor:
executor.map(local_service.evaluate_task, target_evals)
# 启动后台线程,不阻塞当前 HTTP 请求
thread = threading.Thread(target=run_evaluations_background, args=(eval_ids,))
thread.daemon = True # 设置为守护线程
thread.start()
# 返回该任务的所有评估结果
all_evals = AIEvaluation.objects.filter(task=task)
serializer = AIEvaluationSerializer(all_evals, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'])
@extend_schema(
parameters=[
OpenApiParameter("id", OpenApiTypes.UUID, OpenApiParameter.PATH, description="Task ID"),
],
responses={200: TranscriptionTaskSerializer}
)
def refresh_status(self, request, pk=None):
"""
刷新任务状态并获取结果
"""
task = self.get_object()
# 允许刷新的条件:
# 1. 任务未完成 (PENDING, PROCESSING)
# 2. 任务已完成但逐字稿 (transcription) 为空
# 3. 任务已完成但 AI总结 (summary) 为空 (新增)
should_refresh = False
if task.status not in [TranscriptionTask.Status.SUCCEEDED, TranscriptionTask.Status.FAILED]:
should_refresh = True
elif task.status == TranscriptionTask.Status.SUCCEEDED:
if not task.transcription or not task.summary:
should_refresh = True
if not should_refresh:
serializer = self.get_serializer(task)
return Response(serializer.data)
if not task.task_id:
return Response({'error': '任务ID不存在'}, status=status.HTTP_400_BAD_REQUEST)
service = AliyunTingwuService()
try:
result = service.get_task_info(task.task_id)
# 兼容处理响应结构 {"Data": {"TaskStatus": "...", "Result": ...}}
# 有些情况下 SDK 返回的是 JSON 字符串,需要二次解析
if isinstance(result, str):
import json
try:
result = json.loads(result)
except:
pass
if isinstance(result, dict):
data_obj = result.get('Data', result)
else:
data_obj = result
if not isinstance(data_obj, dict):
# 如果 Data 不是字典,可能它本身就是字符串,或者 result 结构更平铺
data_obj = result
# 防御性编程:确保 data_obj 是字典
if not isinstance(data_obj, dict):
logger.error(f"Unexpected response format: {type(data_obj)} - {data_obj}")
return Response({'error': f"Unexpected response format: {type(data_obj)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# 调用 Service 进行解析和更新
service.parse_and_update_task(task, result)
# 如果任务成功但 AI 总结仍为空 (可能之前解析没触发,或者大模型调用失败)
# 再次尝试强制触发 summarize_task (如果原始数据存在)
# 注意service.parse_and_update_task 内部已经尝试异步触发,这里作为补救措施
if task.status == TranscriptionTask.Status.SUCCEEDED and not task.summary:
if task.summary_data or task.auto_chapters_data:
try:
# 先设置状态为 "AI总结生成当中..."
task.summary = "AI总结生成当中..."
task.save(update_fields=['summary'])
# 异步触发总结生成
import threading
from .bailian_service import BailianService
def async_summarize(task_id):
try:
# 重新获取 task 对象以避免线程问题
from .models import TranscriptionTask
task_obj = TranscriptionTask.objects.get(id=task_id)
bailian_service = BailianService()
bailian_service.summarize_task(task_obj)
except Exception as e:
logger.error(f"Async summary generation failed: {e}")
threading.Thread(target=async_summarize, args=(task.id,)).start()
except Exception as e:
logger.error(f"Force trigger AI summarization failed: {e}")
# 重新获取 task 以包含更新后的关联字段
task.refresh_from_db()
serializer = self.get_serializer(task)
return Response(serializer.data)
except Exception as e:
logger.error(f"刷新任务状态失败: {e}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -1,8 +1,11 @@
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import path, reverse
from django.shortcuts import redirect
from unfold.admin import ModelAdmin, TabularInline from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import display from unfold.decorators import display
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .admin_actions import export_signups_csv, export_signups_excel
class ActivitySignupInline(TabularInline): class ActivitySignupInline(TabularInline):
model = ActivitySignup model = ActivitySignup
@@ -28,19 +31,108 @@ class TopicMediaInline(TabularInline):
readonly_fields = ('created_at',) readonly_fields = ('created_at',)
can_delete = True can_delete = True
class OrderableAdminMixin:
"""
为 Admin 添加排序功能的 Mixin
提供上移、下移按钮,直接交换 order 值
"""
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('<path:object_id>/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'),
path('<path:object_id>/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'),
]
return custom_urls + urls
def move_up_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
qs = self.model.objects.all()
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
if hasattr(obj, 'is_pinned'):
qs = qs.filter(is_pinned=obj.is_pinned)
# 找到排在它前面的一个 (order 小于它的最大值)
prev_obj = qs.filter(order__lt=obj.order).order_by('-order').first()
if prev_obj:
# 交换
obj.order, prev_obj.order = prev_obj.order, obj.order
obj.save()
prev_obj.save()
self.message_user(request, f"成功将 {obj} 上移")
else:
# 已经是第一个,或者前面没有更小的 order
pass
return redirect(request.META.get('HTTP_REFERER', '..'))
def move_down_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
qs = self.model.objects.all()
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
if hasattr(obj, 'is_pinned'):
qs = qs.filter(is_pinned=obj.is_pinned)
# 找到排在它后面的一个 (order 大于它的最小值)
next_obj = qs.filter(order__gt=obj.order).order_by('order').first()
if next_obj:
# 交换
obj.order, next_obj.order = next_obj.order, obj.order
obj.save()
next_obj.save()
self.message_user(request, f"成功将 {obj} 下移")
return redirect(request.META.get('HTTP_REFERER', '..'))
def order_actions(self, obj):
# 只有专家用户才显示排序按钮
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型WeChatUser有is_star字段
return "默认排序"
# 使用 inline style 实现基本样式
btn_style = (
"display: inline-flex; align-items: center; justify-content: center; "
"width: 26px; height: 26px; border-radius: 6px; "
"background-color: #f3f4f6; color: #4b5563; text-decoration: none; "
"border: 1px solid #e5e7eb; transition: all 0.2s;"
)
# onmouseover js
hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';"
out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';"
return format_html(
'<div style="display: flex; align-items: center; gap: 6px;">'
'<a href="{}" title="上移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>'
'</a>'
'<span style="font-weight: 700; font-family: system-ui, -apple-system, sans-serif; min-width: 20px; text-align: center; color: #374151; font-size: 13px;">{}</span>'
'<a href="{}" title="下移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
'</a>'
'</div>',
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]),
btn_style, hover_js, out_js,
obj.order,
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]),
btn_style, hover_js, out_js,
)
order_actions.short_description = "排序调节"
order_actions.allow_tags = True
@admin.register(Activity) @admin.register(Activity)
class ActivityAdmin(ModelAdmin): class ActivityAdmin(ModelAdmin):
list_display = ('title', 'banner_display', 'start_time', 'location', 'signup_count', 'is_active', 'created_at') list_display = ('title', 'author_info_display', 'banner_display', 'start_time', 'location', 'signup_count', 'is_visible', 'is_active', 'auto_confirm', 'created_at')
list_filter = ('is_active', 'start_time') list_filter = ('is_visible', 'is_active', 'auto_confirm', 'start_time')
search_fields = ('title', 'location') search_fields = ('title', 'location', 'author__phone_number')
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
raw_id_fields = ('author',)
inlines = [ActivitySignupInline] inlines = [ActivitySignupInline]
fieldsets = ( fieldsets = (
('基本信息', { ('基本信息', {
'fields': ('title', 'description', 'banner', 'banner_url', 'is_active') 'fields': ('title', 'author', 'description', 'banner', 'banner_url', 'is_visible', 'is_active', 'auto_confirm')
}), }),
('时间与地点', { ('费用与时间', {
'fields': ('start_time', 'end_time', 'location'), 'fields': ('is_paid', 'price', 'start_time', 'end_time', 'location'),
'classes': ('tab',) 'classes': ('tab',)
}), }),
('报名设置', { ('报名设置', {
@@ -49,6 +141,14 @@ class ActivityAdmin(ModelAdmin):
}), }),
) )
@display(description="发布者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="Banner") @display(description="Banner")
def banner_display(self, obj): def banner_display(self, obj):
if obj.banner: if obj.banner:
@@ -63,21 +163,41 @@ class ActivityAdmin(ModelAdmin):
@admin.register(ActivitySignup) @admin.register(ActivitySignup)
class ActivitySignupAdmin(ModelAdmin): class ActivitySignupAdmin(ModelAdmin):
list_display = ('activity', 'user', 'signup_time', 'status_label') list_display = ('activity', 'user_info_display', 'signup_time', 'status_label', 'order_link')
list_filter = ('status', 'signup_time', 'activity') list_filter = ('status', 'signup_time', 'activity')
search_fields = ('user__nickname', 'activity__title') search_fields = ('user__nickname', 'user__phone_number', 'activity__title')
autocomplete_fields = ['activity', 'user'] autocomplete_fields = ['activity', 'user']
actions = [export_signups_csv, export_signups_excel]
fieldsets = ( fieldsets = (
('报名详情', { ('报名详情', {
'fields': ('activity', 'user', 'status') 'fields': ('activity', 'user', 'status', 'order', 'signup_info_display')
}), }),
('时间信息', { ('时间信息', {
'fields': ('signup_time',), 'fields': ('signup_time',),
'classes': ('collapse',) 'classes': ('collapse',)
}), }),
) )
readonly_fields = ('signup_time',) readonly_fields = ('signup_time', 'signup_info_display')
@display(description="报名用户 (手机号/昵称)")
def user_info_display(self, obj):
phone = obj.user.phone_number or "无手机号"
nickname = obj.user.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="报名信息")
def signup_info_display(self, obj):
import json
if not obj.signup_info:
return ""
try:
# Format JSON nicely
formatted_json = json.dumps(obj.signup_info, indent=2, ensure_ascii=False)
return format_html('<pre style="white-space: pre-wrap; word-break: break-all;">{}</pre>', formatted_json)
except:
return str(obj.signup_info)
@display( @display(
description="状态", description="状态",
@@ -85,29 +205,79 @@ class ActivitySignupAdmin(ModelAdmin):
"pending": "warning", "pending": "warning",
"confirmed": "success", "confirmed": "success",
"cancelled": "danger", "cancelled": "danger",
"unpaid": "secondary",
} }
) )
def status_label(self, obj): def status_label(self, obj):
# Auto sync with order status on display
if obj.check_payment_status():
# If status changed, return new status
return obj.status
return obj.status return obj.status
@display(description="关联订单")
def order_link(self, obj):
if obj.order:
return format_html('<a href="/admin/shop/order/{}/change/">Order #{}</a>', obj.order.id, obj.order.id)
return "-"
@admin.register(Topic) @admin.register(Topic)
class TopicAdmin(ModelAdmin): class TopicAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('title', 'category', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at') list_display = ('title', 'status', 'category', 'author_info_display', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at', 'order_actions')
list_filter = ('category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course') list_filter = ('status', 'category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
search_fields = ('title', 'content', 'author__nickname') search_fields = ('title', 'content', 'author__nickname', 'author__phone_number')
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course'] autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
filter_horizontal = ('likes',)
inlines = [TopicMediaInline, ReplyInline] inlines = [TopicMediaInline, ReplyInline]
actions = ['reset_ordering', 'approve_topics', 'reject_topics']
list_editable = ('status', 'is_pinned', 'view_count')
@display(description="作者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@admin.action(description="批量通过审核")
def approve_topics(self, request, queryset):
rows_updated = queryset.update(status='published')
self.message_user(request, f"{rows_updated} 个帖子已通过审核")
@admin.action(description="批量拒绝")
def reject_topics(self, request, queryset):
rows_updated = queryset.update(status='rejected')
self.message_user(request, f"{rows_updated} 个帖子已拒绝")
def save_model(self, request, obj, form, change):
# 当帖子被置顶时(新建或修改状态)默认将排序值设为0
if obj.is_pinned and (not change or 'is_pinned' in form.changed_data):
obj.order = 0
super().save_model(request, obj, form, change)
@admin.action(description="重置排序 (0,1,2... 新帖子在前)")
def reset_ordering(self, request, queryset):
"""
将所有帖子按时间倒序重新分配order值 (0, 1, 2, ...)
"""
all_objects = Topic.objects.all().order_by('-created_at')
for index, obj in enumerate(all_objects):
if obj.order != index:
obj.order = index
obj.save(update_fields=['order'])
self.message_user(request, f"成功重置了 {all_objects.count()} 个帖子的排序权重从0开始")
fieldsets = ( fieldsets = (
('帖子内容', { ('帖子内容', {
'fields': ('title', 'category', 'content', 'is_pinned') 'fields': ('title', 'status', 'category', 'content', 'is_pinned', 'likes')
}), }),
('关联信息', { ('关联信息', {
'fields': ('author', 'related_product', 'related_service', 'related_course'), 'fields': ('author', 'related_product', 'related_service', 'related_course'),
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论' 'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
}), }),
('统计数据', { ('统计数据', {
'fields': ('view_count', 'created_at', 'updated_at'), 'fields': ('view_count', 'order', 'created_at', 'updated_at'),
'classes': ('collapse',) 'classes': ('collapse',)
}), }),
) )
@@ -129,22 +299,36 @@ class TopicAdmin(ModelAdmin):
@admin.register(Reply) @admin.register(Reply)
class ReplyAdmin(ModelAdmin): class ReplyAdmin(ModelAdmin):
list_display = ('short_content', 'topic', 'author', 'created_at') list_display = ('short_content', 'topic', 'author_info_display', 'is_pinned', 'like_count', 'created_at')
list_filter = ('created_at',) list_filter = ('is_pinned', 'created_at')
search_fields = ('content', 'author__nickname', 'topic__title') search_fields = ('content', 'author__nickname', 'author__phone_number', 'topic__title')
autocomplete_fields = ['author', 'topic', 'reply_to'] autocomplete_fields = ['author', 'topic', 'reply_to']
filter_horizontal = ('likes',)
list_editable = ('is_pinned',)
inlines = [TopicMediaInline] inlines = [TopicMediaInline]
fieldsets = ( fieldsets = (
('回复内容', { ('回复内容', {
'fields': ('topic', 'reply_to', 'content') 'fields': ('topic', 'reply_to', 'content', 'likes')
}), }),
('发布信息', { ('发布信息', {
'fields': ('author', 'created_at') 'fields': ('author', 'is_pinned', 'created_at')
}), }),
) )
readonly_fields = ('created_at',) readonly_fields = ('created_at',)
@display(description="回复者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="点赞数")
def like_count(self, obj):
return obj.likes.count()
@display(description="内容摘要") @display(description="内容摘要")
def short_content(self, obj): def short_content(self, obj):
return obj.content[:30] + '...' if len(obj.content) > 30 else obj.content return obj.content[:30] + '...' if len(obj.content) > 30 else obj.content

View File

@@ -0,0 +1,149 @@
import csv
import json
import datetime
from django.http import HttpResponse
from django.utils.encoding import escape_uri_path
def flatten_json(y):
"""
Flatten a nested json object
"""
out = {}
def flatten(x, name=''):
if type(x) is dict:
for a in x:
flatten(x[a], name + a + '_')
elif type(x) is list:
i = 0
for a in x:
flatten(a, name + str(i) + '_')
i += 1
else:
out[name[:-1]] = x
flatten(y)
return out
def get_signup_info_keys(queryset):
"""
Collect all unique keys from the signup_info JSON across the queryset
"""
keys = set()
for obj in queryset:
if obj.signup_info and isinstance(obj.signup_info, dict):
# Flatten the dictionary first to get all nested keys
flat_info = flatten_json(obj.signup_info)
keys.update(flat_info.keys())
return sorted(list(keys))
def export_signups_csv(modeladmin, request, queryset):
"""
Export selected signups to CSV, including flattened JSON fields
"""
opts = modeladmin.model._meta
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
response = HttpResponse(content_type='text/csv; charset=utf-8-sig')
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
writer = csv.writer(response)
# Base fields to export
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
# Get dynamic JSON keys
json_keys = get_signup_info_keys(queryset)
# Write header
writer.writerow(base_headers + json_keys)
# Write data
for obj in queryset:
row = [
str(obj.id),
obj.activity.title,
obj.user.nickname if obj.user else 'Unknown',
str(obj.user.id) if obj.user else '',
obj.signup_time.strftime('%Y-%m-%d %H:%M:%S'),
obj.get_status_display(),
str(obj.order.id) if obj.order else ''
]
# Add JSON data
flat_info = {}
if obj.signup_info and isinstance(obj.signup_info, dict):
flat_info = flatten_json(obj.signup_info)
for key in json_keys:
val = flat_info.get(key, '')
if val is None:
val = ''
row.append(str(val))
writer.writerow(row)
return response
export_signups_csv.short_description = "导出选中报名记录为 CSV (含详细信息)"
def export_signups_excel(modeladmin, request, queryset):
"""
Export selected signups to Excel, including flattened JSON fields
"""
try:
from openpyxl import Workbook
except ImportError:
modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能", level='error')
return
opts = modeladmin.model._meta
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
wb = Workbook()
ws = wb.active
ws.title = str(opts.verbose_name)[:31] # Sheet name limit is 31 chars
# Base fields to export
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
# Get dynamic JSON keys
json_keys = get_signup_info_keys(queryset)
# Write header
ws.append(base_headers + json_keys)
# Write data
for obj in queryset:
row = [
obj.id,
obj.activity.title,
obj.user.nickname if obj.user else 'Unknown',
obj.user.id if obj.user else '',
obj.signup_time.replace(tzinfo=None) if obj.signup_time else '', # Remove tz for Excel
obj.get_status_display(),
obj.order.id if obj.order else ''
]
# Add JSON data
flat_info = {}
if obj.signup_info and isinstance(obj.signup_info, dict):
flat_info = flatten_json(obj.signup_info)
for key in json_keys:
val = flat_info.get(key, '')
if val is None:
val = ''
row.append(str(val)) # Ensure string for simplicity, or handle types
ws.append(row)
wb.save(response)
return response
export_signups_excel.short_description = "导出选中报名记录为 Excel (含详细信息)"

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-02-11 06:43 # Generated by Django 6.0.1 on 2026-03-04 04:57
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@@ -9,22 +9,55 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('shop', '0025_vccourse_alter_courseenrollment_course_and_more'), ('shop', '__first__'),
] ]
operations = [ operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='公告标题')),
('content', models.TextField(verbose_name='公告内容')),
('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')),
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')),
('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')),
('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')),
('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')),
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '社区公告',
'verbose_name_plural': '社区公告管理',
'ordering': ['-is_pinned', '-priority', '-created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Activity', name='Activity',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='活动标题')), ('title', models.CharField(max_length=100, verbose_name='活动标题')),
('description', models.TextField(verbose_name='活动详情')), ('description', models.TextField(verbose_name='活动详情')),
('banner', models.ImageField(upload_to='activities/banners/', verbose_name='活动Banner图')), ('banner', models.ImageField(blank=True, null=True, upload_to='activities/banners/', verbose_name='活动Banner图')),
('banner_url', models.URLField(blank=True, help_text='可直接填写图片链接,若同时上传图片,将优先显示上传的图片', null=True, verbose_name='活动Banner链接')),
('start_time', models.DateTimeField(verbose_name='开始时间')), ('start_time', models.DateTimeField(verbose_name='开始时间')),
('end_time', models.DateTimeField(verbose_name='结束时间')), ('end_time', models.DateTimeField(verbose_name='结束时间')),
('location', models.CharField(max_length=100, verbose_name='活动地点')), ('location', models.CharField(max_length=100, verbose_name='活动地点')),
('max_participants', models.IntegerField(default=50, verbose_name='最大报名人数')), ('max_participants', models.IntegerField(default=50, verbose_name='最大报名人数')),
('is_paid', models.BooleanField(default=False, verbose_name='是否收费')),
('price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='报名费用')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')), ('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('is_visible', models.BooleanField(default=True, help_text='关闭后将不在前端列表页显示', verbose_name='是否显示')),
('auto_confirm', models.BooleanField(default=False, help_text='开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核', verbose_name='无需审核')),
('ask_name', models.BooleanField(default=False, verbose_name='收集姓名')),
('ask_phone', models.BooleanField(default=False, verbose_name='收集手机号')),
('ask_wechat', models.BooleanField(default=False, verbose_name='收集微信号')),
('ask_company', models.BooleanField(default=False, verbose_name='收集公司/机构')),
('signup_form_config', models.JSONField(blank=True, default=list, help_text='JSON格式的高级配置若填写则优先于上方开关。例如[{"name": "job", "label": "职位", "type": "text", "required": true}]', verbose_name='自定义报名配置')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
], ],
options={ options={
@@ -37,34 +70,58 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='标题')), ('title', models.CharField(max_length=200, verbose_name='标题')),
('content', models.TextField(verbose_name='内容')), ('category', models.CharField(choices=[('discussion', '技术讨论'), ('help', '求助问答'), ('share', '经验分享'), ('notice', '官方公告')], default='discussion', max_length=20, verbose_name='分类')),
('status', models.CharField(choices=[('pending', '待审核'), ('published', '已发布'), ('rejected', '已拒绝')], default='published', max_length=20, verbose_name='状态')),
('content', models.TextField(help_text='支持Markdown格式支持插入图片', verbose_name='内容')),
('view_count', models.IntegerField(default=0, verbose_name='浏览量')), ('view_count', models.IntegerField(default=0, verbose_name='浏览量')),
('is_pinned', models.BooleanField(default=False, verbose_name='置顶')), ('is_pinned', models.BooleanField(default=False, verbose_name='置顶')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发布时间')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发布时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('order', models.IntegerField(default=0, verbose_name='排序')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='shop.wechatuser', verbose_name='作者')), ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='shop.wechatuser', verbose_name='作者')),
('related_product', models.ForeignKey(blank=True, help_text='如果是技术求助,请选择关联的硬件', null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')), ('likes', models.ManyToManyField(blank=True, related_name='liked_topics', to='shop.wechatuser', verbose_name='点赞用户')),
('related_course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程')),
('related_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')),
('related_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务')),
], ],
options={ options={
'verbose_name': '论坛帖子', 'verbose_name': '论坛帖子',
'verbose_name_plural': '论坛帖子管理', 'verbose_name_plural': '论坛帖子管理',
'ordering': ['-is_pinned', '-created_at'], 'ordering': ['order', '-is_pinned', '-created_at'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Reply', name='Reply',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(verbose_name='回复内容')), ('content', models.TextField(help_text='支持Markdown格式', verbose_name='回复内容')),
('is_pinned', models.BooleanField(default=False, verbose_name='置顶')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='回复时间')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='回复时间')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='shop.wechatuser', verbose_name='回复者')), ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='shop.wechatuser', verbose_name='回复者')),
('likes', models.ManyToManyField(blank=True, related_name='liked_replies', to='shop.wechatuser', verbose_name='点赞用户')),
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='community.reply', verbose_name='回复楼层')), ('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='community.reply', verbose_name='回复楼层')),
('topic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='community.topic', verbose_name='所属帖子')), ('topic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='community.topic', verbose_name='所属帖子')),
], ],
options={ options={
'verbose_name': '帖子回复', 'verbose_name': '帖子回复',
'verbose_name_plural': '帖子回复管理', 'verbose_name_plural': '帖子回复管理',
'ordering': ['created_at'], 'ordering': ['-is_pinned', '-created_at'],
},
),
migrations.CreateModel(
name='TopicMedia',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件')),
('file_url', models.URLField(blank=True, max_length=500, null=True, verbose_name='文件链接')),
('media_type', models.CharField(choices=[('image', '图片'), ('video', '视频'), ('file', '文件')], default='image', max_length=10, verbose_name='媒体类型')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')),
('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.reply', verbose_name='所属回复')),
('topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.topic', verbose_name='所属帖子')),
],
options={
'verbose_name': '论坛媒体资源',
'verbose_name_plural': '论坛媒体资源管理',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@@ -72,8 +129,10 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('signup_time', models.DateTimeField(auto_now_add=True, verbose_name='报名时间')), ('signup_time', models.DateTimeField(auto_now_add=True, verbose_name='报名时间')),
('status', models.CharField(choices=[('pending', '审核中'), ('confirmed', '报名成功'), ('cancelled', '已取消')], default='confirmed', max_length=20, verbose_name='状态')), ('signup_info', models.JSONField(blank=True, default=dict, verbose_name='报名信息')),
('status', models.CharField(choices=[('unpaid', '待支付'), ('pending', '审核中'), ('confirmed', '报名成功'), ('cancelled', '已取消')], default='confirmed', max_length=20, verbose_name='状态')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='community.activity', verbose_name='活动')), ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='community.activity', verbose_name='活动')),
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_signups', to='shop.order', verbose_name='关联订单')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_signups', to='shop.wechatuser', verbose_name='报名用户')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_signups', to='shop.wechatuser', verbose_name='报名用户')),
], ],
options={ options={

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-03-04 04:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0001_initial'),
('shop', '__first__'),
]
operations = [
migrations.AddField(
model_name='activity',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', verbose_name='发布者'),
),
]

View File

@@ -1,30 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-11 06:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0001_initial'),
('shop', '0025_vccourse_alter_courseenrollment_course_and_more'),
]
operations = [
migrations.AddField(
model_name='topic',
name='related_course',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程'),
),
migrations.AddField(
model_name='topic',
name='related_service',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务'),
),
migrations.AlterField(
model_name='topic',
name='related_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-03-17 11:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0002_activity_author'),
('shop', '0039_vccourse_video_embed_code'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', to_field='phone_number', verbose_name='发布者'),
),
]

View File

@@ -1,39 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-11 06:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0002_topic_related_course_topic_related_service_and_more'),
]
operations = [
migrations.AlterField(
model_name='reply',
name='content',
field=models.TextField(help_text='支持Markdown格式', verbose_name='回复内容'),
),
migrations.AlterField(
model_name='topic',
name='content',
field=models.TextField(help_text='支持Markdown格式支持插入图片', verbose_name='内容'),
),
migrations.CreateModel(
name='TopicMedia',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='community/media/', verbose_name='文件')),
('media_type', models.CharField(choices=[('image', '图片'), ('video', '视频'), ('file', '文件')], default='image', max_length=10, verbose_name='媒体类型')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')),
('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.reply', verbose_name='所属回复')),
('topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.topic', verbose_name='所属帖子')),
],
options={
'verbose_name': '论坛媒体资源',
'verbose_name_plural': '论坛媒体资源管理',
},
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-11 07:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0003_alter_reply_content_alter_topic_content_topicmedia'),
]
operations = [
migrations.AddField(
model_name='activity',
name='banner_url',
field=models.URLField(blank=True, help_text='可直接填写图片链接,若同时上传图片,将优先显示上传的图片', null=True, verbose_name='活动Banner链接'),
),
migrations.AlterField(
model_name='activity',
name='banner',
field=models.ImageField(blank=True, null=True, upload_to='activities/banners/', verbose_name='活动Banner图'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-12 06:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0004_activity_banner_url_alter_activity_banner'),
]
operations = [
migrations.AddField(
model_name='topic',
name='category',
field=models.CharField(choices=[('discussion', '技术讨论'), ('help', '求助问答'), ('share', '经验分享'), ('notice', '官方公告')], default='discussion', max_length=20, verbose_name='分类'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-12 06:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0005_topic_category'),
]
operations = [
migrations.AddField(
model_name='topicmedia',
name='file_url',
field=models.URLField(blank=True, max_length=500, null=True, verbose_name='文件链接'),
),
migrations.AlterField(
model_name='topicmedia',
name='file',
field=models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件'),
),
]

View File

@@ -1,36 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-12 06:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0006_topicmedia_file_url_alter_topicmedia_file'),
]
operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='公告标题')),
('content', models.TextField(verbose_name='公告内容')),
('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')),
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')),
('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')),
('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')),
('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')),
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '社区公告',
'verbose_name_plural': '社区公告管理',
'ordering': ['-is_pinned', '-priority', '-created_at'],
},
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-12 12:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0007_announcement'),
]
operations = [
migrations.AddField(
model_name='activity',
name='signup_form_config',
field=models.JSONField(blank=True, default=list, help_text='配置报名时需要收集的信息JSON格式例如[{"name": "phone", "label": "手机号", "type": "text", "required": true}]', verbose_name='报名表单配置'),
),
migrations.AddField(
model_name='activitysignup',
name='signup_info',
field=models.JSONField(blank=True, default=dict, verbose_name='报名信息'),
),
]

View File

@@ -1,38 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-12 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0008_activity_signup_form_config_and_more'),
]
operations = [
migrations.AddField(
model_name='activity',
name='ask_company',
field=models.BooleanField(default=False, verbose_name='收集公司/机构'),
),
migrations.AddField(
model_name='activity',
name='ask_name',
field=models.BooleanField(default=False, verbose_name='收集姓名'),
),
migrations.AddField(
model_name='activity',
name='ask_phone',
field=models.BooleanField(default=False, verbose_name='收集手机号'),
),
migrations.AddField(
model_name='activity',
name='ask_wechat',
field=models.BooleanField(default=False, verbose_name='收集微信号'),
),
migrations.AlterField(
model_name='activity',
name='signup_form_config',
field=models.JSONField(blank=True, default=list, help_text='JSON格式的高级配置若填写则优先于上方开关。例如[{"name": "job", "label": "职位", "type": "text", "required": true}]', verbose_name='自定义报名配置'),
),
]

View File

@@ -13,7 +13,16 @@ class Activity(models.Model):
end_time = models.DateTimeField(verbose_name="结束时间") end_time = models.DateTimeField(verbose_name="结束时间")
location = models.CharField(max_length=100, verbose_name="活动地点") location = models.CharField(max_length=100, verbose_name="活动地点")
max_participants = models.IntegerField(default=50, verbose_name="最大报名人数") max_participants = models.IntegerField(default=50, verbose_name="最大报名人数")
# 费用设置
is_paid = models.BooleanField(default=False, verbose_name="是否收费")
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="报名费用")
author = models.ForeignKey(WeChatUser, to_field='phone_number', on_delete=models.SET_NULL, related_name='activities', verbose_name="发布者", null=True, blank=True)
is_active = models.BooleanField(default=True, verbose_name="是否启用") is_active = models.BooleanField(default=True, verbose_name="是否启用")
is_visible = models.BooleanField(default=True, verbose_name="是否显示", help_text="关闭后将不在前端列表页显示")
auto_confirm = models.BooleanField(default=False, verbose_name="无需审核", help_text="开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核")
# 常用报名信息开关 # 常用报名信息开关
ask_name = models.BooleanField(default=False, verbose_name="收集姓名") ask_name = models.BooleanField(default=False, verbose_name="收集姓名")
@@ -50,9 +59,9 @@ class Activity(models.Model):
@property @property
def current_signups(self): def current_signups(self):
""" """
当前有效报名人数 当前有效报名人数(仅统计已确认/已支付的报名)
""" """
return self.signups.exclude(status='cancelled').count() return self.signups.filter(status='confirmed').count()
def __str__(self): def __str__(self):
return self.title return self.title
@@ -67,6 +76,7 @@ class ActivitySignup(models.Model):
活动报名记录 活动报名记录
""" """
STATUS_CHOICES = ( STATUS_CHOICES = (
('unpaid', '待支付'),
('pending', '审核中'), ('pending', '审核中'),
('confirmed', '报名成功'), ('confirmed', '报名成功'),
('cancelled', '已取消'), ('cancelled', '已取消'),
@@ -82,9 +92,23 @@ class ActivitySignup(models.Model):
) )
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态")
# 关联订单(针对付费活动)
order = models.ForeignKey('shop.Order', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联订单", related_name='activity_signups')
def __str__(self): def __str__(self):
return f"{self.user.nickname} - {self.activity.title}" return f"{self.user.nickname} - {self.activity.title}"
def check_payment_status(self):
"""
检查并同步关联订单的支付状态
"""
if self.status == 'unpaid' and self.order:
if self.order.status == 'paid':
self.status = 'confirmed' if self.activity.auto_confirm else 'pending'
self.save()
return True
return False
class Meta: class Meta:
verbose_name = "活动报名" verbose_name = "活动报名"
verbose_name_plural = "活动报名管理" verbose_name_plural = "活动报名管理"
@@ -105,6 +129,13 @@ class Topic(models.Model):
) )
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='discussion', verbose_name="分类") category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='discussion', verbose_name="分类")
STATUS_CHOICES = (
('pending', '待审核'),
('published', '已发布'),
('rejected', '已拒绝'),
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='published', verbose_name="状态")
content = models.TextField(verbose_name="内容", help_text="支持Markdown格式支持插入图片") content = models.TextField(verbose_name="内容", help_text="支持Markdown格式支持插入图片")
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='topics', verbose_name="作者") author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='topics', verbose_name="作者")
@@ -114,9 +145,26 @@ class Topic(models.Model):
related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程") related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程")
view_count = models.IntegerField(default=0, verbose_name="浏览量") view_count = models.IntegerField(default=0, verbose_name="浏览量")
likes = models.ManyToManyField(WeChatUser, related_name='liked_topics', blank=True, verbose_name="点赞用户")
is_pinned = models.BooleanField(default=False, verbose_name="置顶") is_pinned = models.BooleanField(default=False, verbose_name="置顶")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
order = models.IntegerField(default=0, verbose_name="排序")
def save(self, *args, **kwargs):
# 记录是否为新对象因为super().save后pk就有了
is_new = self.pk is None
# 第一次保存,先入库
super().save(*args, **kwargs)
# 如果是新创建,且 order 默认为 0未指定
if is_new and getattr(self, 'order', 0) == 0:
# 将所有其他帖子的 order + 1腾出 0 的位置
Topic.objects.exclude(pk=self.pk).filter(order__gte=0).update(order=models.F('order') + 1)
# 确保自己是 0
Topic.objects.filter(pk=self.pk).update(order=0)
self.order = 0
def __str__(self): def __str__(self):
return self.title return self.title
@@ -153,7 +201,7 @@ class Topic(models.Model):
class Meta: class Meta:
verbose_name = "论坛帖子" verbose_name = "论坛帖子"
verbose_name_plural = "论坛帖子管理" verbose_name_plural = "论坛帖子管理"
ordering = ['-is_pinned', '-created_at'] ordering = ['order', '-is_pinned', '-created_at']
class Reply(models.Model): class Reply(models.Model):
@@ -164,6 +212,8 @@ class Reply(models.Model):
content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式") content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式")
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者") author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者")
reply_to = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="回复楼层") reply_to = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="回复楼层")
likes = models.ManyToManyField(WeChatUser, related_name='liked_replies', blank=True, verbose_name="点赞用户")
is_pinned = models.BooleanField(default=False, verbose_name="置顶")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间")
def __str__(self): def __str__(self):
@@ -172,7 +222,7 @@ class Reply(models.Model):
class Meta: class Meta:
verbose_name = "帖子回复" verbose_name = "帖子回复"
verbose_name_plural = "帖子回复管理" verbose_name_plural = "帖子回复管理"
ordering = ['created_at'] ordering = ['-is_pinned', '-created_at']
class TopicMedia(models.Model): class TopicMedia(models.Model):

View File

@@ -8,18 +8,35 @@ class ActivitySerializer(serializers.ModelSerializer):
signup_form_config = serializers.SerializerMethodField() signup_form_config = serializers.SerializerMethodField()
current_signups = serializers.IntegerField(read_only=True) current_signups = serializers.IntegerField(read_only=True)
has_signed_up = serializers.SerializerMethodField() has_signed_up = serializers.SerializerMethodField()
is_signed_up = serializers.SerializerMethodField()
my_signup_status = serializers.SerializerMethodField()
class Meta: class Meta:
model = Activity model = Activity
fields = '__all__' fields = '__all__'
def get_has_signed_up(self, obj): def get_has_signed_up(self, obj):
return self.get_is_signed_up(obj)
def get_my_signup_status(self, obj):
request = self.context.get('request')
if not request:
return None
user = get_current_wechat_user(request)
if user:
# Return the status of the non-cancelled signup
signup = obj.signups.filter(user=user).exclude(status='cancelled').first()
return signup.status if signup else None
return None
def get_is_signed_up(self, obj):
request = self.context.get('request') request = self.context.get('request')
if not request: if not request:
return False return False
user = get_current_wechat_user(request) user = get_current_wechat_user(request)
if user: if user:
return obj.signups.filter(user=user).exists() # Check if there is a valid signup (only confirmed counts)
return obj.signups.filter(user=user, status='confirmed').exists()
return False return False
def get_signup_form_config(self, obj): def get_signup_form_config(self, obj):
@@ -71,12 +88,23 @@ class ReplySerializer(serializers.ModelSerializer):
write_only=True, write_only=True,
required=False required=False
) )
like_count = serializers.IntegerField(source='likes.count', read_only=True)
is_liked = serializers.SerializerMethodField()
class Meta: class Meta:
model = Reply model = Reply
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids'] fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids', 'is_pinned', 'like_count', 'is_liked']
read_only_fields = ['author', 'created_at'] read_only_fields = ['author', 'created_at']
def get_is_liked(self, obj):
request = self.context.get('request')
if not request:
return False
user = get_current_wechat_user(request)
if user:
return obj.likes.filter(id=user.id).exists()
return False
def create(self, validated_data): def create(self, validated_data):
media_ids = validated_data.pop('media_ids', []) media_ids = validated_data.pop('media_ids', [])
reply = super().create(validated_data) reply = super().create(validated_data)
@@ -89,6 +117,8 @@ class TopicSerializer(serializers.ModelSerializer):
replies = ReplySerializer(many=True, read_only=True) replies = ReplySerializer(many=True, read_only=True)
media = TopicMediaSerializer(many=True, read_only=True) media = TopicMediaSerializer(many=True, read_only=True)
is_verified_owner = serializers.BooleanField(read_only=True) is_verified_owner = serializers.BooleanField(read_only=True)
like_count = serializers.IntegerField(source='likes.count', read_only=True)
is_liked = serializers.SerializerMethodField()
product_info = ESP32ConfigSerializer(source='related_product', read_only=True) product_info = ESP32ConfigSerializer(source='related_product', read_only=True)
service_info = ServiceSerializer(source='related_service', read_only=True) service_info = ServiceSerializer(source='related_service', read_only=True)
@@ -103,14 +133,24 @@ class TopicSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Topic model = Topic
fields = [ fields = [
'id', 'title', 'category', 'content', 'author', 'author_info', 'id', 'title', 'category', 'status', 'content', 'author', 'author_info',
'related_product', 'product_info', 'related_product', 'product_info',
'related_service', 'service_info', 'related_service', 'service_info',
'related_course', 'course_info', 'related_course', 'course_info',
'view_count', 'is_pinned', 'created_at', 'updated_at', 'view_count', 'is_pinned', 'created_at', 'updated_at',
'is_verified_owner', 'replies', 'media', 'media_ids' 'is_verified_owner', 'replies', 'media', 'media_ids',
'like_count', 'is_liked'
] ]
read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner'] read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner', 'status']
def get_is_liked(self, obj):
request = self.context.get('request')
if not request:
return False
user = get_current_wechat_user(request)
if user:
return obj.likes.filter(id=user.id).exists()
return False
def create(self, validated_data): def create(self, validated_data):
media_ids = validated_data.pop('media_ids', []) media_ids = validated_data.pop('media_ids', [])
@@ -125,3 +165,26 @@ class AnnouncementSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Announcement model = Announcement
fields = '__all__' fields = '__all__'
class AdminActivitySerializer(serializers.ModelSerializer):
signup_form_config = serializers.JSONField(required=False)
description = serializers.CharField(
style={'base_template': 'textarea.html'},
help_text="活动详情内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
)
class Meta:
model = Activity
fields = '__all__'
read_only_fields = ['author', 'created_at', 'current_signups']
class AdminTopicSerializer(serializers.ModelSerializer):
content = serializers.CharField(
style={'base_template': 'textarea.html'},
help_text="帖子内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
)
class Meta:
model = Topic
fields = '__all__'
read_only_fields = ['author', 'created_at', 'updated_at', 'view_count', 'is_verified_owner']

View File

@@ -1,6 +1,6 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet, AdminPublishViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'activities', ActivityViewSet) router.register(r'activities', ActivityViewSet)
@@ -8,6 +8,7 @@ router.register(r'topics', TopicViewSet)
router.register(r'replies', ReplyViewSet) router.register(r'replies', ReplyViewSet)
router.register(r'media', TopicMediaViewSet, basename='media') router.register(r'media', TopicMediaViewSet, basename='media')
router.register(r'announcements', AnnouncementViewSet) router.register(r'announcements', AnnouncementViewSet)
router.register(r'admin-publish', AdminPublishViewSet, basename='admin-publish')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),

View File

@@ -6,11 +6,12 @@ from rest_framework import serializers, permissions
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.utils import timezone from django.utils import timezone
from django.db import models from django.db import models
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from shop.models import WeChatUser from shop.models import WeChatUser, Order
from shop.views import get_wechat_pay_client
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer, AdminActivitySerializer, AdminTopicSerializer
from .utils import get_current_wechat_user from .utils import get_current_wechat_user
from .permissions import IsAuthorOrReadOnly from .permissions import IsAuthorOrReadOnly
@@ -21,8 +22,26 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Activity.objects.filter(is_active=True).order_by('-created_at') queryset = Activity.objects.filter(is_active=True).order_by('-created_at')
serializer_class = ActivitySerializer serializer_class = ActivitySerializer
def get_queryset(self):
qs = super().get_queryset()
# list 接口过滤 is_visible=True
if self.action == 'list':
qs = qs.filter(is_visible=True)
return qs
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
# Sync status for current user
user = get_current_wechat_user(request)
if user:
# Use filter to avoid exception if multiple exist (though unique_together constraint exists)
signup = instance.signups.filter(user=user).exclude(status='cancelled').first()
if signup:
has_changed = signup.check_payment_status()
if has_changed:
print(f"DEBUG: Synced signup status for user {user.id} activity {instance.id}")
serializer = self.get_serializer(instance) serializer = self.get_serializer(instance)
# Debug print to verify data # Debug print to verify data
print(f"DEBUG: Activity {instance.title} current_signups: {instance.current_signups}") print(f"DEBUG: Activity {instance.title} current_signups: {instance.current_signups}")
@@ -37,24 +56,25 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
activity = self.get_object() activity = self.get_object()
# Check if already signed up # 1. Check confirmed signup
if ActivitySignup.objects.filter(activity=activity, user=user).exists(): if ActivitySignup.objects.filter(activity=activity, user=user, status='confirmed').exists():
return Response({'error': '您已报名该活动'}, status=400) return Response({'error': '您已报名该活动'}, status=400)
# Check limit (exclude cancelled) # 2. Get pending signup (for retry)
current_count = activity.signups.exclude(status='cancelled').count() pending_signup = ActivitySignup.objects.filter(activity=activity, user=user, status='pending').first()
if current_count >= activity.max_participants:
# 3. Check limit (exclude cancelled, exclude current pending)
query = activity.signups.exclude(status='cancelled')
if pending_signup:
query = query.exclude(id=pending_signup.id)
if query.count() >= activity.max_participants:
return Response({'error': '活动名额已满'}, status=400) return Response({'error': '活动名额已满'}, status=400)
# Get signup info # Get signup info
signup_info = request.data.get('signup_info', {}) signup_info = request.data.get('signup_info', {})
# Basic validation # Validate signup info
# Re-fetch the config from the object method or serializer logic if needed,
# but here we can just use the serializer's method to get the effective config.
# However, accessing serializer method from view is tricky without instantiating.
# Let's replicate the logic or rely on the fact that we can construct it.
effective_config = activity.signup_form_config effective_config = activity.signup_form_config
if not effective_config: if not effective_config:
effective_config = [] effective_config = []
@@ -70,18 +90,147 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
if effective_config: if effective_config:
required_fields = [f['name'] for f in effective_config if f.get('required')] required_fields = [f['name'] for f in effective_config if f.get('required')]
for field in required_fields: for field in required_fields:
# Simple check: field exists and is not empty string (if it's a string)
val = signup_info.get(field) val = signup_info.get(field)
if val is None or (isinstance(val, str) and not val.strip()): if val is None or (isinstance(val, str) and not val.strip()):
# Try to find label for better error message
label = next((f['label'] for f in effective_config if f['name'] == field), field) label = next((f['label'] for f in effective_config if f['name'] == field), field)
return Response({'error': f'请填写: {label}'}, status=400) return Response({'error': f'请填写: {label}'}, status=400)
# Handle Payment Logic
if activity.is_paid and activity.price > 0:
import time
from wechatpayv3 import WeChatPayType
# Create or Get Order
order = None
if pending_signup and pending_signup.order:
# Reuse existing order if it's pending
if pending_signup.order.status == 'pending':
order = pending_signup.order
# Update contact info if needed
contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User'
contact_phone = signup_info.get('phone') or user.phone_number or ''
if contact_name: order.customer_name = contact_name
if contact_phone: order.phone_number = contact_phone
# Ensure activity is linked
if not order.activity:
order.activity = activity
order.save()
if not order:
# Check independent pending order
pending_order = Order.objects.filter(
wechat_user=user,
activity=activity,
status='pending'
).first()
if pending_order:
order = pending_order
# Ensure shipping address is up-to-date
order.shipping_address = activity.location or '线下活动'
order.save()
else:
contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User'
contact_phone = signup_info.get('phone') or user.phone_number or ''
order = Order.objects.create(
wechat_user=user,
activity=activity,
total_price=activity.price,
status='pending',
quantity=1,
customer_name=contact_name,
phone_number=contact_phone,
shipping_address=activity.location or '线下活动',
)
# Generate Pay Code
out_trade_no = f"ACT{activity.id}U{user.id}T{int(time.time())}"
order.out_trade_no = out_trade_no
order.save()
wxpay, error_msg = get_wechat_pay_client(pay_type=WeChatPayType.NATIVE)
if not wxpay:
return Response({'error': f'支付配置错误: {error_msg}'}, status=500)
code, message = wxpay.pay(
description=f"报名活动: {activity.title}",
out_trade_no=out_trade_no,
amount={
'total': int(activity.price * 100),
'currency': 'CNY'
},
notify_url=wxpay._notify_url,
attach=f'{{"type":"activity","activity_id":{activity.id}}}'
)
import json
result = json.loads(message)
if code in range(200, 300):
code_url = result.get('code_url')
if pending_signup:
pending_signup.signup_info = signup_info
pending_signup.order = order
pending_signup.status = 'unpaid' # Explicitly set to unpaid
pending_signup.save()
else:
ActivitySignup.objects.create(
activity=activity,
user=user,
signup_info=signup_info,
status='unpaid',
order=order
)
return Response({
'payment_required': True,
'code_url': code_url,
'order_id': order.id,
'price': activity.price,
'message': '请完成支付'
}, status=200)
else:
return Response({'error': '支付接口调用失败', 'detail': result}, status=500)
# Free Activity Signup
# Check auto_confirm
status_val = 'confirmed' if activity.auto_confirm else 'pending'
signup = ActivitySignup.objects.create( signup = ActivitySignup.objects.create(
activity=activity, activity=activity,
user=user, user=user,
signup_info=signup_info signup_info=signup_info,
status=status_val
) )
# Send SMS for free activity signup (if confirmed)
if status_val == 'confirmed':
try:
from shop.sms_utils import notify_user_activity_signup_success
# Mock an order object for the SMS template
# The template expects: customer_name, wechat_user, phone_number
class MockOrder:
def __init__(self, user, signup_info):
# Ensure we get the name and phone from signup_info first
# signup_info keys might vary, let's try common ones
self.customer_name = signup_info.get('name') or signup_info.get('username') or user.nickname or "用户"
self.wechat_user = user
self.phone_number = signup_info.get('phone') or signup_info.get('mobile') or user.phone_number or ""
mock_order = MockOrder(user, signup_info)
# Check if we have a valid phone number before sending
if mock_order.phone_number:
notify_user_activity_signup_success(mock_order, signup)
else:
print(f"Skipping SMS for signup {signup.id}: No phone number found")
except Exception as e:
print(f"发送免费活动报名短信失败: {str(e)}")
serializer = ActivitySignupSerializer(signup) serializer = ActivitySignupSerializer(signup)
return Response(serializer.data, status=201) return Response(serializer.data, status=201)
@@ -92,6 +241,11 @@ class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
if not user: if not user:
return Response({'error': '请先登录'}, status=401) return Response({'error': '请先登录'}, status=401)
signups = ActivitySignup.objects.filter(user=user).order_by('-signup_time') signups = ActivitySignup.objects.filter(user=user).order_by('-signup_time')
# Sync payment status
for signup in signups:
signup.check_payment_status()
serializer = ActivitySignupSerializer(signups, many=True) serializer = ActivitySignupSerializer(signups, many=True)
return Response(serializer.data) return Response(serializer.data)
@@ -105,14 +259,24 @@ class TopicViewSet(viewsets.ModelViewSet):
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['title', 'content'] search_fields = ['title', 'content']
filterset_fields = ['category', 'is_pinned'] filterset_fields = ['category', 'is_pinned']
ordering_fields = ['created_at', 'view_count'] ordering_fields = ['created_at', 'view_count', 'order']
ordering = ['-is_pinned', '-created_at'] ordering = ['-is_pinned', 'order', '-created_at']
def get_queryset(self):
qs = super().get_queryset()
# 列表接口仅显示已发布的帖子
if self.action == 'list':
qs = qs.filter(status='published')
return qs
def perform_create(self, serializer): def perform_create(self, serializer):
user = get_current_wechat_user(self.request) user = get_current_wechat_user(self.request)
# Auth check is done in create or permission, but here we need user for save # Auth check is done in create or permission, but here we need user for save
if user: if user:
serializer.save(author=user) # 如果关联了系统用户(user字段不为空),则是管理员/内部人员,直接发布
# 否则进入审核流程
status = 'published' if user.user else 'pending'
serializer.save(author=user, status=status)
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
user = get_current_wechat_user(request) user = get_current_wechat_user(request)
@@ -120,6 +284,22 @@ class TopicViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401) return Response({'error': '请先登录'}, status=401)
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
def like(self, request, pk=None):
obj = self.get_object()
user = get_current_wechat_user(request)
if not user:
return Response({'error': '请先登录'}, status=401)
if obj.likes.filter(id=user.id).exists():
obj.likes.remove(user)
liked = False
else:
obj.likes.add(user)
liked = True
return Response({'liked': liked, 'count': obj.likes.count()})
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
instance.view_count += 1 instance.view_count += 1
@@ -146,6 +326,22 @@ class ReplyViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401) return Response({'error': '请先登录'}, status=401)
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
def like(self, request, pk=None):
obj = self.get_object()
user = get_current_wechat_user(request)
if not user:
return Response({'error': '请先登录'}, status=401)
if obj.likes.filter(id=user.id).exists():
obj.likes.remove(user)
liked = False
else:
obj.likes.add(user)
liked = True
return Response({'liked': liked, 'count': obj.likes.count()})
import requests import requests
class TopicMediaViewSet(viewsets.ViewSet): class TopicMediaViewSet(viewsets.ViewSet):
@@ -215,3 +411,106 @@ class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet):
# Filter by end_time (if set, must be >= now) # Filter by end_time (if set, must be >= now)
qs = qs.filter(models.Q(end_time__isnull=True) | models.Q(end_time__gte=now)) qs = qs.filter(models.Q(end_time__isnull=True) | models.Q(end_time__gte=now))
return qs.order_by('-is_pinned', '-priority', '-created_at') return qs.order_by('-is_pinned', '-priority', '-created_at')
class AdminPublishViewSet(viewsets.ViewSet):
"""
管理员/API发布接口
"""
permission_classes = []
authentication_classes = []
def check_api_key(self, request):
key = request.headers.get('X-API-KEY') or request.query_params.get('apikey')
if key != '123quant-speed':
return False
return True
def get_admin_user_by_phone(self, phone):
if not phone:
return None
# Find WeChatUser by phone
user = WeChatUser.objects.filter(phone_number=phone).first()
if not user:
return None
# Check if linked to a system user and has admin privileges (is_staff)
if user.user and user.user.is_staff:
return user
return None
@extend_schema(
summary="API发布活动",
request=AdminActivitySerializer,
parameters=[
OpenApiParameter(
name='apikey',
description='API访问密钥',
required=True,
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY
),
OpenApiParameter(
name='phone_number',
description='管理员手机号 (用于关联发布者)',
required=True,
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY
)
]
)
@action(detail=False, methods=['post'])
def publish_activity(self, request):
if not self.check_api_key(request):
return Response({'error': 'Invalid API Key'}, status=403)
phone = request.data.get('phone_number') or request.query_params.get('phone_number')
user = self.get_admin_user_by_phone(phone)
if not user:
return Response({'error': 'Admin user not found with this phone number'}, status=404)
data = request.data.copy()
serializer = AdminActivitySerializer(data=data)
if serializer.is_valid():
activity = serializer.save(author=user)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
@extend_schema(
summary="API发布帖子",
request=AdminTopicSerializer,
parameters=[
OpenApiParameter(
name='apikey',
description='API访问密钥',
required=True,
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY
),
OpenApiParameter(
name='phone_number',
description='管理员手机号 (用于关联发布者)',
required=True,
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY
)
]
)
@action(detail=False, methods=['post'])
def publish_topic(self, request):
if not self.check_api_key(request):
return Response({'error': 'Invalid API Key'}, status=403)
phone = request.data.get('phone_number') or request.query_params.get('phone_number')
user = self.get_admin_user_by_phone(phone)
if not user:
return Response({'error': 'Admin user not found with this phone number'}, status=404)
data = request.data.copy()
serializer = AdminTopicSerializer(data=data)
if serializer.is_valid():
# Only set status to published if not provided, otherwise respect the input
status = data.get('status', 'published')
topic = serializer.save(author=user, status=status)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)

View File

View File

@@ -0,0 +1,210 @@
from django.contrib import admin
from unfold.admin import ModelAdmin
from unfold.decorators import display
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, ScoreFormula
class ScoreDimensionInline(admin.TabularInline):
model = ScoreDimension
extra = 1
tab = True
fields = ('dimension_id_display', 'name', 'description', 'weight', 'max_score', 'formula_type', 'formula', 'is_public', 'is_peer_review', 'order')
readonly_fields = ('dimension_id_display',)
@admin.display(description="维度ID")
def dimension_id_display(self, obj):
return f"dimension_{obj.id}" if obj.id else "(新建)"
@admin.display(description="算式预览")
def formula_preview(self, obj):
preview = obj.get_formula_preview()
return preview if preview else "-"
class ScoreFormulaInline(admin.TabularInline):
model = ScoreFormula
extra = 1
tab = True
fields = ('name', 'formula', 'is_active', 'is_default')
@admin.display(description="公式预览")
def formula_preview_display(self, obj):
preview = obj.get_formula_preview()
return preview[:50] + '...' if len(preview) > 50 else preview if preview else '-'
class ProjectFileInline(admin.TabularInline):
model = ProjectFile
extra = 0
tab = True
@admin.register(Competition)
class CompetitionAdmin(ModelAdmin):
list_display = ['title', 'status', 'allow_contestant_grading', 'start_time', 'end_time', 'is_active', 'created_at']
list_filter = ['status', 'allow_contestant_grading', 'is_active']
search_fields = ['title', 'description']
inlines = [ScoreDimensionInline, ScoreFormulaInline]
autocomplete_fields = ['active_formula']
fieldsets = (
('基本信息', {
'fields': ('title', 'description', 'rule_description', 'condition_description')
}),
('封面设置', {
'fields': ('cover_image', 'cover_image_url'),
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
}),
('时间和状态', {
'fields': ('start_time', 'end_time', 'status', 'project_visibility', 'allow_contestant_grading', 'is_active')
}),
('评分配置', {
'fields': ('score_calculation_type', 'active_formula'),
'description': '配置得分计算方式:默认加权平均或使用评分公式'
}),
)
actions = ['make_published', 'make_ended']
def make_published(self, request, queryset):
queryset.update(status='published')
make_published.short_description = "发布选中比赛"
def make_ended(self, request, queryset):
queryset.update(status='ended')
make_ended.short_description = "结束选中比赛"
class Media:
css = {
'all': ('competition/admin/css/competition-admin.css',)
}
js = ('competition/admin/js/competition-admin.js',)
@admin.register(CompetitionEnrollment)
class CompetitionEnrollmentAdmin(ModelAdmin):
list_display = ['competition', 'user_info_display', 'role', 'status', 'created_at']
list_filter = ['competition', 'role', 'status']
search_fields = ['user__nickname', 'user__phone_number', 'competition__title']
autocomplete_fields = ['user', 'competition']
actions = ['approve_enrollment', 'reject_enrollment']
@display(description="报名用户 (手机号/昵称)")
def user_info_display(self, obj):
if not obj.user:
return "-"
phone = obj.user.phone_number or "无手机号"
nickname = obj.user.nickname or "无昵称"
return f"{phone} ({nickname})"
def approve_enrollment(self, request, queryset):
queryset.update(status='approved')
approve_enrollment.short_description = "通过审核"
def reject_enrollment(self, request, queryset):
queryset.update(status='rejected')
reject_enrollment.short_description = "拒绝申请"
@admin.register(Project)
class ProjectAdmin(ModelAdmin):
list_display = ['id', 'title', 'competition', 'contestant_info_display', 'status', 'final_score', 'created_at']
list_filter = ['competition', 'status']
search_fields = ['id', 'title', 'contestant__user__nickname', 'contestant__user__phone_number']
autocomplete_fields = ['competition', 'contestant']
inlines = [ProjectFileInline]
readonly_fields = ['id', 'final_score']
fieldsets = (
('基本信息', {
'fields': ('competition', 'contestant', 'title', 'description', 'team_info')
}),
('封面设置', {
'fields': ('cover_image', 'cover_image_url'),
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
}),
('状态和得分', {
'fields': ('status', 'final_score')
}),
)
@display(description="参赛人员 (手机号/昵称)")
def contestant_info_display(self, obj):
if not obj.contestant or not obj.contestant.user:
return "-"
user = obj.contestant.user
phone = user.phone_number or "无手机号"
nickname = user.nickname or "无昵称"
return f"{phone} ({nickname})"
@admin.register(Score)
class ScoreAdmin(ModelAdmin):
list_display = ['project', 'judge_info_display', 'dimension', 'score', 'created_at']
list_filter = ['project__competition', 'dimension']
search_fields = ['project__title', 'judge__user__nickname', 'judge__user__phone_number']
autocomplete_fields = ['project', 'judge']
@display(description="评委 (手机号/昵称)")
def judge_info_display(self, obj):
if not obj.judge or not obj.judge.user:
return "-"
user = obj.judge.user
phone = user.phone_number or "无手机号"
nickname = user.nickname or "无昵称"
return f"{phone} ({nickname})"
@admin.register(Comment)
class CommentAdmin(ModelAdmin):
list_display = ['project', 'judge_info_display', 'content_preview', 'created_at']
list_filter = ['project__competition']
search_fields = ['project__title', 'judge__user__nickname', 'judge__user__phone_number', 'content']
autocomplete_fields = ['project', 'judge']
@display(description="评委 (手机号/昵称)")
def judge_info_display(self, obj):
if not obj.judge or not obj.judge.user:
return "-"
user = obj.judge.user
phone = user.phone_number or "无手机号"
nickname = user.nickname or "无昵称"
return f"{phone} ({nickname})"
def content_preview(self, obj):
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
content_preview.short_description = "评语内容"
class ScoreFormulaAdmin(ModelAdmin):
list_display = ['name', 'competition', 'formula_preview_display', 'is_active', 'is_default', 'created_at']
list_filter = ['competition', 'is_active', 'is_default']
search_fields = ['name', 'description', 'formula', 'competition__title']
autocomplete_fields = ['competition']
fieldsets = (
('基本信息', {
'fields': ('competition', 'name', 'description')
}),
('公式配置', {
'fields': ('formula',),
'description': '使用 dimension_X 作为变量X为维度ID例如: (dimension_1 + dimension_2) / 2'
}),
('公式设置', {
'fields': ('is_active', 'is_default')
}),
)
@display(description="公式预览")
def formula_preview_display(self, obj):
preview = obj.get_formula_preview()
return preview[:100] + '...' if len(preview) > 100 else preview if preview else '-'
class Media:
css = {
'all': ('competition/admin/css/competition-admin.css', 'competition/admin/css/formula-editor.css')
}
js = ('competition/admin/js/competition-admin.js', 'competition/admin/js/formula-editor.js')
admin.site.register(ScoreFormula, ScoreFormulaAdmin)

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CompetitionConfig(AppConfig):
name = 'competition'

View File

@@ -0,0 +1,21 @@
from django.urls import path
from django.views.generic import RedirectView
from . import judge_views
urlpatterns = [
# 默认跳转到登录页
path('', RedirectView.as_view(url='login/', permanent=False), name='judge_index'),
path('login/', judge_views.login_view, name='judge_login'),
path('logout/', judge_views.logout_view, name='judge_logout'),
path('send_code/', judge_views.send_code, name='judge_send_code'),
path('dashboard/', judge_views.dashboard, name='judge_dashboard'),
path('upload/', judge_views.upload_audio, name='judge_upload'),
path('ai/manage/', judge_views.ai_manage, name='judge_ai_manage'),
# API
path('api/projects/<int:project_id>/', judge_views.project_detail_api, name='judge_project_detail_api'),
path('api/score/submit/', judge_views.submit_score, name='judge_submit_score'),
path('api/upload/', judge_views.upload_audio, name='judge_api_upload'),
path('api/upload/url/', judge_views.upload_audio_url, name='judge_api_upload_url'),
path('api/ai/<str:task_id>/delete/', judge_views.delete_ai_task, name='judge_delete_ai_task'),
]

View File

@@ -0,0 +1,776 @@
import json
import logging
import random
import time
import requests
import threading
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse, HttpResponse
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.core.cache import cache
from django.contrib.auth.models import User
from django.conf import settings
from django.db.models import Q, Avg
from django.utils import timezone
import uuid
from .models import Competition, CompetitionEnrollment, Project, Score, ScoreDimension, Comment, ProjectFile
from shop.models import WeChatUser
from shop.sms_utils import send_sms
from ai_services.models import TranscriptionTask, AIEvaluation
from ai_services.services import AliyunTingwuService
logger = logging.getLogger(__name__)
# --- Helper Functions ---
def get_client_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def log_audit(request, action, target, result="SUCCESS", details=""):
judge_id = request.session.get('judge_id')
phone = request.session.get('judge_phone', 'Unknown')
role = request.session.get('judge_role', 'unknown')
ip = get_client_ip(request)
timestamp = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] IP:{ip} | Phone:{phone} | Role:{role} | Action:{action} | Target:{target} | Result:{result} | Details:{details}\n"
# Write to a file
try:
with open(settings.BASE_DIR / 'judge_audit.log', 'a', encoding='utf-8') as f:
f.write(log_entry)
except Exception as e:
logger.error(f"Failed to write audit log: {e}")
def judge_required(view_func):
def wrapper(request, *args, **kwargs):
if not request.session.get('judge_id'):
return redirect('judge_login')
return view_func(request, *args, **kwargs)
return wrapper
def check_contestant_access(view_func):
"""
Check if the user is allowed to access.
Contestants have limited access.
"""
def wrapper(request, *args, **kwargs):
if not request.session.get('judge_id'):
return redirect('judge_login')
role = request.session.get('judge_role')
if role == 'contestant':
# Some views might be restricted for contestants
# For now, this decorator just ensures login, but specific views handle logic
pass
return view_func(request, *args, **kwargs)
return wrapper
# --- Views ---
def admin_entry(request):
"""Entry point for /competition/admin"""
if request.session.get('judge_id'):
return redirect('judge_dashboard')
return redirect('judge_login')
@csrf_exempt
def login_view(request):
if request.method == 'GET':
return render(request, 'judge/login.html')
phone = request.POST.get('phone')
code = request.POST.get('code')
if not phone or not code:
return render(request, 'judge/login.html', {'error': '请输入手机号和验证码'})
# Verify Code
cached_code = cache.get(f"sms_code_{phone}")
# Universal pass code for development/testing
if code != cached_code and code != '888888':
return render(request, 'judge/login.html', {'error': '验证码错误 or expired'})
# Check User
try:
user = WeChatUser.objects.filter(phone_number=phone).first()
if not user:
return render(request, 'judge/login.html', {'error': '该手机号未绑定用户'})
# Check roles
# Priority: Judge > Guest > Contestant (if allowed)
is_judge = CompetitionEnrollment.objects.filter(user=user, role='judge').exists()
is_guest = CompetitionEnrollment.objects.filter(user=user, role='guest').exists()
role = None
if is_judge:
role = 'judge'
elif is_guest:
role = 'guest'
else:
# Check if contestant in any competition with allow_contestant_grading=True
contestant_enrollments = CompetitionEnrollment.objects.filter(
user=user,
role='contestant',
competition__allow_contestant_grading=True
)
if contestant_enrollments.exists():
role = 'contestant'
if not role:
return render(request, 'judge/login.html', {'error': '您没有权限登录系统'})
# Login Success
request.session['judge_id'] = user.id
request.session['judge_phone'] = phone
request.session['judge_name'] = user.nickname
request.session['judge_role'] = role
log_audit(request, 'LOGIN', 'System', 'SUCCESS', f"User {user.nickname} logged in as {role}")
return redirect('judge_dashboard')
except Exception as e:
logger.error(f"Login error: {e}")
return render(request, 'judge/login.html', {'error': '系统错误'})
@csrf_exempt
def send_code(request):
if request.method != 'POST':
return JsonResponse({'success': False, 'message': 'Method not allowed'})
try:
data = json.loads(request.body)
phone = data.get('phone')
if not phone or len(phone) != 11:
return JsonResponse({'success': False, 'message': 'Invalid phone number'})
# Generate Code
code = str(random.randint(100000, 999999)) # 6 digits to match typical SMS
cache.set(f"sms_code_{phone}", code, timeout=300) # 5 mins
# Send SMS using the specified API
def _send_async():
try:
api_url = "https://data.tangledup-ai.com/api/send-sms"
payload = {
"phone_number": phone,
"code": code,
"template_code": "SMS_493295002",
"sign_name": "叠加态科技云南",
"additionalProp1": {}
}
headers = {
"Content-Type": "application/json",
"accept": "application/json"
}
response = requests.post(api_url, json=payload, headers=headers, timeout=15)
logger.info(f"SMS Response for {phone}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"发送短信异常: {str(e)}")
threading.Thread(target=_send_async).start()
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'success': False, 'message': str(e)})
def logout_view(request):
log_audit(request, 'LOGOUT', 'System')
request.session.flush()
return redirect('judge_login')
@judge_required
def dashboard(request):
judge_id = request.session['judge_id']
role = request.session.get('judge_role', 'judge')
user = WeChatUser.objects.get(id=judge_id)
# Get competitions
if role == 'judge':
enrollments = CompetitionEnrollment.objects.filter(user=user, role='judge')
elif role == 'guest':
enrollments = CompetitionEnrollment.objects.filter(user=user, role='guest')
else:
# Contestant: only competitions allowing grading
enrollments = CompetitionEnrollment.objects.filter(
user=user,
role='contestant',
competition__allow_contestant_grading=True
)
competition_ids = enrollments.values_list('competition_id', flat=True)
# Get Projects
projects = Project.objects.filter(
competition_id__in=competition_ids,
status='submitted'
).select_related('contestant__user')
# Format for template
project_list = []
for p in projects:
# Check current score/grading status for this user
# Note: Score model links to 'judge' which is a CompetitionEnrollment
# We need the enrollment for this user in this competition
user_enrollment = enrollments.filter(competition=p.competition).first()
project_list.append({
'id': p.id,
'title': p.title,
'cover_image_url': p.cover_image_url or (p.cover_image.url if p.cover_image else ''),
'contestant_name': p.contestant.user.nickname,
'current_score': p.final_score, # Global score
'status_class': 'status-submitted',
'get_status_display': p.get_status_display()
})
return render(request, 'judge/dashboard.html', {
'projects': project_list,
'user_role': role,
'user_name': request.session.get('judge_name', '用户')
})
@judge_required
def project_detail_api(request, project_id):
judge_id = request.session['judge_id']
role = request.session.get('judge_role', 'judge')
user = WeChatUser.objects.get(id=judge_id)
project = get_object_or_404(Project, id=project_id)
# Check permission
# User must be enrolled in the project's competition with correct role/settings
if role == 'judge':
enrollment = CompetitionEnrollment.objects.filter(user=user, role='judge', competition=project.competition).first()
elif role == 'guest':
enrollment = CompetitionEnrollment.objects.filter(user=user, role='guest', competition=project.competition).first()
else:
enrollment = CompetitionEnrollment.objects.filter(
user=user,
role='contestant',
competition=project.competition,
competition__allow_contestant_grading=True
).first()
if not enrollment:
return JsonResponse({'error': 'No permission'}, status=403)
# Get Dimensions - 根据角色过滤
if role == 'contestant':
dimensions = ScoreDimension.objects.filter(
competition=project.competition,
is_public=True,
is_peer_review=True
).order_by('order')
else:
dimensions = ScoreDimension.objects.filter(
competition=project.competition,
is_public=True,
is_peer_review=False
).order_by('order')
# Get existing scores by THIS user
scores = Score.objects.filter(project=project, judge=enrollment)
score_map = {s.dimension_id: s.score for s in scores}
dim_data = []
for d in dimensions:
dim_data.append({
'id': d.id,
'name': d.name,
'weight': float(d.weight),
'max_score': d.max_score,
'current_score': float(score_map.get(d.id, 0))
})
# Get Comments
# If role is contestant, they CANNOT see other people's comments
history = []
current_comment = ""
if role in ['judge', 'guest']:
comments = Comment.objects.filter(project=project).order_by('-created_at')
for c in comments:
history.append({
'judge_name': c.judge.user.nickname,
'content': c.content,
'created_at': c.created_at.strftime("%Y-%m-%d %H:%M")
})
if c.judge.id == enrollment.id:
current_comment = c.content
else:
# Contestant: only see their own comment
my_comment = Comment.objects.filter(project=project, judge=enrollment).first()
if my_comment:
current_comment = my_comment.content
history.append({
'judge_name': user.nickname, # Self
'content': my_comment.content,
'created_at': my_comment.created_at.strftime("%Y-%m-%d %H:%M")
})
# Include AI results
latest_task = TranscriptionTask.objects.filter(project=project, status='SUCCEEDED').order_by('-created_at').first()
ai_data = None
if latest_task:
ai_data = {
'transcription': latest_task.transcription,
'summary': latest_task.summary,
'auto_chapters_data': latest_task.auto_chapters_data,
'transcription_data': latest_task.transcription_data
}
latest_task_any = TranscriptionTask.objects.filter(project=project).order_by('-created_at').first()
audio_url = latest_task_any.file_url if latest_task_any else None
# 计算各类评分(仅对评委和嘉宾可见)
judge_score_avg = None
peer_score_avg = None
ai_score_avg = None
final_score = float(project.final_score) if project.final_score else 0
if role in ['judge', 'guest']:
competition = project.competition
# 评委评分is_public=True 的维度(评委可见的维度)
judge_dimensions = ScoreDimension.objects.filter(
competition=competition,
is_public=True
)
judge_enrollments = CompetitionEnrollment.objects.filter(
competition=competition,
role='judge'
)
if judge_dimensions.exists() and judge_enrollments.exists():
judge_total = 0
judge_count = 0
for judge_enrollment in judge_enrollments:
judge_project_scores = Score.objects.filter(
project=project,
judge=judge_enrollment,
dimension__in=judge_dimensions
)
if judge_project_scores.exists():
judge_score = sum(
float(s.score) * float(s.dimension.weight)
for s in judge_project_scores
)
judge_total += judge_score
judge_count += 1
if judge_count > 0:
judge_score_avg = round(judge_total / judge_count, 2)
# 选手互评分allow_contestant_grading=True 且 is_peer_review=True 的维度
if competition.allow_contestant_grading:
peer_dimensions = ScoreDimension.objects.filter(
competition=competition,
is_peer_review=True
)
peer_enrollments = CompetitionEnrollment.objects.filter(
competition=competition,
role='contestant'
)
if peer_dimensions.exists() and peer_enrollments.exists():
peer_total = 0
peer_count = 0
for peer_enrollment in peer_enrollments:
peer_project_scores = Score.objects.filter(
project=project,
judge=peer_enrollment,
dimension__in=peer_dimensions
)
if peer_project_scores.exists():
peer_score = sum(
float(s.score) * float(s.dimension.weight)
for s in peer_project_scores
)
peer_total += peer_score
peer_count += 1
if peer_count > 0:
peer_score_avg = round(peer_total / peer_count, 2)
# AI评分is_public=False 且 is_peer_review=False 的维度两个都为false就是AI维度
ai_dimensions = ScoreDimension.objects.filter(
competition=competition,
is_public=False,
is_peer_review=False
)
if ai_dimensions.exists():
ai_enrollments = CompetitionEnrollment.objects.filter(
competition=competition,
role='judge'
)
if ai_enrollments.exists():
ai_total = 0
ai_count = 0
for ai_enrollment in ai_enrollments:
ai_project_scores = Score.objects.filter(
project=project,
judge=ai_enrollment,
dimension__in=ai_dimensions
)
if ai_project_scores.exists():
ai_score = sum(
float(s.score) * float(s.dimension.weight)
for s in ai_project_scores
)
ai_total += ai_score
ai_count += 1
if ai_count > 0:
ai_score_avg = round(ai_total / ai_count, 2)
# 判断是否为选手查看自己的项目
is_own_project = role == 'contestant' and project.contestant.user == user
data = {
'id': project.id,
'title': project.title,
'description': project.description,
'contestant_name': project.contestant.user.nickname,
'dimensions': dim_data,
'history_comments': history,
'current_comment': current_comment,
'ai_result': ai_data,
'audio_url': audio_url,
'can_grade': role == 'judge' or (role == 'contestant' and project.contestant.user != user),
'is_own_project': is_own_project,
# 评分细项(评委、嘉宾可见,选手查看自己的项目时也可见)
'score_details': {
'judge_score': judge_score_avg,
'peer_score': peer_score_avg,
'ai_score': ai_score_avg,
'final_score': final_score
} if role in ['judge', 'guest'] or is_own_project else None,
# 评分公式信息(评委、嘉宾可见,选手查看自己的项目时也可见)
'formula_info': {
'name': project.competition.active_formula.name if project.competition.active_formula else None,
'formula': project.competition.active_formula.formula if project.competition.active_formula else None,
'preview': project.competition.active_formula.get_formula_preview() if project.competition.active_formula else None
} if project.competition.score_calculation_type == 'formula' and (role in ['judge', 'guest'] or is_own_project) else None
}
# Specifically for guest: can_grade is False
if role == 'guest':
data['can_grade'] = False
return JsonResponse(data)
@judge_required
@csrf_exempt
def submit_score(request):
if request.method != 'POST':
return JsonResponse({'success': False, 'message': 'Method not allowed'})
try:
data = json.loads(request.body)
project_id = data.get('project_id')
comment_content = data.get('comment')
judge_id = request.session['judge_id']
role = request.session.get('judge_role', 'judge')
if role == 'guest':
return JsonResponse({'success': False, 'message': '嘉宾无权评分'})
user = WeChatUser.objects.get(id=judge_id)
project = get_object_or_404(Project, id=project_id)
enrollment = None
if role == 'judge':
enrollment = CompetitionEnrollment.objects.filter(user=user, role='judge', competition=project.competition).first()
else:
enrollment = CompetitionEnrollment.objects.filter(
user=user,
role='contestant',
competition=project.competition,
competition__allow_contestant_grading=True
).first()
if not enrollment:
return JsonResponse({'success': False, 'message': 'No permission'})
# Save Scores - 根据角色过滤维度
if role == 'contestant':
dimensions = ScoreDimension.objects.filter(
competition=project.competition,
is_public=True,
is_peer_review=True
)
else:
dimensions = ScoreDimension.objects.filter(
competition=project.competition,
is_public=True,
is_peer_review=False
)
for d in dimensions:
score_key = f'score_{d.id}'
if score_key in data:
val = data[score_key]
Score.objects.update_or_create(
project=project,
judge=enrollment,
dimension=d,
defaults={'score': val}
)
# Save Comment
if comment_content:
Comment.objects.update_or_create(
project=project,
judge=enrollment,
defaults={'content': comment_content}
)
# Recalculate Project Score
project.calculate_score()
log_audit(request, 'SCORE_UPDATE', f"Project {project.id}", 'SUCCESS')
return JsonResponse({'success': True})
except Exception as e:
logger.error(f"Submit score error: {e}")
return JsonResponse({'success': False, 'message': str(e)})
@judge_required
@csrf_exempt
def upload_audio(request):
# Contestants cannot upload, but Guests can
role = request.session.get('judge_role')
if role not in ['judge', 'guest']:
return JsonResponse({'success': False, 'message': 'Permission denied'})
if request.method != 'POST':
return JsonResponse({'success': False, 'message': 'Method not allowed'})
judge_id = request.session['judge_id']
file_obj = request.FILES.get('file')
project_id = request.POST.get('project_id')
if not file_obj or not project_id:
return JsonResponse({'success': False, 'message': 'Missing file or project_id'})
try:
# Check permission
user = WeChatUser.objects.get(id=judge_id)
project = Project.objects.get(id=project_id)
# Verify judge/guest has access to this project's competition
enrollment = CompetitionEnrollment.objects.filter(
user=user,
role=role,
competition=project.competition
).first()
if not enrollment:
return JsonResponse({'success': False, 'message': 'No permission for this project'})
# Upload to OSS & Create Task
service = AliyunTingwuService()
if not service.bucket:
return JsonResponse({'success': False, 'message': 'OSS not configured'})
file_extension = file_obj.name.split('.')[-1]
file_name = f"transcription/{uuid.uuid4()}.{file_extension}"
oss_url = service.upload_to_oss(file_obj, file_name)
# Create Task Record
task = TranscriptionTask.objects.create(
project=project,
file_url=oss_url,
status=TranscriptionTask.Status.PENDING
)
# Call Tingwu
try:
tingwu_response = service.create_transcription_task(oss_url)
# Handle response format
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
task_id = tingwu_response['Data'].get('TaskId')
else:
task_id = tingwu_response.get('TaskId')
if task_id:
task.task_id = task_id
task.status = TranscriptionTask.Status.PROCESSING
task.save()
log_audit(request, 'UPLOAD_AUDIO', f"Task {task.id}", 'SUCCESS')
return JsonResponse({'success': True, 'task_id': task.id, 'file_url': oss_url})
else:
task.status = TranscriptionTask.Status.FAILED
task.error_message = "No TaskId returned"
task.save()
return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'})
except Exception as e:
task.status = TranscriptionTask.Status.FAILED
task.error_message = str(e)
task.save()
return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'})
except Exception as e:
logger.error(f"Upload error: {e}")
return JsonResponse({'success': False, 'message': str(e)})
@judge_required
@csrf_exempt
def upload_audio_url(request):
"""
处理 URL 上传音频的 API
通过给定的音频 URL 直接进行处理,无需上传文件
"""
role = request.session.get('judge_role')
if role not in ['judge', 'guest']:
return JsonResponse({'success': False, 'message': 'Permission denied'})
if request.method != 'POST':
return JsonResponse({'success': False, 'message': 'Method not allowed'})
import json
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'message': 'Invalid JSON'})
audio_url = data.get('url')
project_id = data.get('project_id')
if not audio_url or not project_id:
return JsonResponse({'success': False, 'message': 'Missing url or project_id'})
# 验证 URL 格式
if not audio_url.startswith(('http://', 'https://')):
return JsonResponse({'success': False, 'message': 'Invalid URL format'})
judge_id = request.session['judge_id']
try:
# 验证权限
user = WeChatUser.objects.get(id=judge_id)
project = Project.objects.get(id=project_id)
enrollment = CompetitionEnrollment.objects.filter(
user=user,
role=role,
competition=project.competition
).first()
if not enrollment:
return JsonResponse({'success': False, 'message': 'No permission for this project'})
# 创建任务记录,使用 URL 作为 file_url
service = AliyunTingwuService()
task = TranscriptionTask.objects.create(
project=project,
file_url=audio_url,
status=TranscriptionTask.Status.PENDING
)
# 调用 Tingwu 服务
try:
tingwu_response = service.create_transcription_task(audio_url)
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
task_id = tingwu_response['Data'].get('TaskId')
else:
task_id = tingwu_response.get('TaskId')
if task_id:
task.task_id = task_id
task.status = TranscriptionTask.Status.PROCESSING
task.save()
log_audit(request, 'UPLOAD_AUDIO_URL', f"Task {task.id}", 'SUCCESS')
return JsonResponse({'success': True, 'task_id': task.id, 'file_url': audio_url})
else:
task.status = TranscriptionTask.Status.FAILED
task.error_message = "No TaskId returned"
task.save()
return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'})
except Exception as e:
task.status = TranscriptionTask.Status.FAILED
task.error_message = str(e)
task.save()
return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'})
except Exception as e:
logger.error(f"Upload URL error: {e}")
return JsonResponse({'success': False, 'message': str(e)})
@judge_required
def ai_manage(request):
# Contestants cannot access AI manage
role = request.session.get('judge_role')
if role not in ['judge', 'guest']:
return redirect('judge_dashboard')
judge_id = request.session['judge_id']
user = WeChatUser.objects.get(id=judge_id)
enrollments = CompetitionEnrollment.objects.filter(user=user, role=role)
competition_ids = enrollments.values_list('competition_id', flat=True)
# Get tasks for projects in these competitions
tasks = TranscriptionTask.objects.filter(
project__competition_id__in=competition_ids
).select_related('project').order_by('-created_at')
task_list = []
for t in tasks:
# Get Evaluation Score
# AIEvaluation is linked to Task
evals = t.ai_evaluations.all()
score = evals[0].score if evals else None
task_list.append({
'id': t.id,
'project': t.project,
'file_url': t.file_url,
'file_name': t.file_url.split('/')[-1] if t.file_url else 'Unknown',
'status': t.status,
'status_class': 'status-' + t.status.lower(), # CSS class
'get_status_display': t.get_status_display(),
'ai_score': score
})
return render(request, 'judge/ai_manage.html', {
'tasks': task_list,
'user_name': request.session.get('judge_name', '用户'),
'user_role': role
})
@judge_required
@csrf_exempt
def delete_ai_task(request, task_id):
role = request.session.get('judge_role')
if role not in ['judge', 'guest']:
return JsonResponse({'success': False, 'message': 'Permission denied'})
if request.method != 'POST':
return JsonResponse({'success': False, 'message': 'Method not allowed'})
try:
task = get_object_or_404(TranscriptionTask, id=task_id)
# Permission check
# ...
task.delete()
log_audit(request, 'DELETE_TASK', f"Task {task_id}", 'SUCCESS')
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'success': False, 'message': str(e)})

View File

@@ -0,0 +1,141 @@
# Generated by Django 6.0.1 on 2026-03-10 02:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('shop', '0039_vccourse_video_embed_code'),
]
operations = [
migrations.CreateModel(
name='Competition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='比赛名称')),
('description', models.TextField(verbose_name='比赛简介')),
('rule_description', models.TextField(verbose_name='规则说明')),
('condition_description', models.TextField(blank=True, verbose_name='参赛条件说明')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/covers/', verbose_name='封面图')),
('start_time', models.DateTimeField(verbose_name='开始时间')),
('end_time', models.DateTimeField(verbose_name='结束时间')),
('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('registration', '报名中'), ('submission', '作品提交中'), ('judging', '评审中'), ('ended', '已结束')], default='draft', max_length=20, verbose_name='状态')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '比赛',
'verbose_name_plural': '比赛管理',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CompetitionEnrollment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('contestant', '选手'), ('judge', '评委'), ('guest', '嘉宾')], default='contestant', max_length=20, verbose_name='角色')),
('status', models.CharField(choices=[('pending', '待审核'), ('approved', '已通过'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='competition.competition', verbose_name='所属比赛')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competitions', to='shop.wechatuser', verbose_name='用户')),
],
options={
'verbose_name': '比赛人员',
'verbose_name_plural': '人员管理',
'unique_together': {('competition', 'user')},
},
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='项目名称')),
('description', models.TextField(verbose_name='项目介绍')),
('team_info', models.TextField(blank=True, verbose_name='团队介绍')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/projects/covers/', verbose_name='项目封面')),
('status', models.CharField(choices=[('draft', '草稿'), ('submitted', '已提交')], default='draft', max_length=20, verbose_name='状态')),
('final_score', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='最终得分')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='competition.competition', verbose_name='所属比赛')),
('contestant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='competition.competitionenrollment', verbose_name='参赛选手')),
],
options={
'verbose_name': '参赛项目',
'verbose_name_plural': '项目管理',
'ordering': ['-final_score', '-created_at'],
},
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(verbose_name='评语内容')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='评论时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_comments', to='competition.competitionenrollment', verbose_name='评委')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='competition.project', verbose_name='所属项目')),
],
options={
'verbose_name': '评委评语',
'verbose_name_plural': '评语管理',
},
),
migrations.CreateModel(
name='ProjectFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file_type', models.CharField(choices=[('ppt', 'PPT演示文稿'), ('pdf', 'PDF文档'), ('image', '图片'), ('video', '视频'), ('doc', '文档'), ('other', '其他')], default='other', max_length=20, verbose_name='文件类型')),
('file', models.FileField(blank=True, null=True, upload_to='competitions/projects/files/', verbose_name='文件')),
('file_url', models.URLField(blank=True, help_text='视频等大文件建议使用外部链接', null=True, verbose_name='文件链接')),
('name', models.CharField(blank=True, max_length=100, verbose_name='文件名称')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='competition.project', verbose_name='所属项目')),
],
options={
'verbose_name': '项目附件',
'verbose_name_plural': '附件管理',
},
),
migrations.CreateModel(
name='ScoreDimension',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='维度名称')),
('description', models.TextField(blank=True, verbose_name='维度说明')),
('weight', models.DecimalField(decimal_places=2, default=1.0, help_text='例如 0.3 表示 30%', max_digits=5, verbose_name='权重')),
('max_score', models.IntegerField(default=100, verbose_name='满分值')),
('order', models.IntegerField(default=0, verbose_name='排序权重')),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_dimensions', to='competition.competition', verbose_name='所属比赛')),
],
options={
'verbose_name': '评分维度',
'verbose_name_plural': '评分维度配置',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='Score',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('score', models.DecimalField(decimal_places=1, max_digits=5, verbose_name='得分')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='打分时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_scores', to='competition.competitionenrollment', verbose_name='评委')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scores', to='competition.project', verbose_name='所属项目')),
('dimension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.scoredimension', verbose_name='评分维度')),
],
options={
'verbose_name': '评分记录',
'verbose_name_plural': '评分记录',
'unique_together': {('project', 'judge', 'dimension')},
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-03-10 02:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='competition',
name='cover_image_url',
field=models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='封面图URL'),
),
migrations.AddField(
model_name='project',
name='cover_image_url',
field=models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='项目封面URL'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-10 06:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0002_competition_cover_image_url_project_cover_image_url'),
]
operations = [
migrations.AddField(
model_name='competition',
name='project_visibility',
field=models.CharField(choices=[('public', '公开可见'), ('contestant', '选手及以上可见'), ('guest', '嘉宾及评委可见'), ('judge', '仅评委可见')], default='public', max_length=20, verbose_name='项目可见性'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-12 05:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0003_competition_project_visibility'),
]
operations = [
migrations.AddField(
model_name='competition',
name='allow_contestant_grading',
field=models.BooleanField(default=False, verbose_name='允许选手互评'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-12 05:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0004_competition_allow_contestant_grading'),
]
operations = [
migrations.AddField(
model_name='scoredimension',
name='is_public',
field=models.BooleanField(default=True, help_text='如果关闭评委端将看不到此评分维度通常用于AI自动评分', verbose_name='是否公开给评委'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-17 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0005_scoredimension_is_public'),
]
operations = [
migrations.AddField(
model_name='scoredimension',
name='is_peer_review',
field=models.BooleanField(default=False, help_text='如果开启,此评分维度仅在选手互评时可见,评委和嘉宾看不到', verbose_name='是否用于选手互评'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.1 on 2026-03-20 05:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0006_add_peer_review_field'),
]
operations = [
migrations.AddField(
model_name='competition',
name='custom_score_formula',
field=models.CharField(blank=True, help_text='如使用自定义算式,将使用此公式计算最终得分。变量格式: dimension_维度ID如 dimension_1, dimension_2', max_length=1000, verbose_name='自定义得分算式'),
),
migrations.AddField(
model_name='competition',
name='score_calculation_type',
field=models.CharField(choices=[('default', '默认加权平均'), ('custom', '自定义算式')], default='default', max_length=20, verbose_name='得分计算方式'),
),
migrations.AddField(
model_name='scoredimension',
name='formula',
field=models.CharField(blank=True, help_text='使用维度ID作为变量如: dimension_1 * 0.3 + dimension_2 * 0.5 + dimension_3 * 0.2', max_length=500, verbose_name='自定义算式'),
),
migrations.AddField(
model_name='scoredimension',
name='formula_type',
field=models.CharField(choices=[('weight', '权重模式'), ('formula', '自定义算式')], default='weight', max_length=20, verbose_name='算式类型'),
),
migrations.AlterField(
model_name='scoredimension',
name='weight',
field=models.DecimalField(decimal_places=4, default=1.0, help_text='例如 0.3000 表示 30%', max_digits=6, verbose_name='权重'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.1 on 2026-03-20 05:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0007_competition_custom_score_formula_and_more'),
]
operations = [
migrations.CreateModel(
name='ScoreFormula',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='用于标识这个公式,方便管理', max_length=100, verbose_name='公式名称')),
('description', models.TextField(blank=True, verbose_name='公式说明')),
('formula', models.TextField(help_text='使用维度名称作为变量,支持四则运算和函数', verbose_name='计算公式')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('is_default', models.BooleanField(default=False, verbose_name='是否设为默认公式')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_formulas', to='competition.competition', verbose_name='所属比赛')),
],
options={
'verbose_name': '评分公式',
'verbose_name_plural': '评分公式配置',
'ordering': ['-is_default', '-created_at'],
},
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 6.0.1 on 2026-03-20 06:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0008_scoreformula'),
]
operations = [
migrations.AddField(
model_name='competition',
name='active_formula',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_competitions', to='competition.scoreformula', verbose_name='启用的评分公式'),
),
migrations.AlterField(
model_name='competition',
name='score_calculation_type',
field=models.CharField(choices=[('default', '默认加权平均'), ('formula', '使用评分公式')], default='default', max_length=20, verbose_name='得分计算方式'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0.1 on 2026-03-20 06:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('competition', '0009_competition_active_formula_and_more'),
]
operations = [
migrations.RemoveField(
model_name='competition',
name='custom_score_formula',
),
]

View File

@@ -0,0 +1,425 @@
from django.db import models
from shop.models import WeChatUser
class Competition(models.Model):
"""
比赛管理模型
"""
STATUS_CHOICES = (
('draft', '草稿'),
('published', '已发布'),
('registration', '报名中'),
('submission', '作品提交中'),
('judging', '评审中'),
('ended', '已结束'),
)
PROJECT_VISIBILITY_CHOICES = (
('public', '公开可见'),
('contestant', '选手及以上可见'),
('guest', '嘉宾及评委可见'),
('judge', '仅评委可见'),
)
title = models.CharField(max_length=200, verbose_name="比赛名称")
description = models.TextField(verbose_name="比赛简介")
rule_description = models.TextField(verbose_name="规则说明")
condition_description = models.TextField(verbose_name="参赛条件说明", blank=True)
cover_image = models.ImageField(upload_to='competitions/covers/', verbose_name="封面图", null=True, blank=True)
cover_image_url = models.URLField(verbose_name="封面图URL", null=True, blank=True, help_text="优先使用上传的图片")
start_time = models.DateTimeField(verbose_name="开始时间")
end_time = models.DateTimeField(verbose_name="结束时间")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
project_visibility = models.CharField(max_length=20, choices=PROJECT_VISIBILITY_CHOICES, default='public', verbose_name="项目可见性")
allow_contestant_grading = models.BooleanField(default=False, verbose_name="允许选手互评")
SCORE_CALCULATION_CHOICES = (
('default', '默认加权平均'),
('formula', '使用评分公式'),
)
score_calculation_type = models.CharField(max_length=20, choices=SCORE_CALCULATION_CHOICES, default='default', verbose_name="得分计算方式")
active_formula = models.ForeignKey('ScoreFormula', on_delete=models.SET_NULL, null=True, blank=True, related_name='active_competitions', verbose_name="启用的评分公式")
is_active = models.BooleanField(default=True, verbose_name="是否启用")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
def __str__(self):
return self.title
class Meta:
verbose_name = "比赛"
verbose_name_plural = "比赛管理"
ordering = ['-created_at']
class CompetitionEnrollment(models.Model):
"""
比赛人员报名/角色分配
"""
ROLE_CHOICES = (
('contestant', '选手'),
('judge', '评委'),
('guest', '嘉宾'),
)
STATUS_CHOICES = (
('pending', '待审核'),
('approved', '已通过'),
('rejected', '已拒绝'),
)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='enrollments', verbose_name="所属比赛")
user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='competitions', verbose_name="用户")
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='contestant', verbose_name="角色")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
verbose_name = "比赛人员"
verbose_name_plural = "人员管理"
unique_together = ('competition', 'user')
def __str__(self):
return f"{self.competition.title} - {self.user.nickname} ({self.get_role_display()})"
class ScoreDimension(models.Model):
"""
评分维度配置
"""
FORMULA_TYPE_CHOICES = (
('weight', '权重模式'),
('formula', '自定义算式'),
)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_dimensions', verbose_name="所属比赛")
name = models.CharField(max_length=100, verbose_name="维度名称")
description = models.TextField(verbose_name="维度说明", blank=True)
weight = models.DecimalField(max_digits=6, decimal_places=4, default=1.0000, verbose_name="权重", help_text="例如 0.3000 表示 30%")
max_score = models.IntegerField(default=100, verbose_name="满分值")
formula_type = models.CharField(max_length=20, choices=FORMULA_TYPE_CHOICES, default='weight', verbose_name="算式类型")
formula = models.CharField(max_length=500, blank=True, verbose_name="自定义算式", help_text="使用维度ID作为变量如: dimension_1 * 0.3 + dimension_2 * 0.5 + dimension_3 * 0.2")
is_public = models.BooleanField(default=True, verbose_name="是否公开给评委", help_text="如果关闭评委端将看不到此评分维度通常用于AI自动评分")
is_peer_review = models.BooleanField(default=False, verbose_name="是否用于选手互评", help_text="如果开启,此评分维度仅在选手互评时可见,评委和嘉宾看不到")
order = models.IntegerField(default=0, verbose_name="排序权重")
class Meta:
verbose_name = "评分维度"
verbose_name_plural = "评分维度配置"
ordering = ['order']
def __str__(self):
return f"{self.competition.title} - {self.name}"
def get_formula_preview(self):
"""
获取算式预览显示维度名称而非ID
"""
if not self.formula or self.formula_type != 'formula':
return None
dimension_map = {d.id: d.name for d in self.competition.score_dimensions.all()}
result = self.formula
for dim_id, dim_name in dimension_map.items():
result = result.replace(f'dimension_{dim_id}', f'[{dim_name}]')
return result
class Project(models.Model):
"""
参赛项目/作品
"""
STATUS_CHOICES = (
('draft', '草稿'),
('submitted', '已提交'),
)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='projects', verbose_name="所属比赛")
contestant = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='projects', verbose_name="参赛选手")
title = models.CharField(max_length=200, verbose_name="项目名称")
description = models.TextField(verbose_name="项目介绍")
team_info = models.TextField(verbose_name="团队介绍", blank=True)
cover_image = models.ImageField(upload_to='competitions/projects/covers/', verbose_name="项目封面", null=True, blank=True)
cover_image_url = models.URLField(verbose_name="项目封面URL", null=True, blank=True, help_text="优先使用上传的图片")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
# 最终得分缓存,避免每次实时计算
final_score = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name="最终得分")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
verbose_name = "参赛项目"
verbose_name_plural = "项目管理"
ordering = ['-final_score', '-created_at']
def __str__(self):
return self.title
def calculate_score(self):
"""
计算项目得分
支持两种模式:
1. 默认加权平均:每个评委的得分 = sum(维度分数 × 维度权重),然后所有评委取平均
2. 使用评分公式:使用比赛关联的评分公式计算最终得分
评分公式变量格式:
- dimension_X: 第X个维度的平均分所有评委对该维度的平均分
- 例如: (dimension_1 + dimension_2) / 2
"""
scores = self.scores.all()
if not scores.exists():
self.final_score = 0
self.save()
return 0
competition = self.competition
if competition.score_calculation_type == 'formula' and competition.active_formula:
return self._calculate_formula_score(scores, competition.active_formula)
return self._calculate_default_score(scores)
def _calculate_default_score(self, scores):
"""
默认加权平均模式
1. 获取所有评委对该项目的打分
2. 每个评委的得分 = sum(维度分数 × 维度权重)
3. 项目最终得分 = 所有评委得分的平均值
"""
judges = set(score.judge for score in scores)
if not judges:
return 0
total_weighted_score = 0
for judge in judges:
judge_score = 0
judge_scores = scores.filter(judge=judge)
for score in judge_scores:
judge_score += float(score.score) * float(score.dimension.weight)
total_weighted_score += judge_score
avg_score = total_weighted_score / len(judges)
self.final_score = round(avg_score, 2)
self.save()
return avg_score
def _calculate_formula_score(self, scores, formula_obj):
"""
公式配置模式(使用 ScoreFormula 模型)
使用公式配置中的公式计算得分
变量格式: dimension_X (X为维度ID)
注意:公式中的维度值已经是加权后的分数(原始分数 × 权重)
保存的评分是原始分数(不乘权重),显示时乘以权重
"""
dimension_scores = {}
dimensions = self.competition.score_dimensions.all()
for dimension in dimensions:
dim_scores = scores.filter(dimension=dimension)
if dim_scores.exists():
avg = sum(float(s.score) for s in dim_scores) / dim_scores.count()
weighted_score = avg * float(dimension.weight)
dimension_scores[f'dimension_{dimension.id}'] = weighted_score
else:
dimension_scores[f'dimension_{dimension.id}'] = 0
if not dimension_scores:
self.final_score = 0
self.save()
return 0
formula = formula_obj.formula
try:
result = eval(formula, {"__builtins__": {}}, dimension_scores)
final_score = float(result)
self.final_score = round(final_score, 2)
self.save()
return self.final_score
except Exception as e:
print(f"公式计算错误: {e}, formula: {formula}, values: {dimension_scores}")
return self._calculate_default_score(scores)
def calculate_judge_score(self, judge):
"""
计算指定评委对该项目的得分
用于显示评委个人评分
"""
scores = self.scores.filter(judge=judge)
if not scores.exists():
return 0
total = 0
for score in scores:
total += float(score.score) * float(score.dimension.weight)
return round(total, 2)
class ProjectFile(models.Model):
"""
项目附件
"""
FILE_TYPE_CHOICES = (
('ppt', 'PPT演示文稿'),
('pdf', 'PDF文档'),
('image', '图片'),
('video', '视频'),
('doc', '文档'),
('other', '其他'),
)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='files', verbose_name="所属项目")
file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型")
file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True)
file_url = models.URLField(verbose_name="文件链接", null=True, blank=True, help_text="视频等大文件建议使用外部链接")
name = models.CharField(max_length=100, verbose_name="文件名称", blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
class Meta:
verbose_name = "项目附件"
verbose_name_plural = "附件管理"
def __str__(self):
return self.name or f"{self.get_file_type_display()}"
class Score(models.Model):
"""
评委打分
"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='scores', verbose_name="所属项目")
judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_scores', verbose_name="评委")
dimension = models.ForeignKey(ScoreDimension, on_delete=models.CASCADE, verbose_name="评分维度")
score = models.DecimalField(max_digits=5, decimal_places=1, verbose_name="得分")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="打分时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
verbose_name = "评分记录"
verbose_name_plural = "评分记录"
unique_together = ('project', 'judge', 'dimension')
def __str__(self):
return f"{self.judge.user.nickname} -> {self.project.title}: {self.score}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 触发重新计算分数
self.project.calculate_score()
class Comment(models.Model):
"""
评委评语
"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments', verbose_name="所属项目")
judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_comments', verbose_name="评委")
content = models.TextField(verbose_name="评语内容")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="评论时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
verbose_name = "评委评语"
verbose_name_plural = "评语管理"
def __str__(self):
return f"{self.judge.user.nickname} -> {self.project.title}"
class ScoreFormula(models.Model):
"""
评分公式配置
用于可视化配置得分计算公式
"""
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_formulas', verbose_name="所属比赛")
name = models.CharField(max_length=100, verbose_name="公式名称", help_text="用于标识这个公式,方便管理")
description = models.TextField(verbose_name="公式说明", blank=True)
formula = models.TextField(verbose_name="计算公式", help_text="使用维度名称作为变量,支持四则运算和函数")
is_active = models.BooleanField(default=True, verbose_name="是否启用")
is_default = models.BooleanField(default=False, verbose_name="是否设为默认公式")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
verbose_name = "评分公式"
verbose_name_plural = "评分公式配置"
ordering = ['-is_default', '-created_at']
def __str__(self):
return f"{self.competition.title} - {self.name}"
def get_formula_preview(self):
"""
获取公式预览,将维度变量替换为维度名称
"""
if not self.formula:
return ""
dimension_map = {f'd["{d.name}"]': f'[{d.name}]' for d in self.competition.score_dimensions.all()}
dimension_map.update({f"d['{d.name}']": f'[{d.name}]' for d in self.competition.score_dimensions.all()})
dimension_map.update({f'dimension_{d.id}': f'[{d.name}]' for d in self.competition.score_dimensions.all()})
result = self.formula
for old, new in dimension_map.items():
result = result.replace(old, new)
return result
def generate_python_code(self):
"""
生成可执行的 Python 代码
"""
if not self.formula:
return ""
dimension_names = [d.name for d in self.competition.score_dimensions.all()]
code_lines = [
"def calculate_score(d):",
" '''",
f" 计算公式: {self.name}",
" 参数 d: 字典,键为维度名称,值为该维度的平均分",
" '''",
]
for name in dimension_names:
code_lines.append(f" {name} = d.get('{name}', 0)")
code_lines.append("")
code_lines.append(f" return {self.formula}")
return "\n".join(code_lines)

View File

@@ -0,0 +1,105 @@
from rest_framework import serializers
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
from shop.serializers import WeChatUserSerializer
class ScoreDimensionSerializer(serializers.ModelSerializer):
class Meta:
model = ScoreDimension
fields = ['id', 'name', 'description', 'weight', 'max_score', 'order']
class CompetitionSerializer(serializers.ModelSerializer):
score_dimensions = ScoreDimensionSerializer(many=True, read_only=True)
display_cover_image = serializers.SerializerMethodField()
status_display = serializers.CharField(source='get_status_display', read_only=True)
class Meta:
model = Competition
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
'cover_image', 'cover_image_url', 'display_cover_image',
'start_time', 'end_time', 'status', 'project_visibility', 'status_display', 'is_active',
'score_dimensions', 'created_at']
def get_display_cover_image(self, obj):
if obj.cover_image:
return obj.cover_image.url
return obj.cover_image_url
class CompetitionEnrollmentSerializer(serializers.ModelSerializer):
user = WeChatUserSerializer(read_only=True)
class Meta:
model = CompetitionEnrollment
fields = ['id', 'competition', 'user', 'role', 'status', 'created_at']
read_only_fields = ['status']
class ProjectFileSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectFile
fields = ['id', 'project', 'file_type', 'file', 'file_url', 'name', 'created_at']
def validate_file(self, value):
if not value:
return value
# 50MB limit
limit_mb = 50
if value.size > limit_mb * 1024 * 1024:
raise serializers.ValidationError(f"文件大小不能超过 {limit_mb}MB")
return value
class ProjectSerializer(serializers.ModelSerializer):
files = ProjectFileSerializer(many=True, read_only=True)
contestant_info = serializers.SerializerMethodField()
display_cover_image = serializers.SerializerMethodField()
class Meta:
model = Project
fields = ['id', 'competition', 'contestant', 'title', 'description', 'team_info',
'cover_image', 'cover_image_url', 'display_cover_image',
'status', 'final_score', 'files', 'contestant_info', 'created_at']
read_only_fields = ['final_score', 'contestant']
def get_contestant_info(self, obj):
return {
"nickname": obj.contestant.user.nickname,
"avatar_url": obj.contestant.user.avatar_url
}
def get_display_cover_image(self, obj):
if obj.cover_image:
return obj.cover_image.url
return obj.cover_image_url
class ScoreSerializer(serializers.ModelSerializer):
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
dimension_name = serializers.CharField(source='dimension.name', read_only=True)
class Meta:
model = Score
fields = ['id', 'project', 'judge', 'dimension', 'score', 'judge_name', 'dimension_name', 'created_at']
read_only_fields = ['judge']
class CommentSerializer(serializers.ModelSerializer):
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
score = serializers.SerializerMethodField()
class Meta:
model = Comment
fields = ['id', 'project', 'judge', 'content', 'judge_name', 'created_at', 'score']
read_only_fields = ['judge']
def get_score(self, obj):
scores = Score.objects.filter(project=obj.project, judge=obj.judge)
if not scores.exists():
return None
current_judge_total_score = 0
current_judge_total_weight = 0
for score in scores:
current_judge_total_score += score.score * score.dimension.weight
current_judge_total_weight += score.dimension.weight
if current_judge_total_weight > 0:
judge_score = current_judge_total_score / current_judge_total_weight
return round(judge_score, 1)
return None

View File

@@ -0,0 +1,179 @@
{% extends 'judge/base.html' %}
{% block title %}AI 服务管理 - 评委系统{% endblock %}
{% block content %}
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900 tracking-tight">AI 服务管理</h2>
<p class="mt-1 text-sm text-gray-500">查看和管理音频转录及 AI 评分任务</p>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
项目
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
文件名
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
状态
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
AI 评分
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for task in tasks %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ task.project.title }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ task.file_url }}" target="_blank" class="text-sm text-blue-600 hover:text-blue-900 flex items-center">
<i class="fas fa-file-audio mr-1"></i> {{ task.file_name|default:"查看文件"|truncatechars:20 }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ task.status_class }}">
{{ task.get_status_display }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% if task.ai_score %}
<span class="font-bold text-gray-900">{{ task.ai_score }}</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="refreshStatus('{{ task.id }}')" class="text-indigo-600 hover:text-indigo-900 transition-colors" title="刷新状态">
<i class="fas fa-sync-alt"></i>
</button>
{% if task.status == 'SUCCEEDED' %}
<button onclick="viewResult('{{ task.id }}')" class="text-green-600 hover:text-green-900 transition-colors" title="查看结果">
<i class="fas fa-eye"></i>
</button>
{% endif %}
<button onclick="deleteTask('{{ task.id }}')" class="text-red-600 hover:text-red-900 transition-colors" title="删除任务">
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="px-6 py-10 text-center text-gray-500">
<div class="flex flex-col items-center">
<i class="fas fa-inbox text-4xl text-gray-300 mb-2"></i>
<p>暂无 AI 任务</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- AI Result Modal -->
<div id="aiResultModal" class="modal fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6" style="background-color: rgba(0,0,0,0.5);">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col relative animate-fade-in">
<button class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 z-10" onclick="closeModal('aiResultModal')">
<i class="fas fa-times text-xl"></i>
</button>
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50">
<h3 class="text-lg font-bold text-gray-900 flex items-center">
<i class="fas fa-robot text-blue-500 mr-2"></i> AI 分析详情
</h3>
</div>
<div class="p-6 overflow-y-auto space-y-6" id="aiResultContent">
<div>
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
<i class="fas fa-align-left mr-2 text-gray-400"></i> 逐字稿
</h4>
<div id="transcriptionText" class="bg-gray-50 p-4 rounded-lg text-sm text-gray-700 leading-relaxed border border-gray-200 max-h-60 overflow-y-auto"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
<i class="fas fa-lightbulb mr-2 text-yellow-500"></i> AI 总结
</h4>
<div id="summaryText" class="bg-yellow-50 p-4 rounded-lg text-sm text-gray-800 border border-yellow-100 h-40 overflow-y-auto"></div>
</div>
<div>
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
<i class="fas fa-comment-dots mr-2 text-green-500"></i> AI 评语
</h4>
<div id="evaluationText" class="bg-green-50 p-4 rounded-lg text-sm text-gray-800 border border-green-100 h-40 overflow-y-auto"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
async function refreshStatus(taskId) {
try {
const res = await apiCall(`/api/ai/transcriptions/${taskId}/refresh_status/`, 'GET');
if (res.status === 'SUCCEEDED' || res.status === 'FAILED') {
alert('状态已更新: ' + res.status);
location.reload();
} else {
alert('当前状态: ' + res.status);
}
} catch (e) {
alert('刷新失败');
}
}
async function viewResult(taskId) {
try {
const res = await apiCall(`/api/ai/transcriptions/${taskId}/`, 'GET');
document.getElementById('transcriptionText').innerText = res.transcription || '无逐字稿';
document.getElementById('summaryText').innerText = res.summary || '无总结';
// Handle Evaluation (might be separate API or included)
// Assuming simple structure for now, adjust based on actual API
let evalText = '暂无评语';
if (res.ai_evaluations && res.ai_evaluations.length > 0) {
evalText = res.ai_evaluations[0].evaluation || '无内容';
}
document.getElementById('evaluationText').innerText = evalText;
document.getElementById('aiResultModal').classList.add('active');
} catch (e) {
console.error(e);
alert('获取结果失败');
}
}
async function deleteTask(taskId) {
if(!confirm('确定要删除此任务吗?')) return;
try {
await apiCall(`/judge/api/ai/${taskId}/delete/`, 'POST');
alert('删除成功');
location.reload();
} catch (e) {
alert('删除失败');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}评委系统{% endblock %}</title>
<!-- suppress tailwind cdn warning -->
<script>
const originalWarn = console.warn;
console.warn = function() {
if (arguments[0] && typeof arguments[0] === 'string' && arguments[0].includes('cdn.tailwindcss.com should not be used in production')) {
return;
}
originalWarn.apply(console, arguments);
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f3f4f6;
}
.glass-effect {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Modal Transitions */
.modal {
opacity: 0;
visibility: hidden;
transition: all 0.3s ease-in-out;
}
.modal.active {
opacity: 1;
visibility: visible;
}
.modal-content {
transform: scale(0.95);
transition: transform 0.3s ease-in-out;
}
.modal.active .modal-content {
transform: scale(1);
}
/* Status Badges */
.status-submitted, .status-succeeded {
background-color: #dcfce7;
color: #166534;
}
.status-pending {
background-color: #fef9c3;
color: #854d0e;
}
.status-processing {
background-color: #dbeafe;
color: #1e40af;
}
.status-failed {
background-color: #fee2e2;
color: #991b1b;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body class="text-gray-800 antialiased min-h-screen flex flex-col">
{% if request.session.judge_id %}
<header class="bg-white shadow-sm sticky top-0 z-50 transition-all duration-300">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="{% url 'judge_dashboard' %}" class="flex-shrink-0 flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors">
<i class="fas fa-gavel text-xl"></i>
<h1 class="font-bold text-xl tracking-tight">评委评分系统</h1>
</a>
<nav class="hidden md:ml-8 md:flex md:space-x-8">
<a href="{% url 'judge_dashboard' %}"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors duration-200
{% if request.resolver_match.url_name == 'judge_dashboard' %}border-blue-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %}">
<i class="fas fa-th-list mr-2"></i>项目列表
</a>
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
<a href="{% url 'judge_ai_manage' %}"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors duration-200
{% if request.resolver_match.url_name == 'judge_ai_manage' %}border-blue-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %}">
<i class="fas fa-robot mr-2"></i>AI服务管理
</a>
{% endif %}
</nav>
</div>
<div class="flex items-center">
<div class="hidden md:flex items-center mr-6 text-sm">
<span class="font-medium text-gray-700 mr-2">{{ request.session.judge_name }}</span>
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
{% if request.session.judge_role == 'judge' %}评委
{% elif request.session.judge_role == 'guest' %}嘉宾
{% elif request.session.judge_role == 'contestant' %}选手
{% else %}{{ request.session.judge_role }}{% endif %}
</span>
</div>
<button onclick="logout()" class="ml-4 px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-500 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all shadow-sm hover:shadow">
<i class="fas fa-sign-out-alt mr-1"></i>退出
</button>
</div>
</div>
</div>
<!-- Mobile Navigation -->
<div class="md:hidden border-t border-gray-200 bg-gray-50">
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<span class="font-medium text-gray-900">{{ request.session.judge_name }}</span>
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
{% if request.session.judge_role == 'judge' %}评委
{% elif request.session.judge_role == 'guest' %}嘉宾
{% elif request.session.judge_role == 'contestant' %}选手
{% else %}{{ request.session.judge_role }}{% endif %}
</span>
</div>
<div class="grid grid-cols-2 divide-x divide-gray-200">
<a href="{% url 'judge_dashboard' %}" class="block py-3 text-center text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-blue-600">
<i class="fas fa-th-list mb-1 block text-lg"></i>项目列表
</a>
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
<a href="{% url 'judge_ai_manage' %}" class="block py-3 text-center text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-blue-600">
<i class="fas fa-robot mb-1 block text-lg"></i>AI管理
</a>
{% endif %}
</div>
</div>
</header>
{% endif %}
<main class="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 animate-fade-in">
{% if messages %}
<div class="mb-6 space-y-2">
{% for message in messages %}
<div class="rounded-md p-4 shadow-sm border-l-4 flex items-center
{% if message.tags == 'error' %}bg-red-50 border-red-500 text-red-700
{% elif message.tags == 'success' %}bg-green-50 border-green-500 text-green-700
{% else %}bg-blue-50 border-blue-500 text-blue-700{% endif %}">
<i class="fas {% if message.tags == 'error' %}fa-exclamation-circle{% elif message.tags == 'success' %}fa-check-circle{% else %}fa-info-circle{% endif %} mr-3 text-lg"></i>
<p class="text-sm font-medium">{{ message }}</p>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="bg-white border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm text-gray-500">
&copy; {% now "Y" %} 评委评分系统. All rights reserved.
</p>
</div>
</footer>
<script>
function logout() {
if(confirm('确定要退出登录吗?')) {
window.location.href = "{% url 'judge_logout' %}";
}
}
// 通用 Fetch 封装,处理 CSRF
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
async function apiCall(url, method='POST', data=null) {
const options = {
method: method,
headers: {
'X-CSRFToken': csrftoken
}
};
if (data && !(data instanceof FormData)) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(data);
} else if (data instanceof FormData) {
options.body = data;
}
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (e) {
console.error('API Error:', e);
alert('操作失败: ' + e.message);
throw e;
}
}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,903 @@
{% extends 'judge/base.html' %}
{% block title %}项目列表 - 评委系统{% endblock %}
{% block extra_css %}
<style>
.markdown-body p { margin-bottom: 0.5em; }
.markdown-body ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 0.5em; }
.markdown-body ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 0.5em; }
.markdown-body strong { font-weight: 600; }
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { font-weight: 600; margin-top: 1em; margin-bottom: 0.5em; }
.markdown-body { overflow: hidden; }
.line-clamp-5 {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
{% endblock %}
{% block content %}
<div class="flex flex-col sm:flex-row justify-between items-center mb-8 gap-4">
<div>
<h2 class="text-3xl font-bold text-gray-900 tracking-tight">参赛项目列表</h2>
<p class="mt-1 text-sm text-gray-500">请对以下分配给您的项目进行评审</p>
</div>
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
<button class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all transform hover:scale-105" onclick="openUploadModal()">
<i class="fas fa-cloud-upload-alt mr-2"></i>批量上传音频
</button>
{% endif %}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{% for project in projects %}
<div class="bg-white rounded-xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden group flex flex-col h-full border border-gray-100" data-id="{{ project.id }}">
<div class="relative overflow-hidden h-48">
{% if project.cover_image_url %}
<img src="{{ project.cover_image_url }}" alt="{{ project.title }}" class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-500">
{% else %}
<div class="w-full h-full bg-gray-100 flex flex-col items-center justify-center text-gray-400">
<i class="fas fa-image text-4xl mb-2"></i>
<span class="text-sm">暂无封面</span>
</div>
{% endif %}
<div class="absolute top-2 right-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ project.status_class }} shadow-sm bg-opacity-90 backdrop-filter backdrop-blur-sm">
{{ project.get_status_display }}
</span>
</div>
</div>
<div class="p-6 flex-1 flex flex-col">
<h3 class="text-xl font-bold text-gray-900 mb-2 line-clamp-1" title="{{ project.title }}">{{ project.title }}</h3>
<div class="flex items-center text-sm text-gray-500 mb-4">
<i class="fas fa-user-circle mr-2 text-gray-400"></i>
<span>{{ project.contestant_name }}</span>
</div>
<div class="mt-auto pt-4 border-t border-gray-100 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-xs text-gray-400 uppercase tracking-wider font-semibold">当前得分</span>
<span class="text-lg font-bold text-blue-600 score-display">{{ project.current_score|default:"--" }}</span>
</div>
<button class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors" onclick="viewProject({{ project.id }})">
详情 & 评分 <i class="fas fa-arrow-right ml-2 text-xs"></i>
</button>
</div>
</div>
</div>
{% empty %}
<div class="col-span-full">
<div class="text-center py-16 bg-white rounded-xl shadow-sm border border-gray-100">
<div class="mx-auto h-24 w-24 text-gray-200">
<i class="fas fa-folder-open text-6xl"></i>
</div>
<h3 class="mt-4 text-lg font-medium text-gray-900">暂无项目</h3>
<p class="mt-1 text-sm text-gray-500">当前没有分配给您的参赛项目。</p>
</div>
</div>
{% endfor %}
</div>
<!-- Project Detail & Grading Modal -->
<div id="projectModal" class="modal fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6" style="background-color: rgba(0,0,0,0.5);">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col relative animate-fade-in">
<button class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 z-10 p-2 rounded-full hover:bg-gray-100 transition-colors" onclick="closeModal('projectModal')">
<i class="fas fa-times text-xl"></i>
</button>
<div class="px-8 py-6 border-b border-gray-100 bg-gray-50">
<h2 class="text-2xl font-bold text-gray-900" id="modalTitle">项目标题</h2>
<div class="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span class="flex items-center"><i class="fas fa-hashtag mr-1"></i> <span id="modalId"></span></span>
<span class="flex items-center"><i class="fas fa-user mr-1"></i> <span id="modalContestant"></span></span>
</div>
</div>
<div class="flex-1 overflow-y-auto p-8">
<div class="flex flex-col lg:flex-row gap-8">
<!-- Left Column: Info -->
<div class="flex-1 space-y-6">
<div>
<h4 class="text-lg font-semibold text-gray-900 mb-3 flex items-center"><i class="fas fa-info-circle mr-2 text-blue-500"></i>项目简介</h4>
<div id="modalDesc" class="bg-gray-50 p-4 rounded-lg text-gray-700 text-sm leading-relaxed border border-gray-100 max-h-48 overflow-y-auto"></div>
</div>
<div>
<h4 class="text-lg font-semibold text-gray-900 mb-3 flex items-center"><i class="fas fa-headphones mr-2 text-blue-500"></i>项目录音</h4>
<div id="modalAudioSection" class="bg-gray-50 p-4 rounded-t-lg border border-gray-100 flex items-center justify-center min-h-[80px]">
<!-- Audio player or "No audio" message will be injected here -->
</div>
<div id="subtitleContainer" class="bg-black text-white p-3 rounded-b-lg text-center min-h-[48px] flex items-center justify-center text-lg font-medium" style="display: none;">
<span id="subtitleText"></span>
</div>
</div>
<div id="aiResultSection" style="display:none;" class="border border-indigo-100 rounded-xl overflow-hidden">
<div class="bg-indigo-50 px-4 py-3 border-b border-indigo-100 flex items-center">
<i class="fas fa-robot text-indigo-600 mr-2"></i>
<h4 class="text-sm font-bold text-indigo-900 uppercase tracking-wide">AI 智能分析</h4>
</div>
<div class="p-4 bg-white space-y-4">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">AI 总结</p>
<div class="relative">
<div class="text-sm text-gray-800 markdown-body line-clamp-5 transition-all duration-300" id="modalAiSummary"></div>
<button id="toggleAiSummaryBtn" type="button" onclick="toggleAiSummary()" class="text-xs text-blue-600 hover:text-blue-800 focus:outline-none flex items-center mt-2 hidden">
<i class="fas fa-chevron-down mr-1" id="toggleAiSummaryIcon"></i> <span id="toggleAiSummaryText">点击完整显示</span>
</button>
</div>
</div>
<div class="border-t border-gray-100 pt-3 relative">
<div class="flex justify-between items-center mb-1">
<p class="text-xs font-semibold text-gray-500 uppercase">逐字稿片段</p>
<button type="button" onclick="openFullTranscriptionModal()" class="text-xs text-blue-600 hover:text-blue-800 focus:outline-none flex items-center">
<i class="fas fa-expand-arrows-alt mr-1"></i> 查看完整逐字稿与章节
</button>
</div>
<p class="text-sm text-gray-600 italic" id="modalAiTrans"></p>
</div>
</div>
</div>
<div>
<h4 class="text-lg font-semibold text-gray-900 mb-3 flex items-center"><i class="fas fa-history mr-2 text-blue-500"></i>历史评语</h4>
<div id="modalHistoryComments" class="space-y-3 max-h-60 overflow-y-auto pr-2">
<!-- Loaded via JS -->
</div>
</div>
<!-- 评分细项(仅评委和嘉宾可见) -->
<div id="scoreDetailsSection" style="display: none;" class="bg-gradient-to-br from-gray-50 to-blue-50 rounded-xl p-5 border border-gray-200 shadow-sm">
<h4 class="text-lg font-bold text-gray-900 mb-4 flex items-center"><i class="fas fa-chart-bar mr-2 text-indigo-500"></i>评分明细</h4>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-gray-500 uppercase">评委评分</span>
<i class="fas fa-user-tie text-blue-400 text-sm"></i>
</div>
<span id="judgeScoreValue" class="text-2xl font-bold text-blue-600">--</span>
</div>
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-gray-500 uppercase">选手互评分</span>
<i class="fas fa-users text-green-400 text-sm"></i>
</div>
<span id="peerScoreValue" class="text-2xl font-bold text-green-600">--</span>
</div>
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-gray-500 uppercase">AI评分</span>
<i class="fas fa-robot text-purple-400 text-sm"></i>
</div>
<span id="aiScoreValue" class="text-2xl font-bold text-purple-600">--</span>
</div>
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-gray-500 uppercase">最终总分</span>
<i class="fas fa-star text-yellow-400 text-sm"></i>
</div>
<span id="finalScoreValue" class="text-2xl font-bold text-yellow-600">--</span>
</div>
</div>
<!-- 评分公式信息 -->
<div id="formulaInfoSection" class="mt-4 p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-200">
<div class="flex items-center mb-2">
<i class="fas fa-calculator text-indigo-500 mr-2"></i>
<span class="text-sm font-bold text-indigo-700">评分算法</span>
</div>
<div id="formulaName" class="text-sm font-semibold text-gray-800 mb-1">--</div>
<div id="formulaPreview" class="text-xs text-gray-600 bg-white p-2 rounded border border-indigo-100 font-mono break-all">--</div>
</div>
</div>
</div>
<!-- Right Column: Grading -->
<div class="lg:w-1/3 bg-gray-50 p-6 rounded-xl border border-gray-200 h-fit sticky top-0">
<h4 class="text-lg font-bold text-gray-900 mb-4 flex items-center"><i class="fas fa-star mr-2 text-yellow-500"></i>打分 & 评语</h4>
<form id="gradingForm" onsubmit="submitScore(event)" class="space-y-6">
<input type="hidden" id="projectId" name="project_id">
<div id="scoreDimensions" class="space-y-4">
<!-- Dimensions loaded via JS -->
</div>
<div id="totalScoreDisplay" class="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-blue-800">综合得分</span>
<span id="totalScoreValue" class="text-2xl font-bold text-blue-600">0</span>
</div>
<p class="text-xs text-blue-500 mt-1">各维度分数×权重相加,提交后计算所有评委平均值</p>
</div>
<div class="space-y-2">
<label for="comment" class="block text-sm font-medium text-gray-700">评语建议</label>
<textarea id="comment" name="comment" rows="4"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-3"
placeholder="请输入您的专业点评..."></textarea>
</div>
<div class="pt-4 border-t border-gray-200 flex items-center justify-between">
<span id="saveStatus" class="text-green-600 text-sm font-medium opacity-0 transition-opacity duration-300 flex items-center">
<i class="fas fa-check mr-1"></i> 已保存
</span>
<button type="submit" class="inline-flex justify-center py-2 px-6 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
提交评分
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Modal -->
<div id="uploadModal" class="modal fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: rgba(0,0,0,0.5);">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md overflow-hidden animate-fade-in relative">
<button class="absolute top-3 right-3 text-gray-400 hover:text-gray-600" onclick="closeModal('uploadModal')">
<i class="fas fa-times"></i>
</button>
<div class="px-6 py-4 bg-gray-50 border-b border-gray-100">
<h2 class="text-lg font-bold text-gray-900">上传项目音频</h2>
</div>
<div class="p-6">
<form id="uploadForm" onsubmit="uploadFiles(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">选择项目</label>
<select id="uploadProjectSelect" required class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md">
{% for project in projects %}
<option value="{{ project.id }}">{{ project.title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">选择上传方式</label>
<div class="flex space-x-4 mb-3">
<label class="inline-flex items-center">
<input type="radio" name="uploadType" value="file" checked class="form-radio text-blue-600" onchange="toggleUploadType()">
<span class="ml-2 text-sm text-gray-700">文件上传</span>
</label>
<label class="inline-flex items-center">
<input type="radio" name="uploadType" value="url" class="form-radio text-blue-600" onchange="toggleUploadType()">
<span class="ml-2 text-sm text-gray-700">URL 上传</span>
</label>
</div>
</div>
<!-- 文件上传 -->
<div id="fileUploadSection">
<label class="block text-sm font-medium text-gray-700 mb-1">选择文件 (支持mp3/mp4, &le;50MB)</label>
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors cursor-pointer" onclick="document.getElementById('fileInput').click()">
<div class="space-y-1 text-center">
<i class="fas fa-cloud-upload-alt text-gray-400 text-3xl mb-2"></i>
<div class="flex text-sm text-gray-600">
<label for="fileInput" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none">
<span>点击上传</span>
<input id="fileInput" name="fileInput" type="file" class="sr-only" multiple accept="audio/mpeg,audio/mp4,audio/*,.mp3,.mp4" onchange="updateFileName(this)">
</label>
<p class="pl-1">或拖拽文件到这里</p>
</div>
<p class="text-xs text-gray-500">MP3, MP4 up to 50MB</p>
<p id="fileNameDisplay" class="text-xs text-blue-600 mt-2 font-medium"></p>
</div>
</div>
</div>
<!-- URL 上传 -->
<div id="urlUploadSection" style="display: none;">
<label class="block text-sm font-medium text-gray-700 mb-1">音频 URL 地址</label>
<input type="url" id="audioUrlInput" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-3" placeholder="https://example.com/audio.mp3">
<p class="text-xs text-gray-500 mt-1">支持 MP3、MP4 等音频/视频格式的直链</p>
</div>
<div id="uploadProgressContainer" style="display: none;" class="bg-gray-50 p-3 rounded-md">
<div class="flex justify-between text-xs text-gray-600 mb-1">
<span id="uploadStatusText">准备上传...</span>
<span id="uploadPercent">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div id="uploadProgressBar" class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
开始上传
</button>
</form>
</div>
</div>
</div>
<!-- Full Transcription Modal -->
<div id="fullTranscriptionModal" class="modal fixed inset-0 z-[60] flex items-center justify-center p-4 sm:p-6" style="background-color: rgba(0,0,0,0.6);">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col relative animate-fade-in">
<button class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 z-10 p-2 rounded-full hover:bg-gray-100 transition-colors" onclick="closeModal('fullTranscriptionModal')">
<i class="fas fa-times text-xl"></i>
</button>
<div class="px-8 py-5 border-b border-gray-100 bg-gray-50 rounded-t-2xl flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-900 flex items-center"><i class="fas fa-file-alt text-blue-500 mr-2"></i>完整逐字稿与章节</h2>
</div>
<div class="flex-1 overflow-y-auto p-8">
<div class="space-y-8">
<!-- Chapters Section -->
<div id="chaptersSection" style="display:none;">
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center border-b pb-2"><i class="fas fa-list-ul mr-2 text-indigo-500"></i>章节内容</h3>
<div id="modalChaptersContent" class="space-y-4">
<!-- Chapters rendered here -->
</div>
</div>
<!-- Full Transcription Section -->
<div>
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center border-b pb-2"><i class="fas fa-align-left mr-2 text-green-500"></i>完整逐字稿</h3>
<div id="modalFullTrans" class="text-gray-700 text-sm leading-relaxed bg-gray-50 p-6 rounded-xl border border-gray-100 whitespace-pre-wrap"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.staticfile.net/marked/11.1.1/marked.min.js"></script>
<script>
/**
* 更新单个维度分数显示并计算总分
*/
function updateDimensionScore(dimensionId, maxScore, weight, value) {
// 更新分数显示
document.getElementById('score_val_' + dimensionId).innerText = value;
// 重新计算总分
calculateTotalScore();
}
/**
* 计算评委的综合得分
* 公式:直接用原始分数乘以权重相加 (与后端逻辑一致)
*/
function calculateTotalScore() {
const dimensionsContainer = document.getElementById('scoreDimensions');
const dimensionDivs = dimensionsContainer.querySelectorAll('[data-dimension-id]');
let totalScore = 0;
dimensionDivs.forEach(div => {
const weight = parseFloat(div.dataset.weight);
const dimensionId = div.dataset.dimensionId;
// 获取当前分数
const scoreInput = document.querySelector('input[name="score_' + dimensionId + '"]');
if (scoreInput) {
const score = parseFloat(scoreInput.value) || 0;
// 直接用原始分数乘以权重相加
totalScore += score * weight;
}
});
// 更新显示
const totalScoreElement = document.getElementById('totalScoreValue');
if (totalScoreElement) {
totalScoreElement.innerText = totalScore.toFixed(1);
}
}
/**
* 切换 AI 总结内容的显示状态(折叠/展开)
* 通过添加或移除 line-clamp-5 类来实现截断或完整显示,并更新按钮的文字和图标。
*/
function toggleAiSummary() {
const summaryDiv = document.getElementById('modalAiSummary');
const toggleText = document.getElementById('toggleAiSummaryText');
const toggleIcon = document.getElementById('toggleAiSummaryIcon');
if (summaryDiv.classList.contains('line-clamp-5')) {
summaryDiv.classList.remove('line-clamp-5');
toggleText.innerText = '收起内容';
toggleIcon.className = 'fas fa-chevron-up mr-1';
} else {
summaryDiv.classList.add('line-clamp-5');
toggleText.innerText = '点击完整显示';
toggleIcon.className = 'fas fa-chevron-down mr-1';
}
}
function updateFileName(input) {
const display = document.getElementById('fileNameDisplay');
if (input.files.length > 0) {
display.innerText = `已选: ${input.files.length} 个文件`;
} else {
display.innerText = '';
}
}
/**
* 切换文件上传和URL上传的显示
* 根据用户选择显示对应的输入区域
*/
function toggleUploadType() {
const uploadType = document.querySelector('input[name="uploadType"]:checked').value;
const fileUploadSection = document.getElementById('fileUploadSection');
const urlUploadSection = document.getElementById('urlUploadSection');
const fileInput = document.getElementById('fileInput');
const urlInput = document.getElementById('audioUrlInput');
if (uploadType === 'file') {
fileUploadSection.style.display = 'block';
urlUploadSection.style.display = 'none';
fileInput.required = true;
urlInput.required = false;
} else {
fileUploadSection.style.display = 'none';
urlUploadSection.style.display = 'block';
fileInput.required = false;
urlInput.required = true;
}
}
function closeModal(id) {
const modal = document.getElementById(id);
modal.classList.remove('active');
// Stop audio if it's playing when modal is closed
const audios = modal.querySelectorAll('audio');
audios.forEach(audio => {
audio.pause();
});
}
function openUploadModal() {
document.getElementById('uploadModal').classList.add('active');
}
function openFullTranscriptionModal() {
if (!window.currentAiData) return;
document.getElementById('modalFullTrans').innerText = window.currentAiData.transcription || '暂无完整逐字稿';
let chaptersData = window.currentAiData.auto_chapters_data;
const chaptersSection = document.getElementById('chaptersSection');
const chaptersContent = document.getElementById('modalChaptersContent');
// Check if chaptersData is a string and parse it if necessary
if (typeof chaptersData === 'string') {
try {
chaptersData = JSON.parse(chaptersData);
} catch (e) {
console.error('Failed to parse auto_chapters_data:', e);
chaptersData = null;
}
}
if (chaptersData && chaptersData.AutoChapters && chaptersData.AutoChapters.length > 0) {
chaptersSection.style.display = 'block';
chaptersContent.innerHTML = chaptersData.AutoChapters.map(chapter => {
// Convert ms to mm:ss format
const formatTime = ms => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const start = formatTime(chapter.Start);
const end = formatTime(chapter.End);
return `
<div class="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
<div class="flex items-center justify-between mb-2">
<h4 class="font-bold text-gray-800 text-sm">${chapter.Headline || '未命名章节'}</h4>
<span class="text-xs font-mono text-indigo-600 bg-indigo-50 px-2 py-1 rounded">${start} - ${end}</span>
</div>
<p class="text-sm text-gray-600">${chapter.Summary || '无摘要'}</p>
</div>
`;
}).join('');
} else {
chaptersSection.style.display = 'none';
}
document.getElementById('fullTranscriptionModal').classList.add('active');
}
async function viewProject(id) {
try {
// Show loading state or skeleton if possible, for now just fetch
const data = await fetch(`/judge/api/projects/${id}/`).then(res => res.json());
document.getElementById('projectId').value = id;
document.getElementById('modalTitle').innerText = data.title;
document.getElementById('modalId').innerText = data.id;
document.getElementById('modalContestant').innerText = data.contestant_name;
document.getElementById('modalDesc').innerHTML = data.description || '<span class="text-gray-400 italic">暂无简介</span>';
// Render Audio Player
const audioSection = document.getElementById('modalAudioSection');
const subtitleContainer = document.getElementById('subtitleContainer');
const subtitleText = document.getElementById('subtitleText');
if (subtitleContainer && subtitleText) {
subtitleText.innerText = '';
subtitleContainer.style.display = 'none';
}
if (data.audio_url) {
audioSection.innerHTML = `
<audio id="projectAudio" controls class="w-full">
<source src="${data.audio_url}" type="audio/mpeg">
<source src="${data.audio_url}" type="audio/mp4">
您的浏览器不支持音频播放。
</audio>
`;
audioSection.classList.remove('justify-center');
} else {
audioSection.innerHTML = `
<div class="text-center text-gray-400 py-2">
<i class="fas fa-microphone-slash text-2xl mb-2 block"></i>
<span class="text-sm">暂未上传录音</span>
</div>
`;
audioSection.classList.add('justify-center');
}
// AI Result
const aiSection = document.getElementById('aiResultSection');
if (data.ai_result) {
aiSection.style.display = 'block';
// Subtitle integration
if (data.audio_url && data.ai_result.transcription_data && subtitleContainer && subtitleText) {
let transData = data.ai_result.transcription_data;
if (typeof transData === 'string') {
try { transData = JSON.parse(transData); } catch(e) { console.error('Error parsing transcription_data', e); }
}
if (transData && transData.Transcription && transData.Transcription.Paragraphs) {
subtitleContainer.style.display = 'flex';
const sentences = [];
transData.Transcription.Paragraphs.forEach(p => {
if (p.Words && p.Words.length > 0) {
let currentSentenceId = null;
let currentSentence = { text: '', start: 0, end: 0 };
p.Words.forEach(w => {
if (w.SentenceId !== currentSentenceId) {
if (currentSentenceId !== null) {
sentences.push({...currentSentence});
}
currentSentenceId = w.SentenceId;
currentSentence = { text: w.Text, start: w.Start, end: w.End };
} else {
currentSentence.text += w.Text;
currentSentence.end = w.End;
}
});
if (currentSentenceId !== null) {
sentences.push({...currentSentence});
}
}
});
const audio = document.getElementById('projectAudio');
if (audio) {
audio.addEventListener('timeupdate', () => {
const currentTimeMs = audio.currentTime * 1000;
// Add a small buffer (e.g. 200ms) to make subtitle display smoother
const activeSentence = sentences.find(s => currentTimeMs >= (s.start - 200) && currentTimeMs <= (s.end + 200));
if (activeSentence) {
subtitleText.innerText = activeSentence.text;
} else {
subtitleText.innerText = '';
}
});
}
}
}
const summaryDiv = document.getElementById('modalAiSummary');
const toggleBtn = document.getElementById('toggleAiSummaryBtn');
const toggleText = document.getElementById('toggleAiSummaryText');
const toggleIcon = document.getElementById('toggleAiSummaryIcon');
const summaryText = data.ai_result.summary || '暂无总结';
summaryDiv.innerHTML = marked.parse(summaryText);
// Reset to collapsed state
summaryDiv.classList.add('line-clamp-5');
toggleText.innerText = '点击完整显示';
toggleIcon.className = 'fas fa-chevron-down mr-1';
// Show/hide toggle button based on content height
setTimeout(() => {
if (summaryDiv.scrollHeight > summaryDiv.clientHeight) {
toggleBtn.classList.remove('hidden');
} else {
toggleBtn.classList.add('hidden');
}
}, 50);
document.getElementById('modalAiTrans').innerText = (data.ai_result.transcription || '暂无内容').substring(0, 150) + '...';
// Store full data for full transcription modal
window.currentAiData = data.ai_result;
} else {
aiSection.style.display = 'none';
window.currentAiData = null;
}
// Render History Comments
const historyHtml = data.history_comments.length > 0 ? data.history_comments.map(c =>
`<div class="bg-white p-3 rounded border border-gray-100 shadow-sm">
<div class="flex justify-between items-center mb-1">
<span class="font-bold text-sm text-gray-800">${c.judge_name}</span>
<span class="text-xs text-gray-400">${c.created_at}</span>
</div>
<p class="text-sm text-gray-600">${c.content}</p>
</div>`
).join('') : '<div class="text-center text-gray-400 py-4 text-sm">暂无历史评语</div>';
document.getElementById('modalHistoryComments').innerHTML = historyHtml;
// 渲染评分细项(仅评委和嘉宾可见)
const scoreDetailsSection = document.getElementById('scoreDetailsSection');
if (data.score_details) {
scoreDetailsSection.style.display = 'block';
document.getElementById('judgeScoreValue').innerText = data.score_details.judge_score !== null ? data.score_details.judge_score : '--';
document.getElementById('peerScoreValue').innerText = data.score_details.peer_score !== null ? data.score_details.peer_score : '--';
document.getElementById('aiScoreValue').innerText = data.score_details.ai_score !== null ? data.score_details.ai_score : '--';
document.getElementById('finalScoreValue').innerText = data.score_details.final_score !== null ? data.score_details.final_score : '--';
// 渲染评分公式信息
const formulaInfoSection = document.getElementById('formulaInfoSection');
if (data.formula_info && data.formula_info.name) {
formulaInfoSection.style.display = 'block';
document.getElementById('formulaName').innerText = data.formula_info.name;
document.getElementById('formulaPreview').innerText = data.formula_info.preview || data.formula_info.formula || '--';
} else {
formulaInfoSection.style.display = 'none';
}
} else {
scoreDetailsSection.style.display = 'none';
}
// Render Score Inputs
const dimensionsHtml = data.dimensions.map(d => `
<div class="bg-white p-3 rounded-lg border border-gray-200" data-dimension-id="${d.id}" data-max-score="${d.max_score}" data-weight="${d.weight}">
<div class="flex justify-between items-center mb-2">
<label class="text-sm font-medium text-gray-700">${d.name}</label>
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">满分: ${d.max_score} | 权重: ${d.weight}</span>
</div>
<div class="flex items-center gap-4">
<input type="range" min="0" max="${d.max_score}" step="1" value="${d.current_score || 0}"
oninput="updateDimensionScore('${d.id}', '${d.max_score}', '${d.weight}', this.value)"
name="score_${d.id}" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="w-12 text-right">
<span id="score_val_${d.id}" class="text-lg font-bold text-blue-600">${d.current_score || 0}</span>
<span class="text-xs text-gray-400">/${d.max_score}</span>
</div>
</div>
</div>
`).join('');
document.getElementById('scoreDimensions').innerHTML = dimensionsHtml;
// 计算初始总分
calculateTotalScore();
document.getElementById('comment').value = data.current_comment || '';
// Handle Grading Permission
const gradingForm = document.getElementById('gradingForm');
const gradingContainer = gradingForm.parentElement;
let readOnlyMsg = document.getElementById('readOnlyMsg');
if (!readOnlyMsg) {
readOnlyMsg = document.createElement('div');
readOnlyMsg.id = 'readOnlyMsg';
readOnlyMsg.className = 'text-center py-10 bg-white rounded-lg border border-dashed border-gray-300 mt-4';
gradingContainer.appendChild(readOnlyMsg);
}
if (data.is_own_project) {
readOnlyMsg.className = 'text-center py-10 bg-white rounded-lg border border-dashed border-blue-300 mt-4';
readOnlyMsg.innerHTML = `
<div class="mx-auto h-12 w-12 bg-blue-50 rounded-full flex items-center justify-center mb-3">
<i class="fas fa-info-circle text-2xl text-blue-400"></i>
</div>
<h3 class="text-base font-medium text-gray-900">查看自己的项目</h3>
<p class="mt-1 text-sm text-gray-500">无法对自己的项目进行评分,但可以查看详细评分</p>
`;
} else {
readOnlyMsg.className = 'text-center py-10 bg-white rounded-lg border border-dashed border-gray-300 mt-4';
readOnlyMsg.innerHTML = `
<div class="mx-auto h-12 w-12 bg-gray-50 rounded-full flex items-center justify-center mb-3">
<i class="fas fa-eye text-2xl text-gray-400"></i>
</div>
<h3 class="text-base font-medium text-gray-900">仅浏览模式</h3>
<p class="mt-1 text-sm text-gray-500">当前身份无法进行评分</p>
`;
}
if (data.can_grade) {
gradingForm.style.display = 'block';
readOnlyMsg.style.display = 'none';
} else {
gradingForm.style.display = 'none';
readOnlyMsg.style.display = 'block';
}
document.getElementById('projectModal').classList.add('active');
} catch (e) {
console.error(e);
alert('加载项目详情失败');
}
}
async function submitScore(e) {
e.preventDefault();
if(!confirm('确认提交评分吗?')) return;
const form = e.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
try {
const res = await fetch('/judge/api/score/submit/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify(data)
});
const result = await res.json();
if(result.success) {
const status = document.getElementById('saveStatus');
status.style.opacity = '1';
setTimeout(() => status.style.opacity = '0', 2000);
// Optional: Update card score in background
const cardScore = document.querySelector(`.project-card[data-id="${data.project_id}"] .score-display`);
if(cardScore) cardScore.innerText = '已评分'; // Or fetch new score
} else {
alert('提交失败: ' + result.message);
}
} catch(e) {
alert('提交出错');
}
}
async function uploadFiles(e) {
e.preventDefault();
const projectSelect = document.getElementById('uploadProjectSelect');
const fileInput = document.getElementById('fileInput');
const audioUrlInput = document.getElementById('audioUrlInput');
const projectId = projectSelect.value;
const files = fileInput.files;
const uploadType = document.querySelector('input[name="uploadType"]:checked').value;
const progressBar = document.getElementById('uploadProgressBar');
const statusText = document.getElementById('uploadStatusText');
const percentText = document.getElementById('uploadPercent');
const container = document.getElementById('uploadProgressContainer');
container.style.display = 'block';
if (uploadType === 'url') {
// URL 上传模式
const audioUrl = audioUrlInput.value.trim();
if (!audioUrl) {
alert('请输入音频 URL');
container.style.display = 'none';
return;
}
statusText.innerText = '正在处理 URL...';
progressBar.style.width = '30%';
percentText.innerText = '30%';
try {
const res = await fetch('/judge/api/upload/url/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
url: audioUrl,
project_id: projectId
})
});
const result = await res.json();
if (result.success) {
progressBar.style.width = '100%';
percentText.innerText = '100%';
statusText.innerText = '上传成功!';
setTimeout(() => {
closeModal('uploadModal');
container.style.display = 'none';
window.location.href = "{% url 'judge_ai_manage' %}";
}, 1000);
} else {
alert('上传失败: ' + result.message);
container.style.display = 'none';
}
} catch (err) {
alert('上传出错: ' + err);
container.style.display = 'none';
}
return;
}
// 文件上传模式 (原有用 XMLHttpRequest)
if (files.length === 0) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > 50 * 1024 * 1024) {
alert(`文件 ${file.name} 超过 50MB跳过`);
continue;
}
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', projectId);
statusText.innerText = `正在上传 ${file.name} (${i+1}/${files.length})...`;
progressBar.style.width = '0%';
percentText.innerText = '0%';
try {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/judge/api/upload/', true);
xhr.setRequestHeader('X-CSRFToken', '{{ csrf_token }}');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percentComplete + '%';
percentText.innerText = percentComplete + '%';
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const res = JSON.parse(xhr.responseText);
if(res.success) resolve();
else reject(res.message);
} else {
reject(xhr.statusText);
}
};
xhr.onerror = () => reject('Network Error');
xhr.send(formData);
});
} catch (err) {
alert(`上传 ${file.name} 失败: ${err}`);
}
}
statusText.innerText = '所有任务完成!';
progressBar.style.width = '100%';
percentText.innerText = '100%';
setTimeout(() => {
closeModal('uploadModal');
container.style.display = 'none';
window.location.href = "{% url 'judge_ai_manage' %}";
}, 1000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,129 @@
{% extends 'judge/base.html' %}
{% block title %}评委登录{% endblock %}
{% block content %}
<div class="fixed inset-0 z-0 bg-gradient-to-br from-blue-500 to-indigo-700 flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 bg-white p-10 rounded-2xl shadow-2xl transform transition-all hover:scale-105 duration-300">
<div>
<div class="mx-auto h-16 w-16 bg-blue-100 rounded-full flex items-center justify-center">
<i class="fas fa-gavel text-3xl text-blue-600"></i>
</div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
评委登录
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
请输入您的手机号验证登录
</p>
</div>
<form class="mt-8 space-y-6" method="post" action="{% url 'judge_login' %}">
{% csrf_token %}
<div class="rounded-md shadow-sm -space-y-px">
<div class="mb-4">
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">手机号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-mobile-alt text-gray-400"></i>
</div>
<input type="tel" id="phone" name="phone" required pattern="[0-9]{11}"
class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors"
placeholder="请输入11位手机号">
</div>
</div>
<div>
<label for="code" class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
<div class="flex gap-2">
<div class="relative rounded-md shadow-sm flex-1">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-shield-alt text-gray-400"></i>
</div>
<input type="text" id="code" name="code" required
class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors"
placeholder="请输入验证码">
</div>
<button type="button" id="sendCodeBtn" onclick="sendSmsCode()"
class="whitespace-nowrap inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors w-32 justify-center">
发送验证码
</button>
</div>
</div>
</div>
<div>
<button type="submit"
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-md hover:shadow-lg transition-all duration-200">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<i class="fas fa-sign-in-alt text-blue-300 group-hover:text-blue-100"></i>
</span>
登录系统
</button>
</div>
</form>
{% if error %}
<div class="rounded-md bg-red-50 p-4 border border-red-200 animate-pulse">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-times-circle text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">登录失败</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ error }}</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
async function sendSmsCode() {
const phone = document.getElementById('phone').value;
if (!phone || phone.length !== 11) {
alert('请输入有效的11位手机号');
return;
}
const btn = document.getElementById('sendCodeBtn');
btn.disabled = true;
let countdown = 60;
try {
const response = await fetch("{% url 'judge_send_code' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ phone: phone })
});
const data = await response.json();
if (data.success) {
alert('验证码已发送');
const timer = setInterval(() => {
btn.innerText = `${countdown}s 后重发`;
countdown--;
if (countdown < 0) {
clearInterval(timer);
btn.disabled = false;
btn.innerText = '发送验证码';
}
}, 1000);
} else {
alert('发送失败: ' + data.message);
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('网络错误,请重试');
btn.disabled = false;
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,26 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
ScoreViewSet, CommentViewSet, CompetitionDimensionsAPIView
)
from . import judge_views
router = DefaultRouter()
router.register(r'competitions', CompetitionViewSet)
router.register(r'projects', ProjectViewSet, basename='project')
router.register(r'files', ProjectFileViewSet, basename='projectfile')
router.register(r'scores', ScoreViewSet, basename='score')
router.register(r'comments', CommentViewSet, basename='comment')
urlpatterns = [
# Judge System Routes
path('admin/', judge_views.admin_entry, name='judge_admin_entry'),
path('judge/', include('competition.judge_urls')), # Sub-routes under /judge/
# API Routes
path('competition/<int:competition_id>/dimensions/', CompetitionDimensionsAPIView.as_view(), name='competition-dimensions'),
# Existing API Routes
path('', include(router.urls)),
]

View File

@@ -0,0 +1,311 @@
from rest_framework import viewsets, permissions, status, filters, serializers
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
from django.db.models import Q
from shop.utils import get_current_wechat_user
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension
from .serializers import (
CompetitionSerializer, CompetitionEnrollmentSerializer,
ProjectSerializer, ProjectFileSerializer,
ScoreSerializer, CommentSerializer, ScoreDimensionSerializer
)
from rest_framework.pagination import PageNumberPagination
class StandardResultsSetPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
class CompetitionViewSet(viewsets.ReadOnlyModelViewSet):
"""
比赛视图集
"""
queryset = Competition.objects.filter(is_active=True).order_by('-created_at')
serializer_class = CompetitionSerializer
permission_classes = [permissions.AllowAny]
pagination_class = StandardResultsSetPagination
filter_backends = [filters.SearchFilter]
search_fields = ['title', 'description']
def get_queryset(self):
"""
获取比赛查询集,支持根据查询参数进行动态过滤
"""
queryset = super().get_queryset()
# 状态过滤
status_param = self.request.query_params.get('status')
if status_param and status_param != 'all':
queryset = queryset.filter(status=status_param)
return queryset
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
def enroll(self, request, pk=None):
"""
报名参加比赛
"""
competition = self.get_object()
user = get_current_wechat_user(request)
if not user:
return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED)
role = request.data.get('role', 'contestant')
# 检查是否已报名
if CompetitionEnrollment.objects.filter(competition=competition, user=user).exists():
return Response({"detail": "您已报名该比赛"}, status=status.HTTP_400_BAD_REQUEST)
enrollment = CompetitionEnrollment.objects.create(
competition=competition,
user=user,
role=role,
status='pending' # 默认待审核
)
return Response(CompetitionEnrollmentSerializer(enrollment).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def my_enrollment(self, request, pk=None):
"""
获取我的报名信息
"""
competition = self.get_object()
user = get_current_wechat_user(request)
if not user:
return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED)
try:
enrollment = CompetitionEnrollment.objects.get(competition=competition, user=user)
return Response(CompetitionEnrollmentSerializer(enrollment).data)
except CompetitionEnrollment.DoesNotExist:
return Response({"detail": "未报名"}, status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=['get'])
def my_enrollments(self, request):
"""
获取我的所有报名信息
"""
user = get_current_wechat_user(request)
if not user:
return Response([])
enrollments = CompetitionEnrollment.objects.filter(user=user)
return Response(CompetitionEnrollmentSerializer(enrollments, many=True).data)
class ProjectViewSet(viewsets.ModelViewSet):
"""
参赛项目视图集
"""
serializer_class = ProjectSerializer
permission_classes = [permissions.AllowAny]
pagination_class = StandardResultsSetPagination
def get_queryset(self):
queryset = Project.objects.all()
competition_id = self.request.query_params.get('competition')
if competition_id:
queryset = queryset.filter(competition_id=competition_id)
contestant_id = self.request.query_params.get('contestant')
if contestant_id:
queryset = queryset.filter(contestant_id=contestant_id)
user = get_current_wechat_user(self.request)
# 1. 基础条件:公开可见且已提交的项目
q = Q(competition__project_visibility='public', status='submitted')
if user:
# 2. 用户自己的项目(始终可见,包括草稿)
q |= Q(contestant__user=user)
# 3. 基于角色的可见性
# 获取用户已通过审核的报名信息
enrollments = CompetitionEnrollment.objects.filter(user=user, status='approved')
# 获取各角色的比赛ID集合
judge_comp_ids = set(enrollments.filter(role='judge').values_list('competition_id', flat=True))
guest_comp_ids = set(enrollments.filter(role='guest').values_list('competition_id', flat=True))
contestant_comp_ids = set(enrollments.filter(role='contestant').values_list('competition_id', flat=True))
# 'judge' 可见性:仅评委可见
if judge_comp_ids:
q |= Q(competition__project_visibility='judge', competition__in=judge_comp_ids, status='submitted')
# 'guest' 可见性:嘉宾及评委可见
guest_access_ids = judge_comp_ids | guest_comp_ids
if guest_access_ids:
q |= Q(competition__project_visibility='guest', competition__in=guest_access_ids, status='submitted')
# 'contestant' 可见性:选手及以上可见(包括评委、嘉宾)
contestant_access_ids = judge_comp_ids | guest_comp_ids | contestant_comp_ids
if contestant_access_ids:
q |= Q(competition__project_visibility='contestant', competition__in=contestant_access_ids, status='submitted')
queryset = queryset.filter(q)
return queryset.order_by('-final_score', '-created_at')
def perform_create(self, serializer):
user = get_current_wechat_user(self.request)
if not user:
raise serializers.ValidationError("请先登录")
competition = serializer.validated_data['competition']
# 检查是否有参赛资格
try:
enrollment = CompetitionEnrollment.objects.get(
competition=competition,
user=user,
role='contestant',
status='approved'
)
except CompetitionEnrollment.DoesNotExist:
raise serializers.ValidationError("您没有参赛资格或审核未通过")
# 检查是否已提交过项目
if Project.objects.filter(competition=competition, contestant=enrollment).exists():
raise serializers.ValidationError("您已提交过该比赛的项目,请勿重复提交")
serializer.save(contestant=enrollment)
@action(detail=True, methods=['post'])
def submit(self, request, pk=None):
"""
提交项目(从草稿转为已提交)
"""
project = self.get_object()
user = get_current_wechat_user(request)
if project.contestant.user != user:
return Response({"detail": "无权操作"}, status=status.HTTP_403_FORBIDDEN)
project.status = 'submitted'
project.save()
return Response({"status": "submitted"})
class ProjectFileViewSet(viewsets.ModelViewSet):
"""
项目附件管理
"""
serializer_class = ProjectFileSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
return ProjectFile.objects.all()
def perform_create(self, serializer):
# 简单权限控制:只有项目拥有者可以上传
project = serializer.validated_data['project']
user = get_current_wechat_user(self.request)
if not user or project.contestant.user != user:
raise serializers.ValidationError("无权上传文件")
serializer.save()
class ScoreViewSet(viewsets.ModelViewSet):
"""
评分管理
"""
serializer_class = ScoreSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
project_id = self.request.query_params.get('project')
if project_id:
return Score.objects.filter(project_id=project_id)
return Score.objects.all()
def perform_create(self, serializer):
user = get_current_wechat_user(self.request)
if not user:
raise serializers.ValidationError("请先登录")
project = serializer.validated_data['project']
# 检查是否是评委
try:
enrollment = CompetitionEnrollment.objects.get(
competition=project.competition,
user=user,
role='judge',
status='approved'
)
except CompetitionEnrollment.DoesNotExist:
raise serializers.ValidationError("您不是该比赛的评委")
# 检查是否重复打分
dimension = serializer.validated_data['dimension']
if Score.objects.filter(project=project, judge=enrollment, dimension=dimension).exists():
raise serializers.ValidationError("您已对该维度打分")
serializer.save(judge=enrollment)
class CommentViewSet(viewsets.ModelViewSet):
"""
评语管理
"""
serializer_class = CommentSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
project_id = self.request.query_params.get('project')
if project_id:
return Comment.objects.filter(project_id=project_id)
return Comment.objects.all()
def perform_create(self, serializer):
user = get_current_wechat_user(self.request)
if not user:
raise serializers.ValidationError("请先登录")
project = serializer.validated_data['project']
# 检查是否是评委
try:
enrollment = CompetitionEnrollment.objects.get(
competition=project.competition,
user=user,
role='judge',
status='approved'
)
except CompetitionEnrollment.DoesNotExist:
raise serializers.ValidationError("您不是该比赛的评委")
serializer.save(judge=enrollment)
class CompetitionDimensionsAPIView(APIView):
"""
获取比赛评分维度的API
"""
permission_classes = [permissions.AllowAny]
def get(self, request, competition_id):
try:
competition = Competition.objects.get(id=competition_id)
dimensions = ScoreDimension.objects.filter(competition=competition).order_by('order')
data = {
'dimensions': [
{
'id': d.id,
'name': d.name,
'weight': float(d.weight),
'max_score': d.max_score,
'description': d.description
}
for d in dimensions
]
}
return Response(data)
except Competition.DoesNotExist:
return Response({'error': '比赛不存在'}, status=404)

View File

@@ -12,10 +12,14 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# Load .env file
load_dotenv(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
@@ -44,8 +48,11 @@ INSTALLED_APPS = [
'django_filters', 'django_filters',
'drf_spectacular', # Swagger文档生成 'drf_spectacular', # Swagger文档生成
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
# 'adminsortable2', # 暂时禁用,改用手动设置
'shop', 'shop',
'community', 'community',
'competition',
'ai_services',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -64,6 +71,7 @@ CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
"https://market.quant-speed.com", "https://market.quant-speed.com",
"http://market.quant-speed.com", "http://market.quant-speed.com",
"http://localhost:8000",
] ]
ROOT_URLCONF = 'config.urls' ROOT_URLCONF = 'config.urls'
@@ -97,7 +105,8 @@ DATABASES = {
} }
} }
# 从环境变量获取数据库配置 (Docker 环境会自动注入这些变量) #从环境变量获取数据库配置 (Docker 环境会自动注入这些变量
DB_HOST = os.environ.get('DB_HOST', '6.6.6.66') DB_HOST = os.environ.get('DB_HOST', '6.6.6.66')
if DB_HOST: if DB_HOST:
DATABASES['default'] = { DATABASES['default'] = {
@@ -110,6 +119,18 @@ if DB_HOST:
} }
# DB_HOST = os.environ.get('DB_HOST', '121.43.104.161')
# if DB_HOST:
# DATABASES['default'] = {
# 'ENGINE': 'django.db.backends.postgresql',
# 'NAME': os.environ.get('DB_NAME', 'market'),
# 'USER': os.environ.get('DB_USER', 'market'),
# 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
# 'HOST': DB_HOST,
# 'PORT': os.environ.get('DB_PORT', '6433'),
# }
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -152,6 +173,10 @@ STATICFILES_DIRS = [
BASE_DIR / 'static', BASE_DIR / 'static',
] ]
# 媒体文件配置
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Django REST Framework配置 # Django REST Framework配置
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
@@ -297,6 +322,58 @@ UNFOLD = {
}, },
], ],
}, },
{
"title": "比赛管理",
"separator": True,
"items": [
{
"title": "比赛列表",
"icon": "emoji_events",
"link": reverse_lazy("admin:competition_competition_changelist"),
},
{
"title": "比赛人员/报名",
"icon": "group_add",
"link": reverse_lazy("admin:competition_competitionenrollment_changelist"),
},
{
"title": "参赛项目",
"icon": "lightbulb",
"link": reverse_lazy("admin:competition_project_changelist"),
},
{
"title": "评分记录",
"icon": "score",
"link": reverse_lazy("admin:competition_score_changelist"),
},
{
"title": "评委评语",
"icon": "rate_review",
"link": reverse_lazy("admin:competition_comment_changelist"),
},
],
},
{
"title": "AI 听悟",
"separator": True,
"items": [
{
"title": "转写与总结任务",
"icon": "record_voice_over",
"link": reverse_lazy("admin:ai_services_transcriptiontask_changelist"),
},
{
"title": "AI 评估模板",
"icon": "rule",
"link": reverse_lazy("admin:ai_services_aievaluationtemplate_changelist"),
},
{
"title": "AI 评估结果",
"icon": "psychology",
"link": reverse_lazy("admin:ai_services_aievaluation_changelist"),
},
],
},
{ {
"title": "系统配置", "title": "系统配置",
"separator": True, "separator": True,
@@ -306,6 +383,11 @@ UNFOLD = {
"icon": "payment", "icon": "payment",
"link": reverse_lazy("admin:shop_wechatpayconfig_changelist"), "link": reverse_lazy("admin:shop_wechatpayconfig_changelist"),
}, },
{
"title": "管理员通知手机号",
"icon": "contact_phone",
"link": reverse_lazy("admin:shop_adminphonenumber_changelist"),
},
{ {
"title": "用户认证", "title": "用户认证",
"icon": "security", "icon": "security",
@@ -334,3 +416,13 @@ LOGGING = {
'level': 'INFO', 'level': 'INFO',
}, },
} }
# 阿里云配置
ALIYUN_ACCESS_KEY_ID = os.environ.get('ALIYUN_ACCESS_KEY_ID', '')
ALIYUN_ACCESS_KEY_SECRET = os.environ.get('ALIYUN_ACCESS_KEY_SECRET', '')
ALIYUN_OSS_BUCKET_NAME = os.environ.get('ALIYUN_OSS_BUCKET_NAME', '')
ALIYUN_OSS_ENDPOINT = os.environ.get('ALIYUN_OSS_ENDPOINT', 'oss-cn-shanghai.aliyuncs.com')
ALIYUN_OSS_INTERNAL_ENDPOINT = os.environ.get('ALIYUN_OSS_INTERNAL_ENDPOINT', '')
ALIYUN_TINGWU_APP_KEY = os.environ.get('ALIYUN_TINGWU_APP_KEY', '') # 听悟AppKey
DASHSCOPE_API_KEY = os.environ.get('DASHSCOPE_API_KEY', '')

View File

@@ -3,11 +3,19 @@ from django.urls import path, include
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
from competition import judge_views
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
# Judge System Routes
path('judge/', include('competition.judge_urls')),
path('competition/admin/', judge_views.admin_entry, name='judge_admin_entry_root'),
path('api/', include('shop.urls')), path('api/', include('shop.urls')),
path('api/community/', include('community.urls')), path('api/community/', include('community.urls')),
path('api/competition/', include('competition.urls')),
path('api/ai/', include('ai_services.urls')),
# Swagger文档路由 # Swagger文档路由
path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
@@ -18,3 +26,4 @@ urlpatterns = [
# 静态文件配置(开发环境) # 静态文件配置(开发环境)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -7,7 +7,7 @@ django.setup()
from shop.models import ESP32Config from shop.models import ESP32Config
def populate(): def populate():
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置 # 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置
if ESP32Config.objects.exists(): if ESP32Config.objects.exists():
print("ESP32Config data already exists, skipping population.") print("ESP32Config data already exists, skipping population.")
return return

View File

@@ -21,4 +21,11 @@ drf-spectacular-sidecar==2026.1.1
gunicorn==21.2.0 gunicorn==21.2.0
requests requests
django-filter django-filter
django-admin-sortable2
openpyxl
aliyun-python-sdk-core==2.16.0
aliyun-python-sdk-tingwu==1.0.7
oss2==2.19.1
python-dotenv
openai

View File

@@ -2,18 +2,100 @@ from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.db.models import Sum from django.db.models import Sum
from django import forms from django import forms
from django.urls import path, reverse
from django.shortcuts import redirect
from unfold.admin import ModelAdmin, TabularInline from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import display from unfold.decorators import display
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment, AdminPhoneNumber
from .admin_actions import export_to_csv, export_to_excel
import qrcode import qrcode
from io import BytesIO from io import BytesIO
import base64 import base64
# 自定义后台标题 # 自定义后台标题1
admin.site.site_header = "量迹AI科技硬件/服务商场后台" admin.site.site_header = "量迹AI科技硬件/服务商场后台"
admin.site.site_title = "量迹AI后台" admin.site.site_title = "量迹AI后台"
admin.site.index_title = "欢迎使用量迹AI管理系统" admin.site.index_title = "欢迎使用量迹AI管理系统"
class OrderableAdminMixin:
"""
为 Admin 添加排序功能的 Mixin
提供上移、下移按钮,直接交换 order 值
"""
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('<path:object_id>/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'),
path('<path:object_id>/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'),
]
return custom_urls + urls
def move_up_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
# 找到排在它前面的一个 (order 小于它的最大值)
prev_obj = self.model.objects.filter(order__lt=obj.order).order_by('-order').first()
if prev_obj:
# 交换
obj.order, prev_obj.order = prev_obj.order, obj.order
obj.save()
prev_obj.save()
self.message_user(request, f"成功将 {obj} 上移")
else:
# 已经是第一个,或者前面没有更小的 order
# 尝试查找 order 等于它的其他对象(理论上不应发生,但为了稳健)
pass
return redirect(request.META.get('HTTP_REFERER', '..'))
def move_down_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
# 找到排在它后面的一个 (order 大于它的最小值)
next_obj = self.model.objects.filter(order__gt=obj.order).order_by('order').first()
if next_obj:
# 交换
obj.order, next_obj.order = next_obj.order, obj.order
obj.save()
next_obj.save()
self.message_user(request, f"成功将 {obj} 下移")
return redirect(request.META.get('HTTP_REFERER', '..'))
def order_actions(self, obj):
# 只有专家用户才显示排序按钮
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型WeChatUser有is_star字段
return "默认排序"
# 使用 inline style 实现基本样式hover 效果如果不能用 CSS 文件,就只能妥协或者用 onmouseover
btn_style = (
"display: inline-flex; align-items: center; justify-content: center; "
"width: 26px; height: 26px; border-radius: 6px; "
"background-color: #f3f4f6; color: #4b5563; text-decoration: none; "
"border: 1px solid #e5e7eb; transition: all 0.2s;"
)
# onmouseover js
hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';"
out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';"
return format_html(
'<div style="display: flex; align-items: center; gap: 6px;">'
'<a href="{}" title="上移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>'
'</a>'
'<span style="font-weight: 700; font-family: system-ui, -apple-system, sans-serif; min-width: 20px; text-align: center; color: #374151; font-size: 13px;">{}</span>'
'<a href="{}" title="下移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
'</a>'
'</div>',
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]),
btn_style, hover_js, out_js,
obj.order,
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]),
btn_style, hover_js, out_js,
)
order_actions.short_description = "排序调节"
order_actions.allow_tags = True
class ExternalUploadWidget(forms.URLInput): class ExternalUploadWidget(forms.URLInput):
def __init__(self, upload_url, accept='*', *args, **kwargs): def __init__(self, upload_url, accept='*', *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -83,9 +165,9 @@ class WeChatPayConfigAdmin(ModelAdmin):
) )
@admin.register(ESP32Config) @admin.register(ESP32Config)
class ESP32ConfigAdmin(ModelAdmin): class ESP32ConfigAdmin(OrderableAdminMixin, ModelAdmin):
form = ESP32ConfigAdminForm form = ESP32ConfigAdminForm
list_display = ('name', 'chip_type', 'price', 'stock', 'has_camera', 'has_microphone') list_display = ('name', 'chip_type', 'price', 'stock', 'has_camera', 'has_microphone', 'order_actions')
list_filter = ('chip_type', 'has_camera') list_filter = ('chip_type', 'has_camera')
search_fields = ('name', 'description') search_fields = ('name', 'description')
inlines = [ProductFeatureInline] inlines = [ProductFeatureInline]
@@ -107,8 +189,8 @@ class ESP32ConfigAdmin(ModelAdmin):
) )
@admin.register(Service) @admin.register(Service)
class ServiceAdmin(ModelAdmin): class ServiceAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('title', 'created_at') list_display = ('title', 'created_at', 'order_actions')
search_fields = ('title', 'description') search_fields = ('title', 'description')
fieldsets = ( fieldsets = (
('基本信息', { ('基本信息', {
@@ -150,14 +232,45 @@ class ServiceOrderAdmin(ModelAdmin):
) )
@admin.register(VCCourse) @admin.register(VCCourse)
class VCCourseAdmin(ModelAdmin): class VCCourseAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('title', 'course_type', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at') list_display = ('title', 'course_type', 'is_video_course', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order_actions')
search_fields = ('title', 'description', 'instructor', 'tag') search_fields = ('title', 'description', 'instructor', 'tag')
list_filter = ('course_type', 'instructor', 'tag') list_filter = ('course_type', 'is_video_course', 'instructor', 'tag')
actions = ['reset_ordering']
@admin.action(description="重置排序 (按ID顺序)")
def reset_ordering(self, request, queryset):
"""
将选中的课程或全部按ID顺序重新分配order值
"""
# 如果没有选中任何项默认处理所有Django Admin默认行为是选中了才会触发Action但为了稳健
# 这里既然是Action用户必须选中。建议用户选中所有。
# 为了方便如果用户只选了一个我们可以提示他选更多或者我们其实可以忽略queryset直接重置所有
# 通常Action是针对queryset的。
# 更好的做法对选中的queryset按ID排序然后更新order。
# 这种实现方式只重置选中的部分可能会导致order冲突。
# 稳妥方式:重置整个表的排序。
all_objects = VCCourse.objects.all().order_by('id')
for index, obj in enumerate(all_objects, start=1):
obj.order = index
obj.save(update_fields=['order'])
self.message_user(request, f"成功重置了 {all_objects.count()} 个课程的排序权重。")
fieldsets = ( fieldsets = (
('基本信息', { ('基本信息', {
'fields': ('title', 'description', 'course_type', 'tag', 'price') 'fields': ('title', 'description', 'course_type', 'tag', 'price')
}), }),
('视频设置', {
'fields': ('is_video_course', 'video_url', 'video_embed_code'),
'description': '设置是否为视频课程及视频链接'
}),
('课程安排', {
'fields': ('is_fixed_schedule', 'start_time', 'end_time'),
'description': '勾选“是否固定时间课程”后,请设置开始和结束时间'
}),
('讲师信息', { ('讲师信息', {
'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'), 'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'),
'description': '讲师头像上传和URL二选一优先使用URL' 'description': '讲师头像上传和URL二选一优先使用URL'
@@ -272,7 +385,7 @@ class SalespersonAdmin(ModelAdmin):
class CommissionLogAdmin(ModelAdmin): class CommissionLogAdmin(ModelAdmin):
list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at') list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at')
list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at') list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at')
search_fields = ('salesperson__name', 'distributor__user__nickname', 'order__id') search_fields = ('salesperson__name', 'distributor__user__nickname', 'distributor__user__phone_number', 'order__id')
readonly_fields = ('amount', 'level', 'created_at') readonly_fields = ('amount', 'level', 'created_at')
fieldsets = ( fieldsets = (
@@ -284,24 +397,108 @@ class CommissionLogAdmin(ModelAdmin):
}), }),
) )
class GenderFilter(admin.SimpleListFilter):
title = '性别'
parameter_name = 'gender'
def lookups(self, request, model_admin):
return (
(1, ''),
(2, ''),
(0, '未知'),
)
def queryset(self, request, queryset):
if self.value():
return queryset.filter(gender=self.value())
return queryset
class UserSourceFilter(admin.SimpleListFilter):
title = '用户来源'
parameter_name = 'user_source'
def lookups(self, request, model_admin):
return (
('miniprogram', '仅小程序用户'),
('both', '网页小程序都已注册'),
)
def queryset(self, request, queryset):
if self.value() == 'miniprogram':
return queryset.filter(user__isnull=True)
if self.value() == 'both':
return queryset.filter(user__isnull=False)
return queryset
class PriceRangeFilter(admin.SimpleListFilter):
title = '价格区间'
parameter_name = 'price_range'
def lookups(self, request, model_admin):
return (
('0-50', '¥0 - ¥50'),
('50-100', '¥50 - ¥100'),
('100-500', '¥100 - ¥500'),
('500-1000', '¥500 - ¥1000'),
('1000+', '¥1000以上'),
)
def queryset(self, request, queryset):
value = self.value()
if value == '0-50':
return queryset.filter(total_price__gte=0, total_price__lte=50)
elif value == '50-100':
return queryset.filter(total_price__gt=50, total_price__lte=100)
elif value == '100-500':
return queryset.filter(total_price__gt=100, total_price__lte=500)
elif value == '500-1000':
return queryset.filter(total_price__gt=500, total_price__lte=1000)
elif value == '1000+':
return queryset.filter(total_price__gt=1000)
return queryset
class ProductTypeFilter(admin.SimpleListFilter):
title = '商品类型'
parameter_name = 'product_type'
def lookups(self, request, model_admin):
return (
('hardware', '硬件产品'),
('course', '课程'),
('activity', '活动'),
)
def queryset(self, request, queryset):
value = self.value()
if value == 'hardware':
return queryset.filter(config__isnull=False)
elif value == 'course':
return queryset.filter(course__isnull=False)
elif value == 'activity':
return queryset.filter(activity__isnull=False)
return queryset
@admin.register(Order) @admin.register(Order)
class OrderAdmin(ModelAdmin): class OrderAdmin(ModelAdmin):
list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at') list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at')
list_filter = ('status', 'salesperson', 'distributor', 'created_at') list_filter = ('status', ProductTypeFilter, 'config', 'course', 'activity', PriceRangeFilter, 'salesperson', 'distributor', 'created_at')
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no') search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no', 'wechat_user__phone_number')
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no') readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
actions = [export_to_csv, export_to_excel]
def get_item_name(self, obj): def get_item_name(self, obj):
if obj.config: if obj.config:
return f"[硬件] {obj.config.name}" return f"[硬件] {obj.config.name}"
if obj.course: if obj.course:
return f"[课程] {obj.course.title}" return f"[课程] {obj.course.title}"
if obj.activity:
return f"[活动] {obj.activity.title}"
return "未知商品" return "未知商品"
get_item_name.short_description = "购买商品" get_item_name.short_description = "购买商品"
fieldsets = ( fieldsets = (
('订单信息', { ('订单信息', {
'fields': ('config', 'course', 'quantity', 'total_price', 'status', 'created_at') 'fields': ('config', 'course', 'activity', 'quantity', 'total_price', 'status', 'created_at')
}), }),
('客户信息', { ('客户信息', {
'fields': ('customer_name', 'phone_number', 'shipping_address', 'wechat_user') 'fields': ('customer_name', 'phone_number', 'shipping_address', 'wechat_user')
@@ -318,11 +515,12 @@ class OrderAdmin(ModelAdmin):
) )
@admin.register(WeChatUser) @admin.register(WeChatUser)
class WeChatUserAdmin(ModelAdmin): class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at') list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at', 'order_actions')
search_fields = ('nickname', 'openid', 'phone_number') search_fields = ('nickname', 'openid', 'phone_number')
list_filter = ('is_star', 'gender', 'province', 'city', 'created_at') list_filter = ('is_star', GenderFilter, UserSourceFilter, 'province', 'city', 'created_at')
readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at') readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at')
actions = [export_to_csv, export_to_excel]
def avatar_display(self, obj): def avatar_display(self, obj):
if obj.avatar_url: if obj.avatar_url:
@@ -335,30 +533,43 @@ class WeChatUserAdmin(ModelAdmin):
return choices.get(obj.gender, '未知') return choices.get(obj.gender, '未知')
gender_display.short_description = "性别" gender_display.short_description = "性别"
fieldsets = ( def get_fieldsets(self, request, obj=None):
fieldsets = [
('基本信息', { ('基本信息', {
'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender') 'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender')
}), }),
('专家认证', { ]
'fields': ('is_star', 'title'),
if obj and obj.is_star:
fieldsets.append(('专家认证', {
'fields': ('is_star', 'title', 'skills', 'order'),
'description': '标记为明星技术用户/专家,将在社区中展示' 'description': '标记为明星技术用户/专家,将在社区中展示'
}), }))
('位置信息', { else:
fieldsets.append(('专家认证', {
'fields': ('is_star',),
'description': '标记为明星技术用户/专家,将在社区中展示。保存后若为专家用户,可进一步编辑专家信息。'
}))
fieldsets.append(('位置信息', {
'fields': ('country', 'province', 'city') 'fields': ('country', 'province', 'city')
}), }))
('认证信息', {
fieldsets.append(('认证信息', {
'fields': ('openid', 'unionid', 'session_key'), 'fields': ('openid', 'unionid', 'session_key'),
'classes': ('collapse',) 'classes': ('collapse',)
}), }))
('时间信息', {
fieldsets.append(('时间信息', {
'fields': ('created_at', 'updated_at') 'fields': ('created_at', 'updated_at')
}), }))
)
return fieldsets
@admin.register(Distributor) @admin.register(Distributor)
class DistributorAdmin(ModelAdmin): class DistributorAdmin(ModelAdmin):
list_display = ('get_nickname', 'level', 'status', 'total_earnings', 'withdrawable_balance', 'invite_code', 'created_at') list_display = ('get_nickname', 'level', 'status', 'total_earnings', 'withdrawable_balance', 'invite_code', 'created_at')
search_fields = ('user__nickname', 'invite_code') search_fields = ('user__nickname', 'user__phone_number', 'invite_code')
list_filter = ('status', 'level', 'created_at') list_filter = ('status', 'level', 'created_at')
readonly_fields = ('total_earnings', 'withdrawable_balance', 'qr_code_url', 'created_at', 'updated_at') readonly_fields = ('total_earnings', 'withdrawable_balance', 'qr_code_url', 'created_at', 'updated_at')
autocomplete_fields = ['user', 'parent'] autocomplete_fields = ['user', 'parent']
@@ -387,7 +598,7 @@ class DistributorAdmin(ModelAdmin):
class WithdrawalAdmin(ModelAdmin): class WithdrawalAdmin(ModelAdmin):
list_display = ('get_distributor', 'amount', 'status', 'created_at') list_display = ('get_distributor', 'amount', 'status', 'created_at')
list_filter = ('status', 'created_at') list_filter = ('status', 'created_at')
search_fields = ('distributor__user__nickname',) search_fields = ('distributor__user__nickname', 'distributor__user__phone_number')
def get_distributor(self, obj): def get_distributor(self, obj):
return obj.distributor.user.nickname return obj.distributor.user.nickname
@@ -401,3 +612,9 @@ class WithdrawalAdmin(ModelAdmin):
'fields': ('created_at', 'updated_at') 'fields': ('created_at', 'updated_at')
}), }),
) )
@admin.register(AdminPhoneNumber)
class AdminPhoneNumberAdmin(ModelAdmin):
list_display = ('name', 'phone_number', 'is_active', 'created_at')
list_filter = ('is_active',)
search_fields = ('name', 'phone_number')

View File

@@ -0,0 +1,110 @@
import csv
import datetime
from django.http import HttpResponse
from django.utils.encoding import escape_uri_path
def export_to_csv(modeladmin, request, queryset):
"""
通用导出 CSV 的 Admin Action
支持中文编码UTF-8 BOM可直接用 Excel 打开
"""
opts = modeladmin.model._meta
# 设置文件名,使用模型的 verbose_name
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
response = HttpResponse(content_type='text/csv; charset=utf-8-sig')
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
writer = csv.writer(response)
# 获取所有非多对多字段和非反向关联字段
fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
# 写入表头 (使用字段的 verbose_name)
writer.writerow([field.verbose_name for field in fields])
# 写入数据
for obj in queryset:
data_row = []
for field in fields:
value = getattr(obj, field.name)
# 处理 Choice 字段,显示可读的标签
if hasattr(obj, f'get_{field.name}_display'):
value = getattr(obj, f'get_{field.name}_display')()
# 处理关联对象ForeignKey
if field.is_relation and value:
value = str(value)
# 处理日期时间
if isinstance(value, datetime.datetime):
value = value.strftime('%Y-%m-%d %H:%M:%S')
elif isinstance(value, datetime.date):
value = value.strftime('%Y-%m-%d')
# 处理 None
if value is None:
value = ""
data_row.append(str(value))
writer.writerow(data_row)
return response
export_to_csv.short_description = "导出选中项为 CSV"
def export_to_excel(modeladmin, request, queryset):
"""
导出为 Excel (需要安装 openpyxl)
"""
try:
from openpyxl import Workbook
except ImportError:
modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能: pip install openpyxl", level='error')
return
opts = modeladmin.model._meta
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
wb = Workbook()
ws = wb.active
# Sheet name limit is 31 chars
ws.title = str(opts.verbose_name)[:31]
fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
# 写入表头
ws.append([str(field.verbose_name) for field in fields])
# 写入数据
for obj in queryset:
row = []
for field in fields:
value = getattr(obj, field.name)
if hasattr(obj, f'get_{field.name}_display'):
value = getattr(obj, f'get_{field.name}_display')()
# 处理关联对象ForeignKey
if field.is_relation and value:
value = str(value)
if isinstance(value, (datetime.datetime, datetime.date)):
# openpyxl 可以直接处理 datetime 格式Excel 会自动识别
# 但为了避免时区问题,通常转为无时区时间或字符串
if isinstance(value, datetime.datetime):
value = value.replace(tzinfo=None)
row.append(value)
ws.append(row)
wb.save(response)
return response
export_to_excel.short_description = "导出选中项为 Excel"

View File

@@ -3,3 +3,7 @@ from django.apps import AppConfig
class ShopConfig(AppConfig): class ShopConfig(AppConfig):
name = 'shop' name = 'shop'
verbose_name = "商城管理"
def ready(self):
import shop.signals

View File

@@ -0,0 +1,46 @@
# Generated by Django 6.0.1 on 2026-02-13 16:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0029_fix_legacy_fields'),
]
operations = [
migrations.AlterModelOptions(
name='esp32config',
options={'ordering': ['order'], 'verbose_name': '硬件配置 (小智参数)', 'verbose_name_plural': '硬件配置 (小智参数)'},
),
migrations.AlterModelOptions(
name='service',
options={'ordering': ['order'], 'verbose_name': 'AI服务', 'verbose_name_plural': 'AI服务管理'},
),
migrations.AlterModelOptions(
name='vccourse',
options={'ordering': ['order'], 'verbose_name': 'VC课程', 'verbose_name_plural': 'VC课程管理'},
),
migrations.AddField(
model_name='esp32config',
name='order',
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
),
migrations.AddField(
model_name='service',
name='order',
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
),
migrations.AddField(
model_name='vccourse',
name='order',
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
),
migrations.AlterField(
model_name='order',
name='config',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='shop.esp32config', verbose_name='所选配置'),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0.1 on 2026-02-16 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0030_alter_esp32config_options_alter_service_options_and_more'),
]
operations = [
migrations.CreateModel(
name='AdminPhoneNumber',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name='管理员姓名')),
('phone_number', models.CharField(max_length=20, verbose_name='手机号')),
('is_active', models.BooleanField(default=True, verbose_name='是否接收通知')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '管理员通知手机号',
'verbose_name_plural': '管理员通知手机号',
},
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-02-23 07:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0001_initial'),
('shop', '0031_adminphonenumber'),
]
operations = [
migrations.AddField(
model_name='order',
name='activity',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='community.activity', verbose_name='所选活动'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-02-23 15:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0032_order_activity'),
]
operations = [
migrations.AddField(
model_name='vccourse',
name='is_fixed_schedule',
field=models.BooleanField(default=False, help_text='勾选后,前端将显示具体的开课时间', verbose_name='是否固定时间课程'),
),
migrations.AddField(
model_name='vccourse',
name='schedule_time',
field=models.CharField(blank=True, help_text='例如:每周六晚 20:00', max_length=100, null=True, verbose_name='课程具体时间'),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0.1 on 2026-02-23 16:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0033_vccourse_is_fixed_schedule_vccourse_schedule_time'),
]
operations = [
migrations.RemoveField(
model_name='vccourse',
name='schedule_time',
),
migrations.AddField(
model_name='vccourse',
name='end_time',
field=models.DateTimeField(blank=True, null=True, verbose_name='结束时间'),
),
migrations.AddField(
model_name='vccourse',
name='start_time',
field=models.DateTimeField(blank=True, null=True, verbose_name='开始时间'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-02-24 09:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0034_remove_vccourse_schedule_time_vccourse_end_time_and_more'),
]
operations = [
migrations.AddField(
model_name='wechatuser',
name='skills',
field=models.JSONField(blank=True, default=list, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]", verbose_name='专家技能'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 6.0.1 on 2026-02-24 09:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0035_wechatuser_skills'),
]
operations = [
migrations.AlterModelOptions(
name='wechatuser',
options={'ordering': ['order', '-created_at'], 'verbose_name': '微信用户', 'verbose_name_plural': '微信用户管理'},
),
migrations.AddField(
model_name='wechatuser',
name='order',
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-02-26 10:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0036_alter_wechatuser_options_wechatuser_order'),
]
operations = [
migrations.AddField(
model_name='wechatuser',
name='has_web_badge',
field=models.BooleanField(default=False, verbose_name='是否拥有Web徽章'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-02-27 05:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0037_wechatuser_has_web_badge'),
]
operations = [
migrations.AddField(
model_name='vccourse',
name='is_video_course',
field=models.BooleanField(default=False, verbose_name='是否视频课程'),
),
migrations.AddField(
model_name='vccourse',
name='video_url',
field=models.URLField(blank=True, help_text='仅当用户付费或报名后可见', null=True, verbose_name='视频课程URL'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-01 09:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0038_vccourse_is_video_course_vccourse_video_url'),
]
operations = [
migrations.AddField(
model_name='vccourse',
name='video_embed_code',
field=models.TextField(blank=True, help_text='支持iframe嵌入代码优先级高于视频URL', null=True, verbose_name='视频嵌入代码'),
),
]

View File

@@ -24,16 +24,29 @@ class WeChatUser(models.Model):
# 明星技术用户/专家标识 # 明星技术用户/专家标识
is_star = models.BooleanField(default=False, verbose_name="是否明星技术用户") is_star = models.BooleanField(default=False, verbose_name="是否明星技术用户")
title = models.CharField(max_length=50, default="技术专家", verbose_name="专家头衔", blank=True) title = models.CharField(max_length=50, default="技术专家", verbose_name="专家头衔", blank=True)
skills = models.JSONField(default=list, verbose_name="专家技能", blank=True, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]")
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
# 徽章标识
has_web_badge = models.BooleanField(default=False, verbose_name="是否拥有Web徽章")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and self.order == 0:
WeChatUser.objects.filter(pk=self.pk).update(order=self.pk)
self.order = self.pk
def __str__(self): def __str__(self):
return self.nickname or self.openid return self.phone_number or self.nickname or self.openid
class Meta: class Meta:
verbose_name = "微信用户" verbose_name = "微信用户"
verbose_name_plural = "微信用户管理" verbose_name_plural = "微信用户管理"
ordering = ['order', '-created_at']
class Distributor(models.Model): class Distributor(models.Model):
@@ -109,6 +122,7 @@ class ESP32Config(models.Model):
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL将优先使用URL") detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL将优先使用URL")
static_image_url = models.URLField(blank=True, null=True, verbose_name="产品静态图 (URL)") static_image_url = models.URLField(blank=True, null=True, verbose_name="产品静态图 (URL)")
model_3d_url = models.URLField(blank=True, null=True, verbose_name="产品3D模型 (URL)") model_3d_url = models.URLField(blank=True, null=True, verbose_name="产品3D模型 (URL)")
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
def __str__(self): def __str__(self):
return f"{self.name} - ¥{self.price}" return f"{self.name} - ¥{self.price}"
@@ -116,6 +130,7 @@ class ESP32Config(models.Model):
class Meta: class Meta:
verbose_name = "硬件配置 (小智参数)" verbose_name = "硬件配置 (小智参数)"
verbose_name_plural = "硬件配置 (小智参数)" verbose_name_plural = "硬件配置 (小智参数)"
ordering = ['order']
class ProductFeature(models.Model): class ProductFeature(models.Model):
@@ -226,8 +241,9 @@ class Order(models.Model):
('cancelled', '已取消'), ('cancelled', '已取消'),
) )
config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置", null=True, blank=True) config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置", null=True, blank=True, related_name='orders')
course = models.ForeignKey('VCCourse', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选课程", related_name='orders') course = models.ForeignKey('VCCourse', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选课程", related_name='orders')
activity = models.ForeignKey('community.Activity', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选活动", related_name='orders')
quantity = models.IntegerField(default=1, verbose_name="数量") quantity = models.IntegerField(default=1, verbose_name="数量")
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价") total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")
@@ -279,6 +295,7 @@ class Service(models.Model):
detail_image = models.ImageField(upload_to='services/details/', blank=True, null=True, verbose_name="详情页长图 (上传)") detail_image = models.ImageField(upload_to='services/details/', blank=True, null=True, verbose_name="详情页长图 (上传)")
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)") detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
def __str__(self): def __str__(self):
return self.title return self.title
@@ -286,6 +303,7 @@ class Service(models.Model):
class Meta: class Meta:
verbose_name = "AI服务" verbose_name = "AI服务"
verbose_name_plural = "AI服务管理" verbose_name_plural = "AI服务管理"
ordering = ['order']
class ServiceOrder(models.Model): class ServiceOrder(models.Model):
@@ -344,6 +362,16 @@ class VCCourse(models.Model):
tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶") tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶")
# 视频课程相关
is_video_course = models.BooleanField(default=False, verbose_name="是否视频课程")
video_url = models.URLField(blank=True, null=True, verbose_name="视频课程URL", help_text="仅当用户付费或报名后可见")
video_embed_code = models.TextField(blank=True, null=True, verbose_name="视频嵌入代码", help_text="支持iframe嵌入代码优先级高于视频URL")
# 课程时间安排
is_fixed_schedule = models.BooleanField(default=False, verbose_name="是否固定时间课程", help_text="勾选后,前端将显示具体的开课时间")
start_time = models.DateTimeField(blank=True, null=True, verbose_name="开始时间")
end_time = models.DateTimeField(blank=True, null=True, verbose_name="结束时间")
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="价格", help_text="0表示免费") price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="价格", help_text="0表示免费")
content = models.TextField(blank=True, verbose_name="详细内容", help_text="支持Markdown或HTML") content = models.TextField(blank=True, verbose_name="详细内容", help_text="支持Markdown或HTML")
@@ -354,6 +382,14 @@ class VCCourse(models.Model):
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL将优先使用URL") detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL将优先使用URL")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and self.order == 0:
VCCourse.objects.filter(pk=self.pk).update(order=self.pk)
self.order = self.pk
def __str__(self): def __str__(self):
return self.title return self.title
@@ -361,6 +397,7 @@ class VCCourse(models.Model):
class Meta: class Meta:
verbose_name = "VC课程" verbose_name = "VC课程"
verbose_name_plural = "VC课程管理" verbose_name_plural = "VC课程管理"
ordering = ['order']
class CourseEnrollment(models.Model): class CourseEnrollment(models.Model):
@@ -396,3 +433,21 @@ class CourseEnrollment(models.Model):
class Meta: class Meta:
verbose_name = "课程报名" verbose_name = "课程报名"
verbose_name_plural = "课程报名管理" verbose_name_plural = "课程报名管理"
class AdminPhoneNumber(models.Model):
"""
管理员通知手机号配置
用于接收订单支付成功等重要通知
"""
name = models.CharField(max_length=50, verbose_name="管理员姓名")
phone_number = models.CharField(max_length=20, verbose_name="手机号")
is_active = models.BooleanField(default=True, verbose_name="是否接收通知")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
def __str__(self):
return f"{self.name} - {self.phone_number}"
class Meta:
verbose_name = "管理员通知手机号"
verbose_name_plural = "管理员通知手机号"

View File

@@ -1,5 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from .models import ESP32Config, Order, Salesperson, Service, VCCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal, CommissionLog, CourseEnrollment from .models import ESP32Config, Order, Salesperson, Service, VCCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal, CommissionLog, CourseEnrollment
from .utils import get_current_wechat_user
class CommissionLogSerializer(serializers.ModelSerializer): class CommissionLogSerializer(serializers.ModelSerializer):
""" """
@@ -20,10 +21,21 @@ class CommissionLogSerializer(serializers.ModelSerializer):
} }
class WeChatUserSerializer(serializers.ModelSerializer): class WeChatUserSerializer(serializers.ModelSerializer):
is_admin = serializers.SerializerMethodField()
has_web_account = serializers.SerializerMethodField()
class Meta: class Meta:
model = WeChatUser model = WeChatUser
fields = ['id', 'openid', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number', 'is_star', 'title'] fields = ['id', 'openid', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number', 'is_star', 'title', 'skills', 'is_admin', 'has_web_account', 'has_web_badge']
read_only_fields = ['id', 'openid', 'phone_number', 'is_star', 'title'] read_only_fields = ['id', 'openid', 'phone_number', 'is_star', 'title', 'skills', 'is_admin', 'has_web_account', 'has_web_badge']
def get_is_admin(self, obj):
# 检查是否关联了系统用户且具有管理员权限
return bool(obj.user and obj.user.is_staff)
def get_has_web_account(self, obj):
# 检查是否关联了系统用户(即网页账号)
return obj.user is not None
class DistributorSerializer(serializers.ModelSerializer): class DistributorSerializer(serializers.ModelSerializer):
user_info = WeChatUserSerializer(source='user', read_only=True) user_info = WeChatUserSerializer(source='user', read_only=True)
@@ -148,6 +160,12 @@ class ServiceOrderSerializer(serializers.ModelSerializer):
except Salesperson.DoesNotExist: except Salesperson.DoesNotExist:
pass pass
try:
distributor = Distributor.objects.get(invite_code=ref_code)
validated_data['distributor'] = distributor
except Distributor.DoesNotExist:
pass
return super().create(validated_data) return super().create(validated_data)
class VCCourseSerializer(serializers.ModelSerializer): class VCCourseSerializer(serializers.ModelSerializer):
@@ -157,6 +175,9 @@ class VCCourseSerializer(serializers.ModelSerializer):
display_cover_image = serializers.SerializerMethodField() display_cover_image = serializers.SerializerMethodField()
display_detail_image = serializers.SerializerMethodField() display_detail_image = serializers.SerializerMethodField()
course_type_display = serializers.CharField(source='get_course_type_display', read_only=True) course_type_display = serializers.CharField(source='get_course_type_display', read_only=True)
video_url = serializers.SerializerMethodField()
video_embed_code = serializers.SerializerMethodField()
is_purchased = serializers.SerializerMethodField()
class Meta: class Meta:
model = VCCourse model = VCCourse
@@ -176,6 +197,56 @@ class VCCourseSerializer(serializers.ModelSerializer):
return obj.detail_image.url return obj.detail_image.url
return None return None
def _check_purchased(self, obj):
request = self.context.get('request')
if not request:
return False
# 尝试获取当前用户
user = get_current_wechat_user(request)
if not user:
return False
# 如果是管理员,视为已购买
if user.user and user.user.is_staff:
return True
# 检查是否已购买/报名 (通过已支付的订单)
has_order = Order.objects.filter(
wechat_user=user,
course=obj,
status__in=['paid', 'shipped', 'completed']
).exists()
return has_order
def get_is_purchased(self, obj):
return self._check_purchased(obj)
def get_video_url(self, obj):
"""
仅当用户已付费/报名时返回视频URL
"""
if not obj.is_video_course:
return None
if self._check_purchased(obj):
return obj.video_url
return None
def get_video_embed_code(self, obj):
"""
仅当用户已付费/报名时返回视频嵌入代码
"""
if not obj.is_video_course:
return None
if self._check_purchased(obj):
return obj.video_embed_code
return None
class ESP32ConfigSerializer(serializers.ModelSerializer): class ESP32ConfigSerializer(serializers.ModelSerializer):
""" """
ESP32配置序列化器 ESP32配置序列化器
@@ -201,6 +272,7 @@ class OrderSerializer(serializers.ModelSerializer):
""" """
config_name = serializers.CharField(source='config.name', read_only=True) config_name = serializers.CharField(source='config.name', read_only=True)
course_title = serializers.CharField(source='course.title', read_only=True) course_title = serializers.CharField(source='course.title', read_only=True)
activity_title = serializers.CharField(source='activity.title', read_only=True)
config_image = serializers.SerializerMethodField() config_image = serializers.SerializerMethodField()
salesperson_name = serializers.CharField(source='salesperson.name', read_only=True) salesperson_name = serializers.CharField(source='salesperson.name', read_only=True)
salesperson_code = serializers.CharField(source='salesperson.code', read_only=True) salesperson_code = serializers.CharField(source='salesperson.code', read_only=True)
@@ -209,7 +281,7 @@ class OrderSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Order model = Order
fields = ['id', 'config', 'config_name', 'config_image', 'course', 'course_title', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no', fields = ['id', 'config', 'config_name', 'config_image', 'course', 'course_title', 'activity', 'activity_title', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no',
'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number'] 'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number']
read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at'] read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at']
extra_kwargs = { extra_kwargs = {
@@ -224,11 +296,14 @@ class OrderSerializer(serializers.ModelSerializer):
config = data.get('config') config = data.get('config')
course = data.get('course') course = data.get('course')
activity = data.get('activity')
if not config and not course: if not config and not course and not activity:
raise serializers.ValidationError("必须选择一种商品(硬件配置课程)") raise serializers.ValidationError("必须选择一种商品(硬件配置课程或活动")
if config and course: # Count how many types are selected
selected_types = sum([bool(config), bool(course), bool(activity)])
if selected_types > 1:
raise serializers.ValidationError("一次只能购买一种类型的商品") raise serializers.ValidationError("一次只能购买一种类型的商品")
if config and not data.get('shipping_address'): if config and not data.get('shipping_address'):
@@ -249,6 +324,12 @@ class OrderSerializer(serializers.ModelSerializer):
return obj.course.cover_image_url return obj.course.cover_image_url
if obj.course.cover_image: if obj.course.cover_image:
return obj.course.cover_image.url return obj.course.cover_image.url
elif obj.activity:
# Use activity.display_banner_url logic
if obj.activity.banner:
return obj.activity.banner.url
if obj.activity.banner_url:
return obj.activity.banner_url
return None return None
def create(self, validated_data): def create(self, validated_data):
@@ -257,6 +338,7 @@ class OrderSerializer(serializers.ModelSerializer):
""" """
config = validated_data.get('config') config = validated_data.get('config')
course = validated_data.get('course') course = validated_data.get('course')
activity = validated_data.get('activity')
quantity = validated_data.get('quantity', 1) quantity = validated_data.get('quantity', 1)
ref_code = validated_data.pop('ref_code', None) ref_code = validated_data.pop('ref_code', None)
@@ -264,6 +346,8 @@ class OrderSerializer(serializers.ModelSerializer):
validated_data['total_price'] = config.price * quantity validated_data['total_price'] = config.price * quantity
elif course: elif course:
validated_data['total_price'] = course.price * quantity validated_data['total_price'] = course.price * quantity
elif activity:
validated_data['total_price'] = activity.price * quantity
# 尝试关联销售员或分销员 # 尝试关联销售员或分销员
if ref_code: if ref_code:

177
backend/shop/services.py Normal file
View File

@@ -0,0 +1,177 @@
import logging
from django.db import models
from .models import Order, CommissionLog, Distributor
# To avoid circular imports, import other models inside function if needed
logger = logging.getLogger(__name__)
def handle_post_payment(order):
"""
处理订单支付成功后的业务逻辑
包括:
1. 更新活动报名状态
2. 发送活动报名短信
3. 计算分销佣金
4. 发送普通订单短信
"""
print(f"开始处理订单 {order.id} 支付后逻辑...")
# 1. Handle Activity Signup
if hasattr(order, 'activity') and order.activity:
try:
# Use apps.get_model to avoid circular dependency
from django.apps import apps
ActivitySignup = apps.get_model('community', 'ActivitySignup')
signup = ActivitySignup.objects.filter(order=order).first()
# Fallback: try to find by user and activity if not found by order
if not signup and order.wechat_user:
print(f"Warning: ActivitySignup not found by order {order.id}, trying by user/activity")
signup = ActivitySignup.objects.filter(
user=order.wechat_user,
activity=order.activity,
status='unpaid'
).first()
if signup:
print(f"Found signup {signup.id} by user/activity, linking order...")
signup.order = order
signup.save()
if signup:
# Determine status based on activity setting
# Use the model method if available, otherwise manual logic
if hasattr(signup, 'check_payment_status'):
signup.check_payment_status()
print(f"活动报名状态已更新(check_payment_status): {signup.id} -> {signup.status}")
else:
new_status = 'confirmed' if signup.activity.auto_confirm else 'pending'
signup.status = new_status
signup.save()
print(f"活动报名状态已更新: {signup.id} -> {new_status}")
# Send Activity SMS
try:
from .sms_utils import notify_user_activity_signup_success
notify_user_activity_signup_success(order, signup)
except Exception as sms_e:
print(f"发送活动报名短信失败: {str(sms_e)}")
else:
print(f"Error: No ActivitySignup found for paid order {order.id}")
except Exception as e:
print(f"更新活动报名状态失败: {str(e)}")
import traceback
traceback.print_exc()
# 2. 计算佣金 (旧版销售员系统 & 新版分销员系统)
try:
# 旧版销售员系统
salesperson = order.salesperson
if salesperson:
# 1. 计算直接佣金 (一级)
# 优先级: 产品独立分润比例 > 销售员个人分润比例
rate_1 = 0
if order.config:
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else salesperson.commission_rate
elif order.course:
# 课程暂时使用销售员默认比例
rate_1 = salesperson.commission_rate
amount_1 = order.total_price * rate_1
if amount_1 > 0:
CommissionLog.objects.create(
order=order,
salesperson=salesperson,
amount=amount_1,
level=1,
status='pending'
)
print(f"生成一级佣金(Salesperson): {salesperson.name} - {amount_1}")
# 2. 计算上级佣金 (二级)
parent = salesperson.parent
if parent:
rate_2 = parent.second_level_rate
amount_2 = order.total_price * rate_2
if amount_2 > 0:
CommissionLog.objects.create(
order=order,
salesperson=parent,
amount=amount_2,
level=2,
status='pending'
)
print(f"生成二级佣金(Salesperson): {parent.name} - {amount_2}")
# 新版分销员系统
distributor = order.distributor
if distributor:
# 1. 计算直接佣金 (一级)
# 优先级: 产品独立分润比例 > 分销员个人分润比例
rate_1 = 0
if order.config:
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else distributor.commission_rate
elif order.course:
# 课程暂时使用分销员默认比例
rate_1 = distributor.commission_rate
amount_1 = order.total_price * rate_1
if amount_1 > 0:
CommissionLog.objects.create(
order=order,
distributor=distributor,
amount=amount_1,
level=1,
status='settled' # 简化流程,直接结算到余额
)
# 更新余额
distributor.total_earnings += amount_1
distributor.withdrawable_balance += amount_1
distributor.save()
print(f"生成一级佣金(Distributor): {distributor.user.nickname} - {amount_1}")
# 2. 计算上级佣金 (二级)
parent = distributor.parent
if parent:
# 二级固定比例 2% (0.02)
rate_2 = 0.02
amount_2 = order.total_price * models.DecimalField(max_digits=5, decimal_places=4).to_python(rate_2)
if amount_2 > 0:
CommissionLog.objects.create(
order=order,
distributor=parent,
amount=amount_2,
level=2,
status='settled'
)
# 更新余额
parent.total_earnings += amount_2
parent.withdrawable_balance += amount_2
parent.save()
print(f"生成二级佣金(Distributor): {parent.user.nickname} - {amount_2}")
except Exception as e:
print(f"佣金计算失败: {str(e)}")
import traceback
traceback.print_exc()
# 3. 发送普通商品/课程购买的短信通知(排除活动报名,避免重复发送)
# 活动报名的短信已经在上面发送过了
if not (hasattr(order, 'activity') and order.activity):
try:
from .sms_utils import notify_admins_order_paid, notify_user_order_paid
notify_admins_order_paid(order)
notify_user_order_paid(order)
except Exception as e:
print(f"发送短信通知失败: {str(e)}")
else:
# 额外保险:如果是活动订单,手动标记不触发 signals 中的支付/发货通知
# 因为 signals 可能会在 save() 时触发
order._was_paid = False
order._was_shipped = False

65
backend/shop/signals.py Normal file
View File

@@ -0,0 +1,65 @@
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from .models import Order
from .sms_utils import notify_admins_order_paid, notify_user_order_paid, notify_user_order_shipped
@receiver(pre_save, sender=Order)
def track_order_changes(sender, instance, **kwargs):
"""
在保存之前检查状态变化
"""
if instance.pk:
try:
old_instance = Order.objects.get(pk=instance.pk)
# 检查是否从非支付状态变为支付状态
if old_instance.status != 'paid' and instance.status == 'paid':
instance._was_paid = True
# 检查是否发货 (状态变为 shipped 且有单号)
# 或者已经是 shipped 状态但刚填入单号
if instance.status == 'shipped' and instance.tracking_number:
if old_instance.status != 'shipped' or not old_instance.tracking_number:
instance._was_shipped = True
except Order.DoesNotExist:
pass
@receiver(post_save, sender=Order)
def send_order_notifications(sender, instance, created, **kwargs):
"""
在保存之后发送通知
"""
if created:
return
# 1. 处理支付成功通知
if getattr(instance, '_was_paid', False):
try:
# 只有当订单不是活动订单时才发送普通支付成功短信
# 活动订单会在 views.py 中单独处理(发送报名成功短信)
if not (hasattr(instance, 'activity') and instance.activity):
print(f"订单 {instance.id} 支付成功,触发短信通知流程...")
notify_admins_order_paid(instance)
notify_user_order_paid(instance)
else:
print(f"订单 {instance.id} 是活动订单,跳过普通支付短信通知(已在 views.py 处理)")
# 清除标记防止重复发送 (虽然实例通常是新的,但保险起见)
instance._was_paid = False
except Exception as e:
print(f"发送支付成功短信失败: {str(e)}")
# 2. 处理发货通知
if getattr(instance, '_was_shipped', False):
try:
# 同样,活动订单不需要发送发货短信(通常活动无需发货)
if not (hasattr(instance, 'activity') and instance.activity):
print(f"订单 {instance.id} 已发货,触发短信通知流程...")
notify_user_order_shipped(instance)
else:
print(f"订单 {instance.id} 是活动订单,跳过发货短信通知")
instance._was_shipped = False
except Exception as e:
print(f"发送发货短信失败: {str(e)}")

144
backend/shop/sms_utils.py Normal file
View File

@@ -0,0 +1,144 @@
import requests
import threading
import json
from .models import AdminPhoneNumber
# SMS API Configuration
SMS_API_URL = "https://data.tangledup-ai.com/api/send-sms/diy"
SIGN_NAME = "叠加态科技云南"
def send_sms(phone_number, template_code, template_params):
"""
通用发送短信函数 (异步)
"""
def _send():
try:
payload = {
"phone_number": phone_number,
"template_code": template_code,
"sign_name": SIGN_NAME,
"template_params": template_params
}
headers = {
"Content-Type": "application/json",
"accept": "application/json"
}
# print(f"Sending SMS to {phone_number} with params: {template_params}")
response = requests.post(SMS_API_URL, json=payload, headers=headers, timeout=15)
print(f"SMS Response for {phone_number}: {response.status_code} - {response.text}")
except Exception as e:
print(f"发送短信异常: {str(e)}")
threading.Thread(target=_send).start()
def notify_admins_order_paid(order):
"""
通知管理员有新订单支付成功
"""
# 获取激活的管理员手机号最多3个
admins = AdminPhoneNumber.objects.filter(is_active=True)[:3]
if not admins.exists():
print("未配置管理员手机号,跳过管理员通知")
return
# 构造参数
# 模板变量: consignee, order_id, address
# order_id 格式要求: "订单编号/电话号码"
params = {
"consignee": order.customer_name or "未填写",
"order_id": f"{order.id}/{order.phone_number}",
"address": order.shipping_address or "无地址"
}
print(f"准备发送管理员通知,共 {admins.count()}")
for admin in admins:
send_sms(admin.phone_number, "SMS_501735480", params)
def notify_user_order_paid(order):
"""
通知用户下单成功 (支付成功)
"""
if not order.phone_number:
return
# 模板变量: user_nick, address
# 尝试获取用户昵称,如果没有则使用收货人姓名
user_nick = order.customer_name
if order.wechat_user and order.wechat_user.nickname:
user_nick = order.wechat_user.nickname
params = {
"user_nick": user_nick or "用户",
"address": order.shipping_address or "无地址"
}
print(f"准备发送用户支付成功通知: {order.phone_number}")
send_sms(order.phone_number, "SMS_501850529", params)
def notify_user_order_shipped(order):
"""
通知用户已发货
"""
if not order.phone_number:
return
# 模板变量: user_nick, address, delivery_company, order_id (这里指快递单号)
user_nick = order.customer_name
if order.wechat_user and order.wechat_user.nickname:
user_nick = order.wechat_user.nickname
params = {
"user_nick": user_nick or "用户",
"address": order.shipping_address or "无地址",
"delivery_company": order.courier_name or "快递",
"order_id": order.tracking_number or "暂无单号"
}
print(f"准备发送用户发货通知: {order.phone_number}")
#send_sms(order.phone_number, "SMS_501650557", params)
send_sms(order.phone_number, "SMS_501665569", params)
def notify_user_activity_signup_success(order, signup):
"""
通知用户活动报名成功 (支付成功后)
模板CODE: SMS_501990528
模板变量: user_nick, unit_name, time, address
"""
if not order.phone_number:
return
# 1. user_nick
user_nick = order.customer_name
if order.wechat_user and order.wechat_user.nickname:
user_nick = order.wechat_user.nickname
# 2. unit_name (Activity Title)
unit_name = f"{signup.activity.title}"
# 3. time
start_time = signup.activity.start_time
# Format time as YYYY-MM-DD HH:MM
time_str = start_time.strftime("%Y-%m-%d %H:%M") if start_time else "待定"
# 4. address
address = signup.activity.location or "线上活动"
# 5. Handle phone number format (remove +86 or spaces if any)
phone_number = str(order.phone_number) if order.phone_number else ""
if phone_number:
phone_number = phone_number.replace("+86", "").replace(" ", "").strip()
# Ensure phone number is valid (11 digits)
if not phone_number or len(phone_number) != 11 or not phone_number.isdigit():
print(f"无效的手机号: {phone_number}, 跳过短信发送")
return
params = {
"user_nick": user_nick or "用户",
"unit_name": unit_name,
"time": time_str,
"address": address
}
print(f"准备发送活动报名成功通知: {phone_number}")
send_sms(phone_number, "SMS_501990528", params)

View File

@@ -1,8 +1,51 @@
import requests import requests
from django.core.cache import cache from django.core.cache import cache
from .models import WeChatPayConfig from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from .models import WeChatPayConfig, WeChatUser
def get_access_token(config=None): import logging
logger = logging.getLogger(__name__)
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return None
token = auth_header.split(' ')[1]
signer = TimestampSigner()
try:
# 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
user = WeChatUser.objects.filter(openid=openid).first()
if user:
return user
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
# 场景Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
if openid.startswith('web_'):
try:
# 格式: web_13800138000
parts = openid.split('_', 1)
if len(parts) == 2:
phone = parts[1]
# 尝试通过手机号查找(查找合并后的主账号)
user = WeChatUser.objects.filter(phone_number=phone).first()
if user:
return user
except Exception:
pass
return None
except (BadSignature, SignatureExpired):
return None
def get_access_token(config=None, force_refresh=False):
""" """
获取微信接口调用凭证 (client_credential) 获取微信接口调用凭证 (client_credential)
""" """
@@ -11,6 +54,7 @@ def get_access_token(config=None):
if config: if config:
cache_key = f'wechat_access_token_{config.app_id}' cache_key = f'wechat_access_token_{config.app_id}'
if not force_refresh:
token = cache.get(cache_key) token = cache.get(cache_key)
if token: if token:
return token return token
@@ -22,6 +66,7 @@ def get_access_token(config=None):
config = WeChatPayConfig.objects.filter(is_active=True).first() config = WeChatPayConfig.objects.filter(is_active=True).first()
if not config or not config.app_id or not config.app_secret: if not config or not config.app_id or not config.app_secret:
logger.error("No active WeChatPayConfig found or missing app_id/app_secret")
return None return None
url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={config.app_id}&secret={config.app_secret}" url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={config.app_id}&secret={config.app_secret}"
@@ -36,8 +81,8 @@ def get_access_token(config=None):
cache.set(cache_key, token, expires_in - 200) cache.set(cache_key, token, expires_in - 200)
return token return token
else: else:
print(f"获取 AccessToken 失败: {data}") logger.error(f"获取 AccessToken 失败: {data}")
except Exception as e: except Exception as e:
print(f"获取 AccessToken 异常: {str(e)}") logger.error(f"获取 AccessToken 异常: {str(e)}", exc_info=True)
return None return None

View File

@@ -10,8 +10,9 @@ from django.http import HttpResponse
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer
from .utils import get_access_token from .utils import get_access_token, get_current_wechat_user
from django.db import transaction from .services import handle_post_payment
from django.db import transaction, models
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.contrib.auth.models import User from django.contrib.auth.models import User
from wechatpayv3 import WeChatPay, WeChatPayType from wechatpayv3 import WeChatPay, WeChatPayType
@@ -24,11 +25,17 @@ import json
import os import os
import base64 import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from django.conf import settings from django.conf import settings
import requests import requests
import random import random
import threading import threading
import logging
import string
from django.core.cache import cache from django.core.cache import cache
logger = logging.getLogger(__name__)
from time import sleep from time import sleep
# 猴子补丁:绕过微信支付响应签名验证 # 猴子补丁:绕过微信支付响应签名验证
@@ -41,12 +48,16 @@ def patched_request(self, *args, **kwargs):
return original_request(self, *args, **kwargs) return original_request(self, *args, **kwargs)
Core.request = patched_request Core.request = patched_request
def get_wechat_pay_client(pay_type=WeChatPayType.NATIVE): def get_wechat_pay_client(pay_type=WeChatPayType.NATIVE, appid=None, config=None):
""" """
获取微信支付 V3 客户端实例的辅助函数 获取微信支付 V3 客户端实例的辅助函数
""" """
print(f"正在获取微信支付配置...") print(f"正在获取微信支付配置...")
wechat_config = config
if not wechat_config:
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first() wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config: if not wechat_config:
print("错误: 数据库中没有激活的 WeChatPayConfig") print("错误: 数据库中没有激活的 WeChatPayConfig")
return None, "支付配置未找到" return None, "支付配置未找到"
@@ -55,7 +66,13 @@ def get_wechat_pay_client(pay_type=WeChatPayType.NATIVE):
# 1. 严格清理所有配置项的空格和换行符 # 1. 严格清理所有配置项的空格和换行符
mch_id = str(wechat_config.mch_id).strip() mch_id = str(wechat_config.mch_id).strip()
# 如果传入了 appid优先使用传入的
if not appid:
appid = str(wechat_config.app_id).strip() appid = str(wechat_config.app_id).strip()
else:
appid = str(appid).strip()
apiv3_key = str(wechat_config.apiv3_key).strip() apiv3_key = str(wechat_config.apiv3_key).strip()
serial_no = str(wechat_config.mch_cert_serial_no).strip() serial_no = str(wechat_config.mch_cert_serial_no).strip()
notify_url = str(wechat_config.notify_url).strip() notify_url = str(wechat_config.notify_url).strip()
@@ -117,6 +134,8 @@ def get_wechat_pay_client(pay_type=WeChatPayType.NATIVE):
notify_url=notify_url, notify_url=notify_url,
cert_dir=cert_dir cert_dir=cert_dir
) )
# 保存私钥内容以便后续手动签名使用
wxpay._private_key_content = private_key
return wxpay, None return wxpay, None
except Exception as e: except Exception as e:
return None, str(e) return None, str(e)
@@ -249,8 +268,8 @@ def pay(request):
product = None product = None
if order_type == 'course': if order_type == 'course':
try: try:
product = VBCourse.objects.get(id=good_id) product = VCCourse.objects.get(id=good_id)
except VBCourse.DoesNotExist: except VCCourse.DoesNotExist:
print(f"课程不存在: {good_id}") print(f"课程不存在: {good_id}")
return Response({'error': f'找不到 ID 为 {good_id} 的课程'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': f'找不到 ID 为 {good_id} 的课程'}, status=status.HTTP_404_NOT_FOUND)
else: else:
@@ -336,7 +355,8 @@ def pay(request):
print(f"微信支付 V3 Native 下单成功!") print(f"微信支付 V3 Native 下单成功!")
print(f"订单 ID: {order.id}") print(f"订单 ID: {order.id}")
print(f"商户订单号: {out_trade_no}") print(f"商户订单号: {out_trade_no}")
print(f"商品: {product.name} x {quantity}") product_name = getattr(product, 'name', getattr(product, 'title', 'Unknown Product'))
print(f"商品: {product_name} x {quantity}")
print(f"总额: {total_price}") print(f"总额: {total_price}")
print(f"code_url: {code_url}") print(f"code_url: {code_url}")
print(f"========================================") print(f"========================================")
@@ -490,100 +510,8 @@ def payment_finish(request):
order.save() order.save()
print(f"订单 {order.id} 状态已更新") print(f"订单 {order.id} 状态已更新")
# 计算佣金 (旧版销售员系统) # 6. 处理支付后业务逻辑 (活动报名、佣金、短信通知)
try: handle_post_payment(order)
salesperson = order.salesperson
if salesperson:
# 1. 计算直接佣金 (一级)
# 优先级: 产品独立分润比例 > 销售员个人分润比例
rate_1 = 0
if order.config:
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else salesperson.commission_rate
elif order.course:
# 课程暂时使用销售员默认比例
rate_1 = salesperson.commission_rate
amount_1 = order.total_price * rate_1
if amount_1 > 0:
CommissionLog.objects.create(
order=order,
salesperson=salesperson,
amount=amount_1,
level=1,
status='pending'
)
print(f"生成一级佣金(Salesperson): {salesperson.name} - {amount_1}")
# 2. 计算上级佣金 (二级)
parent = salesperson.parent
if parent:
rate_2 = parent.second_level_rate
amount_2 = order.total_price * rate_2
if amount_2 > 0:
CommissionLog.objects.create(
order=order,
salesperson=parent,
amount=amount_2,
level=2,
status='pending'
)
print(f"生成二级佣金(Salesperson): {parent.name} - {amount_2}")
# 计算佣金 (新版分销员系统)
distributor = order.distributor
if distributor:
# 1. 计算直接佣金 (一级)
# 优先级: 产品独立分润比例 > 分销员个人分润比例
rate_1 = 0
if order.config:
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else distributor.commission_rate
elif order.course:
# 课程暂时使用分销员默认比例
rate_1 = distributor.commission_rate
amount_1 = order.total_price * rate_1
if amount_1 > 0:
CommissionLog.objects.create(
order=order,
distributor=distributor,
amount=amount_1,
level=1,
status='settled' # 简化流程,直接结算到余额
)
# 更新余额
distributor.total_earnings += amount_1
distributor.withdrawable_balance += amount_1
distributor.save()
print(f"生成一级佣金(Distributor): {distributor.user.nickname} - {amount_1}")
# 2. 计算上级佣金 (二级)
parent = distributor.parent
if parent:
# 二级固定比例 2% (0.02)
rate_2 = 0.02
amount_2 = order.total_price * models.DecimalField(max_digits=5, decimal_places=4).to_python(rate_2)
if amount_2 > 0:
CommissionLog.objects.create(
order=order,
distributor=parent,
amount=amount_2,
level=2,
status='settled'
)
# 更新余额
parent.total_earnings += amount_2
parent.withdrawable_balance += amount_2
parent.save()
print(f"生成二级佣金(Distributor): {parent.user.nickname} - {amount_2}")
except Exception as e:
print(f"佣金计算失败: {str(e)}")
import traceback
traceback.print_exc()
except Exception as e: except Exception as e:
print(f"订单更新失败: {str(e)}") print(f"订单更新失败: {str(e)}")
@@ -604,7 +532,7 @@ class VCCourseViewSet(viewsets.ReadOnlyModelViewSet):
""" """
VC课程列表和详情 VC课程列表和详情
""" """
queryset = VCCourse.objects.all().order_by('-created_at') queryset = VCCourse.objects.all()
serializer_class = VCCourseSerializer serializer_class = VCCourseSerializer
class CourseEnrollmentViewSet(viewsets.ModelViewSet): class CourseEnrollmentViewSet(viewsets.ModelViewSet):
@@ -628,7 +556,7 @@ class ServiceViewSet(viewsets.ReadOnlyModelViewSet):
""" """
AI服务列表和详情 AI服务列表和详情
""" """
queryset = Service.objects.all().order_by('-created_at') queryset = Service.objects.all()
serializer_class = ServiceSerializer serializer_class = ServiceSerializer
class ServiceOrderViewSet(viewsets.ModelViewSet): class ServiceOrderViewSet(viewsets.ModelViewSet):
@@ -669,12 +597,30 @@ class OrderViewSet(viewsets.ModelViewSet):
return queryset.filter(wechat_user=user).order_by('-created_at') return queryset.filter(wechat_user=user).order_by('-created_at')
return queryset.order_by('-created_at') return queryset.order_by('-created_at')
def create(self, request, *args, **kwargs):
print(f"Creating order with data: {request.data}")
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
print(f"Order validation failed: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer): def perform_create(self, serializer):
""" """
创建订单时自动关联当前微信用户 创建订单时自动关联当前微信用户
""" """
user = get_current_wechat_user(self.request) user = get_current_wechat_user(self.request)
serializer.save(wechat_user=user) instance = serializer.save(wechat_user=user)
# Check if free course and set to paid
if instance.course and instance.course.price == 0 and instance.status == 'pending':
instance.status = 'paid'
instance.save()
# Trigger post payment logic
from .services import handle_post_payment
handle_post_payment(instance)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def prepay_miniprogram(self, request, pk=None): def prepay_miniprogram(self, request, pk=None):
@@ -694,12 +640,23 @@ class OrderViewSet(viewsets.ModelViewSet):
order.wechat_user = user order.wechat_user = user
order.save() order.save()
# 小程序 AppID
miniprogram_appid = 'wxdf2ca73e6c0929f0'
# 尝试查找特定配置
wechat_config = WeChatPayConfig.objects.filter(app_id=miniprogram_appid).first()
if not wechat_config:
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first() wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config: if not wechat_config:
return Response({'error': '支付系统维护中'}, status=status.HTTP_503_SERVICE_UNAVAILABLE) return Response({'error': '支付系统维护中'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
# 初始化支付客户端 # 初始化支付客户端,强制使用小程序 AppID
wxpay, error_msg = get_wechat_pay_client(pay_type=WeChatPayType.JSAPI) wxpay, error_msg = get_wechat_pay_client(
pay_type=WeChatPayType.JSAPI,
appid=miniprogram_appid,
config=wechat_config
)
if not wxpay: if not wxpay:
return Response({'error': error_msg}, status=500) return Response({'error': error_msg}, status=500)
@@ -717,11 +674,19 @@ class OrderViewSet(viewsets.ModelViewSet):
else: else:
description = f"支付订单 {order.id}" description = f"支付订单 {order.id}"
# 强制修正回调地址为正确的后端接口地址
# 用户配置可能是 /pay (前端页面),我们需要的是 /api/finish/ (后端回调接口)
current_notify = wechat_config.notify_url
if 'quant-speed.com' in current_notify:
notify_url = "https://market.quant-speed.com/api/finish/"
else:
notify_url = current_notify # Fallback
print(f"准备发起微信支付(小程序):") print(f"准备发起微信支付(小程序):")
print(f" OutTradeNo: {out_trade_no}") print(f" OutTradeNo: {out_trade_no}")
print(f" Amount: {amount_in_cents}") print(f" Amount: {amount_in_cents}")
print(f" OpenID: {user.openid}") print(f" OpenID: {user.openid}")
print(f" NotifyURL: {wechat_config.notify_url}") print(f" NotifyURL: {notify_url}")
# 统一下单 (JSAPI) # 统一下单 (JSAPI)
code, message = wxpay.pay( code, message = wxpay.pay(
@@ -729,7 +694,7 @@ class OrderViewSet(viewsets.ModelViewSet):
out_trade_no=out_trade_no, out_trade_no=out_trade_no,
amount={'total': amount_in_cents, 'currency': 'CNY'}, amount={'total': amount_in_cents, 'currency': 'CNY'},
payer={'openid': user.openid}, # 小程序支付必须传 openid payer={'openid': user.openid}, # 小程序支付必须传 openid
notify_url=wechat_config.notify_url notify_url=notify_url
) )
print(f"微信支付响应: Code={code}, Message={message}") print(f"微信支付响应: Code={code}, Message={message}")
@@ -746,14 +711,28 @@ class OrderViewSet(viewsets.ModelViewSet):
# 再次签名 (小程序端需要的签名) # 再次签名 (小程序端需要的签名)
# 注意WeChatPayV3 SDK 可能没有直接提供生成小程序签名的 helper需手动计算 # 注意WeChatPayV3 SDK 可能没有直接提供生成小程序签名的 helper需手动计算
# 签名串格式appId\ntimeStamp\nnonceStr\npackage\n # 签名串格式appId\ntimeStamp\nnonceStr\npackage\n
message_build = f"{wechat_config.app_id}\n{timestamp}\n{nonce_str}\n{package}\n" message_build = f"{miniprogram_appid}\n{timestamp}\n{nonce_str}\n{package}\n"
# 使用商户私钥签名 print(f"待签名字符串:\n{repr(message_build)}")
# 注意WeChatPayV3 对象的私钥属性名可能随版本变化,或者被封装
# 这里我们不直接访问私钥,而是利用 SDK 提供的 sign 方法
# 实际上 wechatpayv3 库提供了 sign 方法 # 手动签名
signature = wxpay.sign(message_build) from cryptography.hazmat.backends import default_backend
private_key_obj = serialization.load_pem_private_key(
wxpay._private_key_content.encode('utf-8'),
password=None,
backend=default_backend()
)
signature = base64.b64encode(
private_key_obj.sign(
message_build.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
).decode('utf-8')
print(f"生成的签名: {signature}")
return Response({ return Response({
'timeStamp': timestamp, 'timeStamp': timestamp,
@@ -928,6 +907,10 @@ class OrderViewSet(viewsets.ModelViewSet):
order.status = 'paid' order.status = 'paid'
order.wechat_trade_no = result.get('transaction_id') order.wechat_trade_no = result.get('transaction_id')
order.save() order.save()
# 处理支付后逻辑
handle_post_payment(order)
return Response({'status': 'paid', 'message': '支付成功', 'detail': result}) return Response({'status': 'paid', 'message': '支付成功', 'detail': result})
return Response({'status': 'pending', 'trade_state': trade_state, 'message': result.get('trade_state_desc')}) return Response({'status': 'pending', 'trade_state': trade_state, 'message': result.get('trade_state_desc')})
@@ -946,54 +929,27 @@ class OrderViewSet(viewsets.ModelViewSet):
order.status = 'paid' order.status = 'paid'
order.wechat_trade_no = f"WX_{str(uuid.uuid4())[:18]}" order.wechat_trade_no = f"WX_{str(uuid.uuid4())[:18]}"
order.save() order.save()
handle_post_payment(order)
return Response({'status': 'success', 'message': '支付成功'}) return Response({'status': 'success', 'message': '支付成功'})
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return None
token = auth_header.split(' ')[1]
signer = TimestampSigner()
try:
# 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
user = WeChatUser.objects.filter(openid=openid).first()
if user:
return user
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
# 场景Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
if openid.startswith('web_'):
try:
# 格式: web_13800138000
parts = openid.split('_', 1)
if len(parts) == 2:
phone = parts[1]
# 尝试通过手机号查找(查找合并后的主账号)
user = WeChatUser.objects.filter(phone_number=phone).first()
if user:
return user
except Exception:
pass
return None
except (BadSignature, SignatureExpired):
return None
@extend_schema( @extend_schema(
summary="微信小程序登录", summary="微信小程序登录",
description="支持通过 code 登录,以及可选的 phone_code 用于直接获取手机号并合并 Web 用户账号", description="支持通过 code 登录,以及可选的 phone_code 用于直接获取手机号并合并 Web 用户账号。同时支持传入用户基本信息nickname, avatar_url, gender, country, province, city",
request={ request={
'application/json': { 'application/json': {
'properties': { 'properties': {
'code': {'type': 'string', 'description': 'wx.login获取的code'}, 'code': {'type': 'string', 'description': 'wx.login获取的code'},
'phone_code': {'type': 'string', 'description': 'getPhoneNumber获取的code (可选)'} 'phone_code': {'type': 'string', 'description': 'getPhoneNumber获取的code (可选)'},
'nickname': {'type': 'string', 'description': '昵称 (可选)'},
'avatar_url': {'type': 'string', 'description': '头像URL (可选)'},
'gender': {'type': 'integer', 'description': '性别 0未知 1男 2女 (可选)'},
'country': {'type': 'string', 'description': '国家 (可选)'},
'province': {'type': 'string', 'description': '省份 (可选)'},
'city': {'type': 'string', 'description': '城市 (可选)'}
}, },
'required': ['code'] 'required': ['code']
} }
@@ -1005,6 +961,22 @@ def wechat_login(request):
code = request.data.get('code') code = request.data.get('code')
phone_code = request.data.get('phone_code') phone_code = request.data.get('phone_code')
# 获取可选的用户信息
nickname = request.data.get('nickname')
avatar_url = request.data.get('avatar_url')
gender = request.data.get('gender')
country = request.data.get('country')
province = request.data.get('province')
city = request.data.get('city')
print("="*20 + " 小程序登录调试 " + "="*20)
print(f"收到登录请求: code={code}")
print(f"用户信息: nickname={nickname}, gender={gender}")
print(f"头像URL: {avatar_url}")
print(f"位置信息: country={country}, province={province}, city={city}")
print(f"完整数据: {request.data}")
print("="*50)
if not code: if not code:
return Response({'error': 'Code is required'}, status=400) return Response({'error': 'Code is required'}, status=400)
@@ -1044,6 +1016,15 @@ def wechat_login(request):
phone_res = requests.post(phone_url, json={'code': phone_code}, timeout=5) phone_res = requests.post(phone_url, json={'code': phone_code}, timeout=5)
phone_data = phone_res.json() phone_data = phone_res.json()
# Retry if access token is invalid or expired
if phone_data.get('errcode') in [40001, 40014, 42001]:
print(f"Access token invalid/expired ({phone_data.get('errcode')}), refreshing...")
access_token = get_access_token(config, force_refresh=True)
if access_token:
phone_url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}"
phone_res = requests.post(phone_url, json={'code': phone_code}, timeout=5)
phone_data = phone_res.json()
if phone_data.get('errcode') == 0: if phone_data.get('errcode') == 0:
phone_info = phone_data.get('phone_info') phone_info = phone_data.get('phone_info')
phone_number = phone_info.get('purePhoneNumber') phone_number = phone_info.get('purePhoneNumber')
@@ -1068,62 +1049,65 @@ def wechat_login(request):
if mp_user and phone_user: if mp_user and phone_user:
if mp_user != phone_user: if mp_user != phone_user:
# 【合并场景】: 小程序用户 和 手机号用户 都存在且不同 # 【合并场景】: 小程序用户 和 手机号用户 都存在且不同
# 规则: 只要手机号一致,强制合并。以当前 OpenID (mp_user) 为准,吸纳旧用户 (phone_user) 的数据。
# 检查 phone_user 是否已经是真实的 MP 用户 (防止覆盖已绑定的其他微信账号)
# 规则: 如果 phone_user.openid 不是以 'web_' 开头,说明它已经是一个微信用户
# 此时我们不能简单的合并,因为这意味着两个不同的微信账号绑定了同一个手机号(可能是异常或用户更换了微信号)
# 策略: 优先保留当前的 mp_user提示用户手机号已被占用或者这里简单处理为不合并手机号只登录 mp_user
if not phone_user.openid.startswith('web_'):
print(f"冲突: 手机号 {phone_number} 已被用户 {phone_user.id} (OpenID: {phone_user.openid}) 绑定,无法合并到当前用户 {mp_user.id}")
# 这种情况下,我们让当前用户登录,但不更新手机号 (或者可以返回错误提示需人工解绑)
user = mp_user
# 也可以选择强制更新手机号到当前用户,并解绑旧用户(取决于业务规则,这里选择保守策略:不合并,仅登录)
else:
# 是 Web 虚拟用户,可以安全合并
# 1. 迁移订单 # 1. 迁移订单
Order.objects.filter(wechat_user=phone_user).update(wechat_user=mp_user) Order.objects.filter(wechat_user=phone_user).update(wechat_user=mp_user)
# 2. 迁移社区数据 (延迟导入避免循环引用) # 2. 迁移社区数据 (延迟导入避免循环引用)
from community.models import ActivitySignup, Topic, Reply from community.models import ActivitySignup, Topic, Reply
ActivitySignup.objects.filter(user=phone_user).update(user=mp_user) ActivitySignup.objects.filter(user=phone_user).update(user=mp_user)
Topic.objects.filter(author=phone_user).update(author=mp_user) Topic.objects.filter(author=phone_user).update(author=mp_user)
Reply.objects.filter(author=phone_user).update(author=mp_user) Reply.objects.filter(author=phone_user).update(author=mp_user)
# 3. 迁移分销员 # 3. 迁移分销员
if hasattr(phone_user, 'distributor') and not hasattr(mp_user, 'distributor'): if hasattr(phone_user, 'distributor') and not hasattr(mp_user, 'distributor'):
dist = phone_user.distributor dist = phone_user.distributor
dist.user = mp_user dist.user = mp_user
dist.save() dist.save()
# 4. 迁移用户信息
# 如果 mp_user 尚未设置昵称头像(新用户),则沿用 phone_user 的
if not mp_user.nickname and phone_user.nickname:
mp_user.nickname = phone_user.nickname
if not mp_user.avatar_url and phone_user.avatar_url:
mp_user.avatar_url = phone_user.avatar_url
if mp_user.gender == 0 and phone_user.gender != 0:
mp_user.gender = phone_user.gender
# 迁移关联的系统用户 (用于管理员权限等)
if phone_user.user and not mp_user.user:
mp_user.user = phone_user.user
phone_user.user = None
phone_user.save()
# 标记拥有Web徽章 (如果旧用户是 Web 用户)
if phone_user.openid.startswith('web_') or phone_user.has_web_badge:
mp_user.has_web_badge = True
# 更新手机号
mp_user.phone_number = phone_number
mp_user.save()
# 删除旧用户 # 删除旧用户
phone_user.delete() phone_user.delete()
user = mp_user user = mp_user
# 更新手机号
if not user.phone_number:
user.phone_number = phone_number
user.save()
else: else:
# 同一个用户 # 同一个用户
user = mp_user user = mp_user
elif phone_user: elif phone_user:
# 【绑定场景】: 只有手机号用户存在 (通常是 Web 用户) -> 升级为小程序用户 # 【绑定场景】: 只有手机号用户存在
# 无论是否 Web 用户,只要 OpenID 不同,都更新为最新的 OpenID
user = phone_user user = phone_user
# 只有当它是 Web 虚拟用户时,才覆盖 OpenID if user.openid.startswith('web_'):
if user.openid.startswith('web_') or not user.openid: user.has_web_badge = True
if user.openid != openid:
print(f"用户更换 OpenID: {user.openid} -> {openid}, Phone: {phone_number}")
user.openid = openid user.openid = openid
user.save() user.save()
elif user.openid != openid:
# 冲突: 手机号已被另一个真实 OpenID 绑定,但当前登录的是新的 OpenID
# 策略: 创建新用户,不合并 (避免安全风险)
# 检查 openid 是否已被其他用户占用 (理论上 mp_user 为 None 说明没有,但双重检查)
existing_openid_user = WeChatUser.objects.filter(openid=openid).first()
if existing_openid_user:
user = existing_openid_user
else:
user = WeChatUser.objects.create(openid=openid)
# 此时不绑定手机号,因为手机号被 phone_user 占用了
elif mp_user: elif mp_user:
# 【更新场景】: 只有小程序用户存在 -> 更新手机号 # 【更新场景】: 只有小程序用户存在 -> 更新手机号
@@ -1135,18 +1119,47 @@ def wechat_login(request):
else: else:
# 【新建场景】: 都不存在 -> 创建新用户 # 【新建场景】: 都不存在 -> 创建新用户
user = WeChatUser.objects.create(openid=openid)
if phone_number: if phone_number:
user = WeChatUser.objects.create(openid=openid)
user.phone_number = phone_number user.phone_number = phone_number
user.save() user.save()
else:
# 严格限制:没有手机号无法注册
# 如果用户既不是已存在的小程序用户,也未提供手机号,则拒绝注册/登录
print(f"拒绝无手机号注册: OpenID={openid}")
return Response({'error': '请授权手机号进行登录', 'code': 'PHONE_REQUIRED'}, status=400)
# 统一更新会话信息 (确保 user 对象是最新的) # 统一更新会话信息 (确保 user 对象存在)
# 重新获取对象以防状态不一致 (可选,但推荐) if user and user.openid == openid:
# user.refresh_from_db()
if user.openid == openid:
user.session_key = session_key user.session_key = session_key
user.unionid = unionid user.unionid = unionid
# 更新用户基本信息 (如果有传入)
if nickname:
user.nickname = nickname
elif not user.nickname:
# 默认昵称逻辑 (与 Web 端保持一致)
if user.phone_number:
user.nickname = f"User_{user.phone_number[-4:]}"
else:
user.nickname = f"WeChat_User_{user.openid[-4:]}"
if avatar_url:
user.avatar_url = avatar_url
elif not user.avatar_url:
# 默认头像逻辑
seed = user.phone_number or user.openid
user.avatar_url = f"https://api.dicebear.com/7.x/avataaars/svg?seed={seed}"
if gender is not None:
user.gender = gender
if country:
user.country = country
if province:
user.province = province
if city:
user.city = city
user.save() user.save()
created = False # 简化处理 created = False # 简化处理
@@ -1161,25 +1174,22 @@ def wechat_login(request):
# 生成 Token # 生成 Token
if not user: if not user:
return Response({'error': 'Login failed: User not created'}, status=500) # 用户未注册且未提供手机号
return Response({'error': 'User not registered', 'code': 'USER_NOT_FOUND'}, status=404)
signer = TimestampSigner() signer = TimestampSigner()
token = signer.sign(user.openid) token = signer.sign(user.openid)
return Response({ # Use serializer to ensure all fields (including is_star, is_admin, etc.) are included
serializer = WeChatUserSerializer(user)
data = serializer.data
data.update({
'token': token, 'token': token,
'id': user.id,
'openid': user.openid,
'is_new': created, 'is_new': created,
'nickname': user.nickname,
'avatar_url': user.avatar_url,
'phone_number': user.phone_number,
'gender': user.gender,
'province': user.province,
'city': user.city,
'country': user.country
}) })
return Response(data)
@extend_schema( @extend_schema(
summary="更新微信用户信息", summary="更新微信用户信息",
request=WeChatUserSerializer, request=WeChatUserSerializer,
@@ -1264,16 +1274,16 @@ def phone_login(request):
signer = TimestampSigner() signer = TimestampSigner()
token = signer.sign(user.openid) token = signer.sign(user.openid)
return Response({ # Use serializer to ensure all fields are included
serializer = WeChatUserSerializer(user)
data = serializer.data
data.update({
'token': token, 'token': token,
'id': user.id, 'is_new': created,
'openid': user.openid,
'nickname': user.nickname,
'avatar_url': user.avatar_url,
'phone_number': user.phone_number,
'is_new': created
}) })
return Response(data)
@extend_schema( @extend_schema(
summary="绑定手机号 (小程序端)", summary="绑定手机号 (小程序端)",
@@ -1319,12 +1329,8 @@ def bind_phone(request):
return Response({'message': '已绑定该手机号'}) return Response({'message': '已绑定该手机号'})
# 发现冲突,需要合并 # 发现冲突,需要合并
# 策略:保留 current_user (MP User, with real OpenID),合并 existing_user (Web User) 的数据 # 策略:保留 current_user (MP User, with real OpenID),合并 existing_user (Phone User) 的数据
# 仅当 existing_user 是 Web 用户 (openid startswith 'web_') 时才合并 # 无论 existing_user 是否是 Web 用户,都允许合并,以 current_user 为主(覆盖旧 OpenID
# 如果 existing_user 也是 MP 用户 (real openid),则提示冲突,不允许绑定
if not existing_user.openid.startswith('web_'):
return Response({'error': '该手机号已被其他微信账号绑定,无法重复绑定'}, status=status.HTTP_409_CONFLICT)
# 执行合并 # 执行合并
from django.db import transaction from django.db import transaction
@@ -1338,13 +1344,31 @@ def bind_phone(request):
Topic.objects.filter(author=existing_user).update(author=current_user) Topic.objects.filter(author=existing_user).update(author=current_user)
# 4. 迁移 Reply # 4. 迁移 Reply
Reply.objects.filter(author=existing_user).update(author=current_user) Reply.objects.filter(author=existing_user).update(author=current_user)
# 5. 迁移 Distributor (如果 Web 用户注册了分销员,且 MP 用户未注册) # 5. 迁移 Distributor (如果用户注册了分销员,且用户未注册)
if hasattr(existing_user, 'distributor') and not hasattr(current_user, 'distributor'): if hasattr(existing_user, 'distributor') and not hasattr(current_user, 'distributor'):
dist = existing_user.distributor dist = existing_user.distributor
dist.user = current_user dist.user = current_user
dist.save() dist.save()
# 删除旧 Web 用户 # 6. 迁移用户信息 (如果新用户尚未设置,则使用旧用户的信息)
if not current_user.nickname and existing_user.nickname:
current_user.nickname = existing_user.nickname
if not current_user.avatar_url and existing_user.avatar_url:
current_user.avatar_url = existing_user.avatar_url
if current_user.gender == 0 and existing_user.gender != 0:
current_user.gender = existing_user.gender
# 7. 迁移系统用户关联
if existing_user.user and not current_user.user:
current_user.user = existing_user.user
existing_user.user = None
existing_user.save()
# 8. 标记 Web 徽章 (如果旧用户是 Web 用户或已有徽章)
if existing_user.openid.startswith('web_') or existing_user.has_web_badge:
current_user.has_web_badge = True
# 删除旧用户
existing_user.delete() existing_user.delete()
# 更新当前用户手机号 # 更新当前用户手机号
@@ -1408,18 +1432,75 @@ class DistributorViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'])
def invite(self, request): def invite(self, request):
"""生成小程序码""" """生成小程序码"""
try:
user = get_current_wechat_user(request) user = get_current_wechat_user(request)
if not user or not hasattr(user, 'distributor'): if not user or not hasattr(user, 'distributor'):
return Response({'error': 'Unauthorized'}, status=401) return Response({'error': 'Unauthorized'}, status=401)
distributor = user.distributor distributor = user.distributor
if distributor.qr_code_url: if distributor.qr_code_url:
# 检查文件是否真的存在
try:
# 如果是本地存储,检查文件路径
if distributor.qr_code_url.startswith(settings.MEDIA_URL):
file_path = distributor.qr_code_url.replace(settings.MEDIA_URL, '', 1)
if default_storage.exists(file_path):
return Response({'qr_code_url': distributor.qr_code_url}) return Response({'qr_code_url': distributor.qr_code_url})
elif distributor.qr_code_url.startswith('http'):
# 远程 URL假设有效
return Response({'qr_code_url': distributor.qr_code_url})
except Exception as e:
logger.warning(f"Error checking QR code existence: {e}")
# 调用微信接口生成小程序码 (wxacode.getUnlimited) # 如果文件不存在,重置 URL 并重新生成
# 这里简化处理返回模拟URL或需要实现具体逻辑 distributor.qr_code_url = ''
# 实际逻辑需要获取 AccessToken 然后调用 API distributor.save()
return Response({'qr_code_url': 'https://placeholder.com/qrcode.png'})
# 确保有邀请码
if not distributor.invite_code:
distributor.invite_code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
distributor.save()
access_token = get_access_token()
if not access_token:
logger.error("Failed to get access token for invite generation")
return Response({'error': 'Failed to get access token'}, status=500)
# 微信小程序码接口 B适用于需要的码数量极多的业务场景
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}"
data = {
"scene": distributor.invite_code,
"page": "pages/index/index", # 扫码落地页
"width": 430,
"check_path": False, # 开发阶段不检查页面路径是否存在(因为可能还未发布)
"env_version": "develop" # 开发版
}
res = requests.post(url, json=data)
# 微信返回图片时 Content-Type 包含 image/jpeg 或 image/png
if res.status_code == 200 and 'image' in res.headers.get('Content-Type', ''):
file_name = f"distributor_qr_{distributor.invite_code}_{uuid.uuid4().hex[:6]}.png"
# 保存到 media/qr_codes 目录
path = default_storage.save(f"qr_codes/{file_name}", ContentFile(res.content))
qr_url = default_storage.url(path)
distributor.qr_code_url = qr_url
distributor.save()
return Response({'qr_code_url': qr_url})
else:
# 如果是 JSON 错误信息
logger.error(f"WeChat API error in invite: {res.status_code} - {res.text}")
try:
detail = res.json()
except:
detail = res.text
return Response({'error': 'WeChat API error', 'detail': detail}, status=500)
except Exception as e:
logger.error("Exception in invite view: %s", str(e), exc_info=True)
import traceback
traceback.print_exc()
return Response({'error': str(e), 'traceback': traceback.format_exc()}, status=500)
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'])
def withdraw(self, request): def withdraw(self, request):
@@ -1530,7 +1611,7 @@ class WeChatUserViewSet(viewsets.ReadOnlyModelViewSet):
""" """
获取明星技术用户列表 获取明星技术用户列表
""" """
stars = WeChatUser.objects.filter(is_star=True).order_by('-created_at') stars = WeChatUser.objects.filter(is_star=True).order_by('order', '-created_at')
serializer = self.get_serializer(stars, many=True) serializer = self.get_serializer(stars, many=True)
return Response(serializer.data) return Response(serializer.data)

18
backend/start_judge_system.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
echo "Starting Judge System..."
# 激活虚拟环境 (如果有)
if [ -d "venv" ]; then
source venv/bin/activate
fi
# 安装依赖
pip install -r requirements.txt
# 迁移数据库
python manage.py makemigrations
python manage.py migrate
# 启动 Django 开发服务器
echo "Server running at http://127.0.0.1:8000/competition/admin/"
python manage.py runserver 0.0.0.0:8000

39
deploy_market_page 2.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# 定义关键变量,方便后续维护修改
TARGET_DIR="~/data/dev/market_page"
SUDO_PASSWORD="123quant-speed"
# 脚本执行出错时立即退出
set -e
# 1. 切换到目标目录(先解析 ~ 为实际家目录)
echo "===== 切换到目标目录: $TARGET_DIR ====="
RESOLVED_DIR=$(eval echo $TARGET_DIR)
cd $RESOLVED_DIR || {
echo "错误:目录 $RESOLVED_DIR 不存在!"
exit 1
}
# 2. 停止并移除 Docker 容器(自动输入 sudo 密码)
echo -e "\n===== 停止 Docker 容器 ====="
echo $SUDO_PASSWORD | sudo -S docker compose down
# 3. 删除 Docker 镜像(说明:这里默认删除 compose 关联的镜像,也可指定镜像名)
echo -e "\n===== 删除 Docker 镜像 ====="
# 方式1删除 compose.yml 中定义的所有镜像(推荐)
echo $SUDO_PASSWORD | sudo -S docker compose down --rmi all
# 方式2如果你想删除指定镜像替换上面这行示例需修改为你的镜像名
# echo $SUDO_PASSWORD | sudo -S docker rmi -f your-image-name:tag
# 4. 拉取 Git 最新代码
echo -e "\n===== 拉取 Git 代码 ====="
git pull || {
echo "警告Git pull 失败(可能是本地有未提交的修改),脚本继续执行..."
}
# 5. 重新启动 Docker 容器(后台运行)
echo -e "\n===== 启动 Docker 容器 ====="
echo $SUDO_PASSWORD | sudo -S docker compose up -d
echo -e "\n===== 操作完成!====="

View File

@@ -1,10 +1,11 @@
services: services:
backend: backend:
build: ./backend build: ./backend
# 使用 gunicorn 替代 runserver提高稳定性并捕获标准输出 # 使用 gunicorn 替代 runserver提高稳定性并捕获标准输出1
command: sh -c "python manage.py collectstatic --noinput && python manage.py migrate && gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - config.wsgi:application" command: sh -c "python manage.py collectstatic --noinput && python manage.py migrate && gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - config.wsgi:application"
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./backend/media:/app/media
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:

Some files were not shown because too many files have changed in this diff Show More