Compare commits
675 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c3fa91fb | ||
|
|
c03867304e | ||
|
|
b595120d62 | ||
|
|
8e66f0bfb3 | ||
|
|
e9ea1adc5d | ||
|
|
2eee171a50 | ||
|
|
fd6a8a3650 | ||
|
|
84a90b7760 | ||
|
|
7335c59b68 | ||
|
|
035c9d9d3d | ||
|
|
36da18af79 | ||
|
|
363badbc97 | ||
|
|
9be6265220 | ||
|
|
be53e6c6ac | ||
|
|
4eab130313 | ||
|
|
c706c515ee | ||
|
|
8a576595ce | ||
|
|
8c809bbff1 | ||
|
|
08ed396444 | ||
|
|
f5eb84589f | ||
|
|
daca384822 | ||
|
|
0a6e944a9a | ||
|
|
e0d1b08e89 | ||
|
|
6b54b7b1c5 | ||
|
|
adc7fb0d07 | ||
|
|
f969c8145c | ||
|
|
20b5daba50 | ||
|
|
aa2e0acaba | ||
|
|
e57736bcc1 | ||
|
|
a8db8dde7b | ||
|
|
635f6e5d5a | ||
|
|
4875574c6e | ||
|
|
b1d5652bc7 | ||
|
|
025f45df0a | ||
|
|
981a5c9f0f | ||
|
|
88cfd40abe | ||
|
|
cdcf0ff5f3 | ||
|
|
42e355149c | ||
|
|
518364d70d | ||
|
|
f25340c0b3 | ||
|
|
24f607f442 | ||
|
|
6fbddbe77c | ||
|
|
21ba2665b9 | ||
|
|
0888f599a4 | ||
|
|
ef7293704b | ||
|
|
8cd4669b90 | ||
|
|
7f7a82b4b8 | ||
|
|
0863e5529a | ||
|
|
e0ad8ce6c1 | ||
|
|
9f4e5a8335 | ||
|
|
587db459bf | ||
|
|
5b87714acf | ||
|
|
bc54ac9462 | ||
|
|
7e5b31cfb2 | ||
|
|
d81b4ed273 | ||
|
|
0c1a913134 | ||
|
|
7dc641e69e | ||
|
|
18336c870e | ||
|
|
e43588c3b2 | ||
|
|
64649b514e | ||
|
|
24710289e1 | ||
|
|
2a3f05e06f | ||
|
|
0d31106b0f | ||
|
|
fbd1c829a1 | ||
|
|
82d2ca6360 | ||
|
|
717e520556 | ||
|
|
c8ddb511cf | ||
|
|
caf728de8d | ||
|
|
a7cd4d7fa8 | ||
|
|
ddc0046e24 | ||
|
|
1059630b9d | ||
|
|
e1c1fc030f | ||
|
|
09edb14d56 | ||
|
|
f27cef2d66 | ||
|
|
07a2e6df29 | ||
|
|
f521f0df65 | ||
|
|
a67fcd6f02 | ||
|
|
d17f404853 | ||
|
|
8def4addc4 | ||
|
|
0ecaf9740f | ||
|
|
bc75680ee9 | ||
|
|
6a71964592 | ||
|
|
00a2ea3d2f | ||
|
|
95e97333b4 | ||
|
|
9e65500748 | ||
|
|
a2acd6f6e4 | ||
|
|
ee96730268 | ||
|
|
f925f238dd | ||
|
|
39c6ca3e8c | ||
|
|
c798faa8db | ||
|
|
ed2f843815 | ||
|
|
984b98e4fc | ||
|
|
4b32472d64 | ||
|
|
fc171bc71f | ||
|
|
cc80fa83e0 | ||
|
|
782ba4a151 | ||
|
|
04708cedb6 | ||
|
|
4068966700 | ||
|
|
3ce8cf381a | ||
|
|
f78d3f3aff | ||
|
|
c60dff0950 | ||
|
|
f2d49ee104 | ||
|
|
a248d81230 | ||
|
|
1ac6bad2bb | ||
|
|
37de721df9 | ||
|
|
773eead827 | ||
|
|
c4dd04ccb6 | ||
|
|
2cdde37069 | ||
|
|
f68f759418 | ||
|
|
801d0b24ab | ||
|
|
29be29b9cf | ||
|
|
c253044f61 | ||
|
|
9acf7d2046 | ||
|
|
3911af7b51 | ||
|
|
6b722b7ed7 | ||
|
|
6a00b87f72 | ||
|
|
0a97039d75 | ||
|
|
cb56a01622 | ||
|
|
452af4bd2f | ||
|
|
75073d4320 | ||
|
|
d4d7a0d69f | ||
|
|
165ad03024 | ||
|
|
3603cf9889 | ||
|
|
027662ebab | ||
|
|
106465b932 | ||
|
|
eef4c6fbe5 | ||
|
|
916ae97ca7 | ||
|
|
841405505d | ||
|
|
22a653bb0f | ||
|
|
3482e4b1a8 | ||
|
|
9097369b0c | ||
|
|
95c6b53f10 | ||
|
|
f7d5040b02 | ||
|
|
26b7f83d35 | ||
|
|
07b99c6e75 | ||
|
|
cb5e7e2cc7 | ||
|
|
2180998e81 | ||
|
|
478876ddc1 | ||
|
|
ae021fd148 | ||
|
|
f36317b081 | ||
|
|
066a5a619c | ||
|
|
654793156d | ||
|
|
ba65378c6b | ||
|
|
cb6c50b071 | ||
|
|
2cb67fafe7 | ||
|
|
8eaba6f364 | ||
|
|
c4f0fb5a3d | ||
|
|
59ad79fa58 | ||
|
|
c65f0276bd | ||
|
|
f8b335a003 | ||
|
|
0ac4b546ba | ||
|
|
07a41ca0ac | ||
|
|
347465fc4d | ||
|
|
acb9cd317c | ||
|
|
b7213f8c47 | ||
|
|
a3caf5ebdf | ||
|
|
87dd07ef23 | ||
|
|
0cefb7eaff | ||
|
|
ff87de9f44 | ||
|
|
22de7de87c | ||
|
|
53dd9dca0f | ||
|
|
12d6bbea19 | ||
|
|
23b06327d6 | ||
|
|
6c22e373f7 | ||
|
|
4ebbb387ee | ||
|
|
9234fe3ed1 | ||
|
|
70be6619e9 | ||
|
|
c8c27e808f | ||
|
|
9cb8c92492 | ||
|
|
f4f9ee1d3d | ||
|
|
138336711f | ||
|
|
2163bb0bff | ||
|
|
bc460f0da8 | ||
|
|
ad66811f49 | ||
|
|
70ad8c394a | ||
|
|
32ffecb905 | ||
|
|
b794ba7a6b | ||
|
|
07360a8d2c | ||
|
|
fb7731ddcd | ||
|
|
13a25e3011 | ||
|
|
055cf53738 | ||
|
|
cb414b48f6 | ||
|
|
1c27719ac4 | ||
|
|
ec33327408 | ||
|
|
c2c27a684b | ||
|
|
224703a6d0 | ||
|
|
dd20711c04 | ||
|
|
3a2b7b1400 | ||
|
|
792989a504 | ||
|
|
c0183e62fb | ||
|
|
ce5bb5f187 | ||
|
|
a34b0c88d5 | ||
|
|
9c7ec58bb6 | ||
|
|
067a736b57 | ||
|
|
f8f08c9d0d | ||
|
|
4f2d382fd6 | ||
|
|
42e4ddbd17 | ||
|
|
3026cd698f | ||
|
|
47c53a18fa | ||
|
|
22926e19cd | ||
|
|
495b25e2b1 | ||
|
|
01908b7c48 | ||
|
|
b138dc580d | ||
|
|
78b14f4aad | ||
|
|
60387aa521 | ||
|
|
633826cb89 | ||
|
|
cf6d180fc5 | ||
|
|
0d85174250 | ||
|
|
925449c66a | ||
|
|
cd58b418af | ||
|
|
4cfc5e6024 | ||
|
|
7321ab06f0 | ||
|
|
790f5d4838 | ||
|
|
731dbc5507 | ||
|
|
3b1dce6d67 | ||
|
|
4929d44ce7 | ||
|
|
ce42c2a660 | ||
|
|
16d5ffd4f9 | ||
|
|
fc74e0d952 | ||
|
|
089f219280 | ||
|
|
9d62ec1ec1 | ||
|
|
5a4e51d1e0 | ||
|
|
f0982d7d9a | ||
|
|
1ac3a4cc96 | ||
|
|
7f9c42d3d8 | ||
|
|
4e99e398d6 | ||
|
|
395fc155ce | ||
|
|
6bdefc4f03 | ||
|
|
d4547cbe97 | ||
|
|
c9a0b7481a | ||
|
|
f496bc5fca | ||
|
|
4ba02b9dce | ||
|
|
f821e5ad28 | ||
|
|
425f7b6f79 | ||
|
|
61d7970b6a | ||
|
|
1aa9984535 | ||
|
|
8ab810c670 | ||
|
|
5cc3d60e15 | ||
|
|
42a2eb56c7 | ||
|
|
4b0f4e388c | ||
|
|
31045b3808 | ||
|
|
a95f22bf42 | ||
|
|
fa84f92577 | ||
|
|
90a5624877 | ||
|
|
f42250b8b7 | ||
|
|
b9809d207d | ||
|
|
0d8e10b60e | ||
|
|
501ff21e55 | ||
|
|
4759e28a56 | ||
|
|
bd7841ac05 | ||
|
|
ea0d27fdea | ||
|
|
610979f30b | ||
|
|
9a8304d595 | ||
|
|
e020a80020 | ||
|
|
7a21a2d800 | ||
|
|
ec0db3a76c | ||
|
|
67fc0781e5 | ||
|
|
79c2ba140c | ||
|
|
908171a977 | ||
|
|
a52dc14369 | ||
|
|
1e94ce501e | ||
|
|
7a5ef3a491 | ||
|
|
c08323e1ea | ||
|
|
fdf5ceeaab | ||
|
|
48ef4cfdef | ||
|
|
10c6177a9f | ||
|
|
0362c83e77 | ||
|
|
1af29837e2 | ||
|
|
986c4871df | ||
|
|
fe7a2a0e73 | ||
|
|
23faf28f7f | ||
|
|
a8d4f261a4 | ||
|
|
a336fd4a1a | ||
|
|
8759e6fd7e | ||
|
|
92d23014a7 | ||
|
|
7c3f33ea0d | ||
|
|
16a55de6f1 | ||
|
|
869ac7d316 | ||
|
|
55303689ea | ||
|
|
c69123ac92 | ||
|
|
7bce5f1c1f | ||
|
|
989660969c | ||
|
|
862acd0776 | ||
|
|
3b3ffd494f | ||
|
|
6cf8290565 | ||
|
|
230ebbcfb9 | ||
|
|
dc77f1cda1 | ||
|
|
1f791b528a | ||
|
|
1459d953ed | ||
|
|
719a36b275 | ||
|
|
0b7a3046fe | ||
|
|
203d107d68 | ||
|
|
17fd7f02a6 | ||
|
|
57ea4f2b6f | ||
|
|
df431eea46 | ||
|
|
ad9dd6330f | ||
|
|
df9d291f98 | ||
|
|
0cf7fc2ed2 | ||
|
|
e8f82baa99 | ||
|
|
353a05f344 | ||
|
|
d94ebfe04c | ||
|
|
52913abb4f | ||
|
|
d77406951d | ||
|
|
8c23192eeb | ||
|
|
078c9c198d | ||
|
|
6cfe2d226a | ||
|
|
fee1c12357 | ||
|
|
a6385b699e | ||
|
|
718ed8953f | ||
|
|
a1eea77b9e | ||
|
|
6eb08ac09b | ||
|
|
20fc2b073b | ||
|
|
8c4b9e8d12 | ||
|
|
8d187f5cfc | ||
|
|
db07a96e97 | ||
|
|
7acc9227ff | ||
|
|
c3a71e5b07 | ||
|
|
ac9e1e5e67 | ||
|
|
c668340661 | ||
|
|
ee9b6248bb | ||
|
|
01c7f7250b | ||
|
|
2abc5976f9 | ||
|
|
3e468c74e4 | ||
|
|
4ef78d2c81 | ||
|
|
4621222fa3 | ||
|
|
be860f9968 | ||
|
|
fe0b8aed20 | ||
|
|
f0e844c308 | ||
|
|
6a7cc95b23 | ||
|
|
7fd90b9ceb | ||
|
|
43577073e6 | ||
|
|
faeeb09a4a | ||
|
|
d88349b6f7 | ||
|
|
ff53e1fac3 | ||
|
|
cf4894b7c3 | ||
|
|
678dfd2d5c | ||
|
|
bf4a62ae04 | ||
|
|
7e6f3f92cf | ||
|
|
df382dafb4 | ||
|
|
10925d3a47 | ||
|
|
66252072c7 | ||
|
|
29918882bd | ||
|
|
4983fe8feb | ||
|
|
f65da118d7 | ||
|
|
a86bd9a05e | ||
|
|
f2719eb742 | ||
|
|
4f9ee1dfa9 | ||
|
|
e6ad1218bc | ||
|
|
dd2cd1df9a | ||
|
|
6dcbe8ba38 | ||
|
|
360d4dbbe2 | ||
|
|
2f32b53d19 | ||
|
|
6a3e3c3753 | ||
|
|
5ad08d8d36 | ||
|
|
b892d92614 | ||
|
|
b259f083d4 | ||
|
|
38aa9fe2fb | ||
|
|
863dd3a53e | ||
|
|
bea5058df8 | ||
|
|
31c157f58f | ||
|
|
8af6887daa | ||
|
|
eb9b7b4f86 | ||
|
|
cf78766a37 | ||
|
|
944824b552 | ||
|
|
477bb1ac8f | ||
|
|
29df864ecb | ||
|
|
bcf897b7e0 | ||
|
|
e63890c755 | ||
|
|
f3725215bd | ||
|
|
c43e305ea7 | ||
|
|
b9215e2410 | ||
|
|
19d79ab055 | ||
|
|
64d4492806 | ||
|
|
0790eae8c6 | ||
|
|
e10e2c27c1 | ||
|
|
d30b38d4b9 | ||
|
|
f6e4ed7c60 | ||
|
|
7a6bbfac75 | ||
|
|
425d6f9a06 | ||
|
|
58c760bb77 | ||
|
|
3ffdce5e7a | ||
|
|
8e518a044a | ||
|
|
a5adbf80a9 | ||
|
|
0b6c478b4f | ||
|
|
0434bde16f | ||
|
|
0deb3113b5 | ||
|
|
ecb52c76b9 | ||
|
|
69c66053b7 | ||
|
|
892ad395a7 | ||
|
|
e801c09c0f | ||
|
|
ad560a8555 | ||
|
|
e75aa5c2b9 | ||
|
|
e83fd7af1b | ||
|
|
eaec8ef994 | ||
|
|
3339e6b442 | ||
|
|
4c2425c758 | ||
|
|
80d1e6469e | ||
|
|
2fad6394ee | ||
|
|
4bfe33a37f | ||
|
|
130c8bf3b1 | ||
|
|
b9df277104 | ||
|
|
97e1f321ca | ||
|
|
4933930afd | ||
|
|
ab4640382d | ||
|
|
e4cfa4b405 | ||
|
|
789062e85e | ||
|
|
5370bee369 | ||
|
|
2f972488a1 | ||
|
|
6f7656802f | ||
|
|
7d98c5493e | ||
|
|
e0443aa336 | ||
|
|
39ff0d1516 | ||
|
|
1b9c0ee4b8 | ||
|
|
d48287f93a | ||
|
|
717e87cfa9 | ||
|
|
708b488af8 | ||
|
|
d60d3f374b | ||
|
|
8b87a2bc40 | ||
|
|
d0da517503 | ||
|
|
754036c472 | ||
|
|
720438fd91 | ||
|
|
ba76df1b00 | ||
|
|
44d85c2864 | ||
|
|
1c8b73a381 | ||
|
|
b445af932c | ||
|
|
5121739fe4 | ||
|
|
96106498d8 | ||
|
|
0116d92021 | ||
|
|
43746634a5 | ||
|
|
5183786fb0 | ||
|
|
5ba0eed721 | ||
|
|
7d08c735ef | ||
|
|
e3067b685c | ||
|
|
b219ca4c1c | ||
|
|
9e5d16ff16 | ||
|
|
da630458e1 | ||
|
|
ee2eceffb0 | ||
|
|
c8d22e7b5f | ||
|
|
342e8725bd | ||
|
|
3ced00de1f | ||
|
|
7fa075fa75 | ||
|
|
95ca496691 | ||
|
|
50b1d93f08 | ||
|
|
8958f2f234 | ||
|
|
00b4d6a748 | ||
|
|
f4de0d8276 | ||
|
|
cfa749f4f3 | ||
|
|
eeaff08673 | ||
|
|
0475e88dc2 | ||
|
|
e1f73a4639 | ||
|
|
e2296a6f64 | ||
|
|
1a6abf4e1b | ||
|
|
315851eb5f | ||
|
|
0b99b4a9a0 | ||
|
|
66002ff401 | ||
|
|
bdfc8bdd0c | ||
|
|
98e4668969 | ||
|
|
e8235dd0a2 | ||
|
|
123c74de46 | ||
|
|
c92b9bf0fb | ||
|
|
b4cbfd2ae9 | ||
|
|
dd7eee277e | ||
|
|
ab76185434 | ||
|
|
6d97bf1e88 | ||
|
|
49701fcd09 | ||
|
|
40f04d9860 | ||
|
|
d58dd25dbb | ||
|
|
9b2731607b | ||
|
|
a8d2d6f13f | ||
|
|
7c21782ab5 | ||
|
|
f59bdaf5e0 | ||
|
|
9419ddd174 | ||
|
|
0666a8f5c2 | ||
|
|
81c019105c | ||
|
|
6584259454 | ||
|
|
03d0f56095 | ||
|
|
6ffd169784 | ||
|
|
406f64a7c5 | ||
|
|
1353a2c4c9 | ||
|
|
fb88f3bd96 | ||
|
|
22b3598704 | ||
|
|
b62c580d5e | ||
|
|
6a63ceaecc | ||
|
|
591f9e61fb | ||
|
|
7011c81bcd | ||
|
|
3cf7055122 | ||
|
|
aba31eda83 | ||
|
|
1b30582dd9 | ||
|
|
0fb66358cc | ||
|
|
e226f444f7 | ||
|
|
95bf70f568 | ||
|
|
a6597b44c3 | ||
|
|
51c01c5445 | ||
|
|
161bf75a1d | ||
|
|
2f16e2c608 | ||
|
|
aea2e79b37 | ||
|
|
f433d13a2f | ||
|
|
e9abf6ed05 | ||
|
|
0c32b25ddf | ||
|
|
a03dec91c5 | ||
|
|
7c5a966944 | ||
|
|
652dc0953b | ||
|
|
03860a6dce | ||
|
|
c6bee25264 | ||
|
|
068de0fa9f | ||
|
|
4b45d5ca26 | ||
|
|
a268391e68 | ||
|
|
89bdd86f14 | ||
|
|
e533bd7e35 | ||
|
|
09ed978e80 | ||
|
|
4b106e1f41 | ||
|
|
feeeb26d94 | ||
|
|
bef0d2d992 | ||
|
|
6e6bd8a6be | ||
|
|
631fa0db4e | ||
|
|
65d30b7a30 | ||
|
|
5ba5f27ca7 | ||
|
|
acc437bf2d | ||
|
|
5fd2505a33 | ||
|
|
7f6abc331b | ||
|
|
c190aab8b9 | ||
|
|
0f71abdac3 | ||
|
|
8ddc507bd5 | ||
|
|
1c4bae2d91 | ||
|
|
73ca4b1ea5 | ||
|
|
18a922b5cd | ||
|
|
11b98978c1 | ||
|
|
379d3811a8 | ||
|
|
0401b8a6e6 | ||
|
|
6148b996d8 | ||
|
|
39781c9cd7 | ||
|
|
18758a1614 | ||
|
|
b044d8d90e | ||
|
|
02e56f87bc | ||
|
|
d9b9ee221b | ||
|
|
21ec9188ca | ||
|
|
4d768becf5 | ||
|
|
a27049386b | ||
|
|
b23e3d7359 | ||
|
|
7660164583 | ||
|
|
5e1f3c5564 | ||
|
|
197fa9c01c | ||
|
|
554e3d0c2f | ||
|
|
b800cde34d | ||
|
|
775fdd2be0 | ||
|
|
7908ae4258 | ||
|
|
bfbd8229a1 | ||
|
|
afbf8dedbf | ||
|
|
569912abef | ||
|
|
7c94f6bc9a | ||
|
|
b825b5b063 | ||
|
|
50098b5e70 | ||
|
|
e237b4db1c | ||
|
|
2a25cf3bbd | ||
|
|
02275bb417 | ||
|
|
788cae3efe | ||
|
|
0dec70c53a | ||
|
|
f534f012d2 | ||
|
|
bb83875c99 | ||
|
|
d048aa33f7 | ||
|
|
8f3e250073 | ||
|
|
63a792d169 | ||
|
|
eb3524a22d | ||
|
|
f657a24a1a | ||
|
|
a5228448d7 | ||
|
|
1ec4796f72 | ||
|
|
6964158cf6 | ||
|
|
4fc4dd1b16 | ||
|
|
3e851f0c3c | ||
|
|
b8befaa973 | ||
|
|
b05046af29 | ||
|
|
eecc6c9e53 | ||
|
|
d4e754d601 | ||
|
|
a8a54593e2 | ||
|
|
5bbffc4f5c | ||
|
|
0833018399 | ||
|
|
f6850fc795 | ||
|
|
c0b4674568 | ||
|
|
5a8996d90a | ||
|
|
548b30e5b3 | ||
|
|
80f9329004 | ||
|
|
f672280236 | ||
|
|
90a4a01de7 | ||
|
|
09cebb90fe | ||
|
|
70389aab3d | ||
|
|
d9132a722f | ||
|
|
ea7a4e46e0 | ||
|
|
07b91058af | ||
|
|
c27ace6a6a | ||
|
|
1c0a5b17ca | ||
|
|
9b12a829d2 | ||
|
|
0f41172468 | ||
|
|
8597705a77 | ||
|
|
3f733ce857 | ||
|
|
40f8ec77b8 | ||
|
|
0af967d6c9 | ||
|
|
f6d43c9f39 | ||
|
|
70b0538dd5 | ||
|
|
439262b930 | ||
|
|
968b2587ae | ||
|
|
15f471a032 | ||
|
|
5175157ba6 | ||
|
|
e51e8f7196 | ||
|
|
00b34fda42 | ||
|
|
b34fabab54 | ||
|
|
487c7e2824 | ||
|
|
46c79a8772 | ||
|
|
bfb4144e57 | ||
|
|
dc1bb72070 | ||
|
|
8e084d2362 | ||
|
|
d5a75f887d | ||
|
|
710609e98b | ||
|
|
b73ab76bfb | ||
|
|
27b64df870 | ||
|
|
eabb897f96 | ||
|
|
68c5e47bad | ||
|
|
2ae5af7019 | ||
|
|
860d1ca9b3 | ||
|
|
66a9d1f25e | ||
|
|
bbfeedcdb3 | ||
|
|
079e273edb | ||
|
|
393aab4c4b | ||
|
|
4f2bf7549c | ||
|
|
acdf23571c | ||
|
|
62ec634db3 | ||
|
|
c53e978106 | ||
|
|
a7fa757d0d | ||
|
|
5fb1bd4175 | ||
|
|
e792ab7b4d | ||
|
|
02544d29fd | ||
|
|
20acbd0331 | ||
|
|
115b4aacb8 | ||
|
|
8746caab06 | ||
|
|
625648c908 | ||
|
|
734b5f9534 | ||
|
|
a0579318bd | ||
|
|
a437e3cbd3 | ||
|
|
1b242dc04e | ||
|
|
a1a51914a2 | ||
|
|
f6cab9b5a9 | ||
|
|
a3649c04e2 | ||
|
|
a562bfdb08 | ||
|
|
8ffe64ad8e | ||
|
|
a116d06d61 | ||
|
|
c26f73a5a8 | ||
|
|
f5847a57c1 | ||
|
|
fe9d23a0ff | ||
|
|
cdc27004bf | ||
|
|
b914164a77 | ||
|
|
35e58f90bc | ||
|
|
16d360c582 | ||
|
|
4c075b4d11 | ||
|
|
8c9c1c5afa | ||
|
|
d093163cd4 | ||
|
|
9bd6fcefd3 | ||
|
|
5139947643 | ||
|
|
01ff10385a | ||
|
|
9969c3a7ac | ||
|
|
f7ed2ec3e3 | ||
|
|
fedeeb3076 | ||
|
|
8157c27529 | ||
|
|
0eba0c6a4b | ||
|
|
13fb9db52b | ||
|
|
f6818ba880 | ||
|
|
dbf3b3cc79 | ||
|
|
24534069da | ||
|
|
4cec0a7350 | ||
|
|
0b86fa7bee | ||
|
|
b406e22695 | ||
|
|
3fca783dd8 | ||
|
|
6de4865052 | ||
|
|
facc2fab24 | ||
|
|
ddc0931e90 | ||
|
|
d5d32038f5 | ||
|
|
a20edd9bec |
119
.claude/skills/dootask-backup/SKILL.md
Normal file
119
.claude/skills/dootask-backup/SKILL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
description: 备份 DooTask 数据:数据库(必须)+ public/uploads(排除 tmp,可选)+ docker/appstore/config(可选)。汇总到临时目录并附 README 说明,打包到 backup/ 按日期命名。只读取源数据、绝不删改,失败即停。
|
||||
---
|
||||
|
||||
# DooTask 数据备份
|
||||
|
||||
**刚性技能**——前置检查 → 选可选项 → 确认 → 执行 → 报告。只读取源数据生成归档,**绝不删除或修改任何源数据/既有备份**。任何一步失败立即停止。
|
||||
|
||||
## 备份范围
|
||||
|
||||
| 项 | 来源 | 是否必须 | 说明 |
|
||||
|----|------|---------|------|
|
||||
| 数据库 | `./cmd mysql backup` 产出的 `.sql.gz` | **必须** | 脚本内部用 mysqldump 导出当前库 |
|
||||
| 上传文件 | `public/uploads`(**排除 `public/uploads/tmp`**) | 可选 | 头像/聊天/任务/文件等真实上传数据;`tmp` 是临时目录,可重建,不备份 |
|
||||
| 应用配置 | `docker/appstore/config` | 可选 | 应用市场各应用的配置;含 **root 属主子目录**,收集时可能需 sudo |
|
||||
|
||||
> `docker/appstore/apps` **不在备份范围**——可从应用市场重新安装,无需备份。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
1. **工作目录**:在项目根(存在 `cmd`、`docker-compose.yml`)
|
||||
2. **数据库容器**:`mariadb` 容器在跑(DB 备份依赖它;不在则提示用户先 `./cmd up` 起服务)
|
||||
3. **磁盘空间**:确认 `backup/` 所在盘空间足够(数据库 dump 可能较大)
|
||||
4. **选可选项**:询问用户本次是否包含 `public/uploads` 和 `docker/appstore/config`(**默认两个都含**)
|
||||
|
||||
检查通过、可选项确定后,汇报本次将备份哪些项,**向用户确认一次**再执行。
|
||||
|
||||
## 执行
|
||||
|
||||
用一个统一时间戳贯穿全程:`TS=$(date +%Y%m%d_%H%M%S)`,临时目录 `WORK="tmp/dootask-backup-${TS}"`。
|
||||
|
||||
### 1) 建临时工作目录
|
||||
```shell
|
||||
mkdir -p "$WORK"
|
||||
```
|
||||
(`tmp/` 已被 gitignore,安全)
|
||||
|
||||
### 2) 数据库(必须)
|
||||
```shell
|
||||
./cmd mysql backup
|
||||
```
|
||||
脚本会把 dump 写到 `docker/mysql/backup/<库名>_<时间戳>.sql.gz` 并打印「备份文件:...」。**取该次产出的最新 dump** 复制进工作目录(不用关心它原始落在哪):
|
||||
```shell
|
||||
DB_FILE=$(ls -t docker/mysql/backup/*.sql.gz | head -1)
|
||||
cp "$DB_FILE" "$WORK/"
|
||||
```
|
||||
|
||||
### 3) public/uploads(可选,排除 tmp)
|
||||
```shell
|
||||
rsync -a --exclude='tmp' public/uploads/ "$WORK/uploads/"
|
||||
```
|
||||
> 无 rsync 时用 tar 管道:`mkdir -p "$WORK/uploads" && tar cf - --exclude='./tmp' -C public/uploads . | tar xf - -C "$WORK/uploads"`
|
||||
|
||||
### 4) docker/appstore/config(可选)
|
||||
```shell
|
||||
cp -a docker/appstore/config "$WORK/appstore-config"
|
||||
```
|
||||
> 含 root 属主子目录,若报 `permission denied`:改用 `sudo cp -a ...`,随后把整个工作目录属主归还当前用户,保证后续打包/清理不受阻:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" "$WORK"
|
||||
> ```
|
||||
|
||||
### 5) 写 README.md(备份说明)
|
||||
在 `$WORK/README.md` 写明本次备份信息,便于日后识别与还原。模板:
|
||||
```markdown
|
||||
# DooTask 备份 — <TS>
|
||||
|
||||
- 备份时间:<人类可读时间>
|
||||
- DooTask 版本:<取自 package.json 的 version>
|
||||
- 包含内容:
|
||||
- 数据库:<DB dump 文件名>(来源 mysqldump 当前库)
|
||||
- 上传文件:uploads/(来源 public/uploads,已排除 tmp) ← 未选则写「未包含」
|
||||
- 应用配置:appstore-config/(来源 docker/appstore/config) ← 未选则写「未包含」
|
||||
- 各项大小:<du -sh 列出工作目录内各项>
|
||||
|
||||
## 还原提示
|
||||
- 数据库:`gunzip < <db>.sql.gz | mysql -u<user> -p<pass> <库名>`,或用 `./cmd mysql recovery` 选对应文件还原。
|
||||
- 上传文件:将 uploads/ 内容覆盖回项目 public/uploads/。
|
||||
- 应用配置:将 appstore-config/ 覆盖回 docker/appstore/config/。
|
||||
```
|
||||
|
||||
### 6) 打包到 backup/,清理临时目录
|
||||
```shell
|
||||
mkdir -p backup
|
||||
tar czf "backup/dootask_backup_${TS}.tar.gz" -C tmp "dootask-backup-${TS}"
|
||||
rm -rf "$WORK"
|
||||
```
|
||||
|
||||
## 报告
|
||||
|
||||
向用户报告:
|
||||
- 最终归档路径:`backup/dootask_backup_<TS>.tar.gz`
|
||||
- 归档大小(`ls -lh`)
|
||||
- 实际包含了哪些项(数据库 + 视选择含/不含 uploads、appstore-config)
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误
|
||||
- **不要**自动重试、不要静默跳过某一项(可选项是否包含由前置确认决定,不在执行中临时变更)
|
||||
- DB 备份失败(如 mariadb 未运行)→ 停止,提示用户起服务后重试
|
||||
- 打包前若工作目录有 root 属主残留导致 tar/rm 失败 → `sudo chown` 归还属主后继续,不要删源数据
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 为"省空间"删除源数据或既有备份 | 只读取源数据生成归档,源数据一律不动 |
|
||||
| 备份 `public/uploads/tmp` | 排除 tmp(临时、可重建) |
|
||||
| 把 `docker/appstore/apps` 也打进去 | 不在范围,可从应用市场重装 |
|
||||
| 遇 config 的 root 子目录就跳过该项 | `sudo` 收集后 chown 归还,完整备份 |
|
||||
| 不写 README 直接打包 | 每个归档自带 README,便于日后识别还原 |
|
||||
| 把归档写进 git | 归档放 `backup/`(已 gitignore),不提交 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "源数据太大,删点旧的再备份" → 不,备份只读不删
|
||||
- "config 有 root 目录,跳过算了" → 不,sudo 收集后归还属主
|
||||
- "apps 也一起备了更全" → 不,apps 不在范围
|
||||
- "tmp 里临时文件顺手也备了" → 不,明确排除 `public/uploads/tmp`
|
||||
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
76
.claude/skills/dootask-fix-permission/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: dootask-fix-permission
|
||||
description: 修复 DooTask 可写目录(bootstrap/cache、docker、public、storage)的属主/权限:chown 回当前用户 + 目录 chmod 775,对齐 install 的赋权逻辑,赋权不删数据。
|
||||
---
|
||||
|
||||
# DooTask 目录权限修复
|
||||
|
||||
容器内进程常以 **root** 写入挂载目录(`storage`、`public/uploads`、`bootstrap/cache` 等),导致宿主机当前用户对这些文件**没有写权限**,进而触发:
|
||||
|
||||
- `./cmd install` 报「目录【xxx】权限不足」/ 目录权限检测失败
|
||||
- `./cmd build`(vite)报 `EACCES: permission denied, copyfile`(复制 `public/uploads/...` 时)
|
||||
- Laravel 运行时写 `storage`/`bootstrap/cache` 失败
|
||||
|
||||
本技能**对齐 `./cmd install` 的目录赋权逻辑**:对四个可写目录做 `chmod 775`(目录)+ `chown` 回当前用户。
|
||||
|
||||
## 适用目录
|
||||
|
||||
与 install 一致的四个:
|
||||
|
||||
```
|
||||
bootstrap/cache
|
||||
docker
|
||||
public # 含 public/uploads(真实上传数据)
|
||||
storage
|
||||
```
|
||||
|
||||
## 核心原则:赋权,不删数据
|
||||
|
||||
`public/uploads` 含真实上传文件(头像、附件等)。**永远优先 `chown` 改属主,不要删数据。** 即便用户说"清理一下",也只允许清临时目录 `public/uploads/tmp`,**切勿**删 uploads 下其他内容。
|
||||
|
||||
## 前置检查
|
||||
|
||||
1. **工作目录**:在项目根(存在 `cmd` 且这四个目录在)
|
||||
2. **sudo**:改属主需 root(当前文件多为 root 属主)。本机一般可免密 sudo;不行则经 docker 以 root 改权限
|
||||
3. 确认要修的范围:默认四个目录全修;若用户只想解 build 报错,也可只针对 `public`(含 `public/uploads`)
|
||||
|
||||
检查通过后汇报将执行的命令,**向用户确认一次**再执行。
|
||||
|
||||
## 执行
|
||||
|
||||
确认后执行(属主修回当前用户,目录权限 775):
|
||||
|
||||
```shell
|
||||
# 1) 属主修回当前用户(递归)
|
||||
sudo chown -R "$(id -u):$(id -g)" bootstrap/cache docker public storage
|
||||
|
||||
# 2) 目录权限 775(仅目录,对齐 install 的 `find -type d -exec chmod 775`)
|
||||
find bootstrap/cache docker public storage -type d -exec chmod 775 {} \;
|
||||
```
|
||||
|
||||
> 只想解 build 的 uploads 报错时,可只对 `public`:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
> ```
|
||||
|
||||
执行后报告:改了哪些目录、属主/权限现状(可 `ls -ld` 抽查),并提示用户可重试之前失败的 install/build/update。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `chown` 报权限不足 → 当前用户无 sudo 权限,提示用户用有 root 权限的账户,或经 docker 以 root 执行;不要静默跳过
|
||||
- 任何步骤失败立即停止报告,不自动重试
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| build 报 uploads EACCES 就 `rm` 删文件 | `chown` 修属主,保留数据 |
|
||||
| 删整个 `public/uploads` 清场 | 最多清 `public/uploads/tmp`,别碰真实上传数据 |
|
||||
| 对文件无差别 `chmod 777` | 目录 `chmod 775` + `chown` 回当前用户即可 |
|
||||
| 不加 sudo 直接 chown root 文件 | 改属主需 root |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "uploads 复制失败,删掉再 build" → 不,`chown` 赋权,不丢数据
|
||||
- "777 一把梭最省事" → 不,按 install 的 775(目录)+ chown
|
||||
- "权限不够就跳过这个目录" → 不,报告交用户处理 sudo
|
||||
74
.claude/skills/dootask-install/SKILL.md
Normal file
74
.claude/skills/dootask-install/SKILL.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: dootask-install
|
||||
description: 首次部署 DooTask:前置检查后执行 `sudo ./cmd install`(建库 + migrate --seed 的重操作),刚性流程、单次确认、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 安装流程
|
||||
|
||||
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并步骤,不要为"省事"绕过 sudo 或确认。
|
||||
|
||||
`./cmd install` 已把整套安装封装为单条命令(赋权→起容器→`composer install`→`key:generate`→`migrate --seed`→`up -d`)。本技能的职责是**安装前把关、选对参数、执行前确认、已知失败处理**,而不是把脚本逻辑拆开重做。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
执行前依次确认:
|
||||
|
||||
1. **工作目录**:必须在项目根(存在 `cmd`、`docker-compose.yml`、`.env.docker`)
|
||||
2. **Docker**:`docker` 与 `docker-compose`/`docker compose`(v2+) 可用且 daemon 在跑(脚本 `check_docker` 也会查,但提前确认能更早报错)
|
||||
3. **Node.js ≥ 20**(脚本 `check_node` 会查)
|
||||
4. **APP_ID 不冲突**:若 `.env` 已有 `APP_ID` 且被其他实例占用,脚本 `check_instance` 会报错——此时**停止**,提示用户先清空 `.env` 里的 `APP_ID` 和 `APP_IPPR` 再装
|
||||
5. **sudo**:`./cmd install` 需 root(`check_sudo`),用 `sudo ./cmd install` 执行
|
||||
|
||||
⚠️ **这是重操作**:会创建数据库并执行 `migrate --seed`(灌入种子数据)。在已有数据的环境上重装前务必和用户确认,避免覆盖。
|
||||
|
||||
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||
|
||||
## 参数选择
|
||||
|
||||
| 参数 | 作用 | 何时用 |
|
||||
|------|------|--------|
|
||||
| `--port <端口>` | 指定 HTTP 端口(脚本会做端口占用检测) | 用户要自定义端口,或默认端口被占 |
|
||||
| `--relock` | 删除 `node_modules`/`package-lock.json`/`vendor`/`composer.lock` 后重装 | **谨慎**:仅在依赖锁损坏、用户明确要求重建锁时用,会拖慢安装 |
|
||||
|
||||
不确定时不要自作主张加参数,按需询问用户。
|
||||
|
||||
## 执行
|
||||
|
||||
确认后执行(按用户选择带上参数):
|
||||
|
||||
```shell
|
||||
sudo ./cmd install
|
||||
# 或: sudo ./cmd install --port 8080
|
||||
```
|
||||
|
||||
成功后脚本会输出访问地址并调用 `repassword.sh`。执行完向用户报告:访问地址(`http://127.0.0.1:<APP_PORT>`)、以及数据库密码提示。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误信息
|
||||
- **不要**自动重试,**不要**自动跳过
|
||||
- 常见失败与对应处理:
|
||||
- `APP_ID(xxx)已被其他实例使用` → 停止,让用户清空 `.env` 的 `APP_ID`/`APP_IPPR` 再装
|
||||
- `端口 xxx 已被占用` → 停止,让用户换 `--port`
|
||||
- `目录【xxx】权限不足` / 目录权限检测失败 → 这是目录属主/权限问题,引导用户用 **dootask-fix-permission** 技能修复后重装
|
||||
- `安装依赖失败`(composer)→ 报告,交用户决定(常因网络/镜像源)
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 不加 sudo 直接 `./cmd install` | 用 `sudo ./cmd install`(脚本强制 root) |
|
||||
| 失败后"我再试一次"或自动跳过 | 立即停止,交还用户 |
|
||||
| 在已有数据环境上不问就重装 | 先确认会 `migrate --seed`,可能影响现有数据 |
|
||||
| 遇权限报错自己乱 `chmod`/`chown` | 走 dootask-fix-permission 技能统一处理 |
|
||||
| 不问就加 `--relock` | 默认不加;仅用户明确要求或锁损坏时用 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "端口/权限报错了我顺手帮 TA 改一下别的" → 停下,只处理本次报的问题,按指引走对应技能
|
||||
- "种子数据应该没事,直接重装" → 不,先确认是否会覆盖现有数据
|
||||
- "sudo 麻烦,先试试不加" → 不,install 必须 root
|
||||
204
.claude/skills/dootask-release/SKILL.md
Normal file
204
.claude/skills/dootask-release/SKILL.md
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: dootask-release
|
||||
description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本号/更新日志 → 构建 → 提交推送,刚性顺序、每步确认、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 发布流程
|
||||
|
||||
**刚性技能**——严格按顺序执行,每步向用户确认,任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
按固定顺序执行,不增删、合并或重排步骤。翻译(Step 1)和更新日志(Step 2)由你直接产出;脚本只做确定性机械工作(算版本号、检测差异、字节级生成语言文件)。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
执行任何发布步骤前,依次检查:
|
||||
|
||||
1. **分支**:必须是 `pro`,否则停止,提示用户切换
|
||||
2. **工作区**:`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
|
||||
3. **Node.js**:`node --version` 必须 ≥ 20
|
||||
4. **PHP**:`php --version` 必须可用(Step 1 的脚本依赖本地 php,无需容器)。若 host 无 php,停止并提示用户
|
||||
|
||||
检查通过后汇报结果,用户确认后再开始执行。
|
||||
|
||||
## 发布步骤
|
||||
|
||||
**每步执行前**向用户确认;**每步执行后**报告结果。
|
||||
|
||||
开始前先把这份清单复制到你的回复里,逐项勾选、跟踪进度:
|
||||
|
||||
```
|
||||
发布进度:
|
||||
- [ ] 前置检查(分支 pro / 工作区干净 / node≥20 / php 可用)
|
||||
- [ ] Step 1 翻译(diff → 翻译 → apply → generate)
|
||||
- [ ] Step 2 版本号 + CHANGELOG
|
||||
- [ ] Step 3 构建(./cmd prod)
|
||||
- [ ] 汇总变更 → 用户确认 → commit + push
|
||||
- [ ] 确认 GitHub Actions Publish 工作流 success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 1: 翻译
|
||||
|
||||
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`。
|
||||
|
||||
**1.1 检测差异**
|
||||
|
||||
```shell
|
||||
php .claude/skills/dootask-release/scripts/language.php diff
|
||||
```
|
||||
|
||||
输出 JSON:
|
||||
- `regexErrorCount > 0`:translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
|
||||
- `redundantCount > 0`:translate.json 里有、但原文已删除的条目 → 仅作提示(apply 时会自动剔除,不致命)
|
||||
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
|
||||
- `needsCount > 0`:`needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
|
||||
|
||||
**1.2 翻译**
|
||||
|
||||
对 `needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`。
|
||||
|
||||
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
|
||||
|
||||
| 原文 | 翻成英语 |
|
||||
|---|---|
|
||||
| (%T1)的周报[(%T2)][(%T3)月第(%T4)周] | Weekly report of (%T1) [(%T2)] [Week (%T4) of month (%T3)] |
|
||||
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
|
||||
|
||||
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
|
||||
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`(`zh` 写 `""`)。
|
||||
|
||||
```json
|
||||
[
|
||||
{"key":"...(%T1)...","zh":"","zh-CHT":"...","en":"...","ko":"...","ja":"...","de":"...","fr":"...","id":"...","ru":"..."}
|
||||
]
|
||||
```
|
||||
|
||||
**1.3 合并进 translate.json**
|
||||
|
||||
```shell
|
||||
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
|
||||
```
|
||||
|
||||
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。
|
||||
|
||||
**1.4 生成前端/后端语言文件**
|
||||
|
||||
```shell
|
||||
php .claude/skills/dootask-release/scripts/language.php generate
|
||||
```
|
||||
|
||||
由 `translate.json` 字节级重新生成 `public/language/web/*.js` 与 `public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。
|
||||
|
||||
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 版本号 + 更新日志
|
||||
|
||||
**2.1 计算并写入版本号**
|
||||
|
||||
```shell
|
||||
node .claude/skills/dootask-release/scripts/version_bump.js
|
||||
```
|
||||
|
||||
脚本据 git 历史算出新 `version` 与 `codeVerson` 并写入 `package.json`,输出 JSON 含:`version`、`prevVersion`、`changelogRange`(如 `<上次release提交>..HEAD`,用于下一步圈定本次更新范围)。
|
||||
|
||||
**2.2 撰写 CHANGELOG**
|
||||
|
||||
读取本次区间的提交:
|
||||
|
||||
```shell
|
||||
git log <changelogRange> --stat
|
||||
```
|
||||
|
||||
`--stat` 会带上每个提交的完整描述正文 + 改动文件清单;光看标题不够时用 `git show <hash>` 看具体代码改动。
|
||||
|
||||
按 `CHANGELOG.md` 现有格式,在文件顶部 `# Changelog` 说明段之后、紧挨上一个 `## [...]` 之前,插入新版本区段:
|
||||
|
||||
```markdown
|
||||
## [<version>]
|
||||
|
||||
### Features
|
||||
|
||||
- ...
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- ...
|
||||
|
||||
### Performance
|
||||
|
||||
- ...
|
||||
```
|
||||
|
||||
撰写要求(对齐项目历史风格):
|
||||
- 小节标题用**英文 Title Case**:`Features` / `Bug Fixes` / `Performance` / `Documentation` / `Security` / `Miscellaneous`,**不要译成中文**;**没有内容的小节整段省略**。
|
||||
- 条目正文用**通俗友好的简体中文**,面向**普通用户**描述更新带来的直接好处,**避免技术术语**(如 refactor、merge branch、commit lint、bump deps 等)。
|
||||
- 过滤掉对用户无意义的提交(纯构建/依赖/CI/合并提交、本技能自身的脚手架改动等)。
|
||||
- 仅凭提交标题无法判断是否对用户有价值时,结合提交的完整描述正文和实际代码改动(`git show <hash>`)再决定,不要只看一行就下结论。
|
||||
- 合并相似项;每个小节内**按用户价值与影响范围排序,重要的在前**。
|
||||
|
||||
**2.3 报告**:展示新版本号与你写的 changelog 区段,请用户过目。
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 构建前端
|
||||
|
||||
```shell
|
||||
./cmd prod
|
||||
```
|
||||
|
||||
构建前端生产版本。用 `./cmd prod`,不要换成裸跑 vite(它还负责 node 检查、清 `public/js/build`、debug 切换)。
|
||||
|
||||
> **已知失败**:build 报 `public/uploads/...` 的 `EACCES: permission denied, copyfile`,是 vite 复制 `public/` 时撞到 root 属主的运行时上传文件(不限于 `tmp`,`avatar` 等都可能)。补救是赋权、不是删数据——把 uploads 属主改回当前用户后重试:
|
||||
> ```shell
|
||||
> sudo chown -R "$(id -u):$(id -g)" public/uploads
|
||||
> ```
|
||||
> `public/uploads` 是真实上传数据,**不要删**;即便要清也只清 `public/uploads/tmp`。
|
||||
|
||||
---
|
||||
|
||||
## 最终:提交并推送
|
||||
|
||||
所有步骤完成后:
|
||||
|
||||
1. 通过 `git diff` + `git status` 汇总所有变更,向用户报告摘要
|
||||
2. **询问用户是否提交并推送**
|
||||
3. 用户明确确认后才执行 `git add`、`git commit`、`git push`
|
||||
4. 未确认一律不执行
|
||||
|
||||
提交规范:
|
||||
- 提交信息使用 `release: v<新版本号>`(与历史一致,参见 `git log --oneline | grep '^release:'`)
|
||||
- **只 add 本次发布相关改动**,按文件名/目录显式添加(例如 `git add package.json CHANGELOG.md language/translate.json public/language public/js`),不要用 `git add -A` / `git add .`,以免卷入未跟踪的本地实验文件
|
||||
- 不打 git tag(现行发布流程不使用 tag)
|
||||
- 确认前先核对:`/tmp/dootask-release-translated.json` 等临时文件不在仓库内,工作区不应残留发布无关的未跟踪文件
|
||||
|
||||
## push 之后:确认发布工作流(CI 才是真正出包)
|
||||
|
||||
push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成——**push 成功 ≠ 发布完成**:
|
||||
|
||||
- **Publish**(`.github/workflows/publish.yml`,push→pro 触发)跑完才算出包;成功后会自动触发 **Sync to Gitee**(镜像同步)。
|
||||
- push 完成后**主动确认** Publish 工作流 `conclusion=success`。优先用 `gh`(未装可临时装;公开仓库也可用 GitHub REST API 免鉴权读取 runs):
|
||||
```shell
|
||||
gh run list --workflow=publish.yml -R kuaifan/dootask -L 1
|
||||
gh run view <run-id> -R kuaifan/dootask --json status,conclusion,url
|
||||
```
|
||||
- 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。
|
||||
|
||||
### iOS 发布(询问后决定)
|
||||
|
||||
`ios-publish.yml` 是**独立的手动工作流**(`workflow_dispatch`),不随 push 触发。Publish 成功后,用 options 或 AskUserQuestion 形式提问是否同时发布 iOS(选项:发布 iOS / 不发布):
|
||||
|
||||
- 选「发布 iOS」才执行:
|
||||
```shell
|
||||
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
|
||||
```
|
||||
需 `gh` 已登录且 token 含 `workflow` 权限;触发后可挂后台轮询结果。
|
||||
- 选「不发布」则结束。
|
||||
|
||||
## 失败处理
|
||||
|
||||
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。
|
||||
239
.claude/skills/dootask-release/scripts/language.php
Normal file
239
.claude/skills/dootask-release/scripts/language.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
// DooTask 发布——翻译流水线(纯本地 php,host 直接跑,不进容器、不调 OpenAI、不需 autoload)。
|
||||
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
|
||||
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因:array_multisort + json_encode
|
||||
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff(已验证 host php 可字节级复现)。
|
||||
//
|
||||
// 子命令:
|
||||
// language.php diff
|
||||
// —— 输出 JSON:needs(待翻译,key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
|
||||
// language.php apply <translated.json>
|
||||
// —— 把新翻译合并进 translate.json(追加 + 剔除冗余),不生成 public 文件
|
||||
// language.php generate
|
||||
// —— 由 translate.json 重新生成 public/language/{web,api}/*
|
||||
//
|
||||
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
$ROOT = dirname(__DIR__, 4);
|
||||
$LANG_DIR = $ROOT . '/language';
|
||||
$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'];
|
||||
|
||||
if (!is_dir($LANG_DIR)) {
|
||||
fwrite(STDERR, "未找到 language 目录($LANG_DIR)。\n");
|
||||
exit(1);
|
||||
}
|
||||
chdir($LANG_DIR);
|
||||
|
||||
$cmd = $argv[1] ?? '';
|
||||
|
||||
// ---- 公共:读取 original-*.txt ----
|
||||
function read_generateds(): array
|
||||
{
|
||||
$originals = [];
|
||||
$generateds = [];
|
||||
foreach (['web', 'api'] as $type) {
|
||||
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
|
||||
$array = array_values(array_filter(array_unique(explode("\n", $content))));
|
||||
$generateds[$type] = $array;
|
||||
$originals = array_merge($originals, $array);
|
||||
}
|
||||
return [$originals, $generateds];
|
||||
}
|
||||
|
||||
// ---- 公共:构建 translations 映射(normalizedKey -> obj),并收集冗余/占位符错乱 ----
|
||||
function build_translations(array $originals): array
|
||||
{
|
||||
$translations = [];
|
||||
$redundants = [];
|
||||
$regrror = [];
|
||||
if (!file_exists("translate.json")) {
|
||||
fwrite(STDERR, "translate.json not exists\n");
|
||||
exit(1);
|
||||
}
|
||||
$tmps = json_decode(file_get_contents("translate.json"), true);
|
||||
foreach ($tmps as $obj) {
|
||||
if (!isset($obj['key'])) {
|
||||
continue;
|
||||
}
|
||||
$currentKey = $obj['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
if (!in_array($originalKey, $originals)) {
|
||||
$redundants[$originalKey] = $obj;
|
||||
continue;
|
||||
}
|
||||
$translations[$originalKey] = $obj;
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($obj as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
$regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match];
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [$translations, $redundants, $regrror];
|
||||
}
|
||||
|
||||
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
|
||||
function generate(array $generateds, array $translations): void
|
||||
{
|
||||
foreach ($generateds as $type => $array) {
|
||||
$datas = [];
|
||||
foreach ($array as $text) {
|
||||
$text = trim($text);
|
||||
if (isset($translations[$text])) {
|
||||
$datas[] = $translations[$text];
|
||||
}
|
||||
}
|
||||
$inOrder = [];
|
||||
foreach ($datas as $index => $item) {
|
||||
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
|
||||
$inOrder[$index] = strlen($item['key']);
|
||||
} else {
|
||||
$inOrder[$index] = strlen($item['key']) + 10000000000;
|
||||
}
|
||||
}
|
||||
array_multisort($inOrder, SORT_DESC, $datas);
|
||||
$results = [];
|
||||
foreach ($datas as $items) {
|
||||
foreach ($items as $kk => $item) {
|
||||
$results[$kk][] = $item;
|
||||
}
|
||||
}
|
||||
if ($type === 'api') {
|
||||
if (!is_dir("../public/language/api")) {
|
||||
mkdir("../public/language/api", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} elseif ($type === 'web') {
|
||||
if (!is_dir("../public/language/web")) {
|
||||
mkdir("../public/language/web", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
echo "[$type] total: " . count($results['key']) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($cmd === 'diff') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
|
||||
// 需要翻译的数据(对齐 translate.php 150-169:占位符按单一计数器编号)
|
||||
$needs = [];
|
||||
foreach ($originals as $text) {
|
||||
$key = trim($text);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($translations[$key])) {
|
||||
$needs[$key] = $key;
|
||||
}
|
||||
}
|
||||
$needsOut = [];
|
||||
foreach ($needs as $key) {
|
||||
$c = 1;
|
||||
$converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
|
||||
$label = strlen($m[1]) > 1 ? "M" : "T";
|
||||
return "(%" . $label . $c++ . ")";
|
||||
}, $key);
|
||||
$needsOut[] = ['key' => $converted];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'needsCount' => count($needsOut),
|
||||
'redundantCount' => count($redundants),
|
||||
'regexErrorCount' => count($regrror),
|
||||
'needs' => $needsOut,
|
||||
'redundants' => array_keys($redundants),
|
||||
'regexErrors' => array_values($regrror),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
|
||||
if (count($regrror) > 0) {
|
||||
exit(2); // 已有数据占位符错乱,需先修复
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($cmd === 'apply') {
|
||||
$file = $argv[2] ?? '';
|
||||
if ($file === '' || !file_exists($file)) {
|
||||
fwrite(STDERR, "用法:apply <translated.json>(文件不存在)\n");
|
||||
exit(1);
|
||||
}
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations, $redundants, $regrror] = build_translations($originals);
|
||||
if (count($regrror) > 0) {
|
||||
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$incoming = json_decode(file_get_contents($file), true);
|
||||
if (!is_array($incoming)) {
|
||||
fwrite(STDERR, "translated.json 必须是数组\n");
|
||||
exit(1);
|
||||
}
|
||||
$added = 0;
|
||||
foreach ($incoming as $raw) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if (!array_key_exists($f, $raw)) {
|
||||
fwrite(STDERR, "新翻译缺字段 \"$f\":" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
// 占位符完整性:key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) {
|
||||
foreach ($m[0] as $match) {
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
if ($f === 'key' || $f === 'zh') {
|
||||
continue;
|
||||
}
|
||||
if (empty($raw[$f])) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($raw[$f], $match)) {
|
||||
fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 规范化:固定字段顺序 + zh 置空
|
||||
$item = [];
|
||||
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
|
||||
$item[$f] = $f === 'zh' ? '' : $raw[$f];
|
||||
}
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
|
||||
$translations[$originalKey] = $item;
|
||||
$added++;
|
||||
}
|
||||
|
||||
// array_values:现有条目(去冗余)在前,新条目追加在后
|
||||
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo json_encode([
|
||||
'added' => $added,
|
||||
'total' => count($translations),
|
||||
'droppedRedundant' => count($redundants),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($cmd === 'generate') {
|
||||
[$originals, $generateds] = read_generateds();
|
||||
[$translations] = build_translations($originals);
|
||||
generate($generateds, $translations);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "未知子命令:'$cmd'。可用:diff | apply <file> | generate\n");
|
||||
exit(1);
|
||||
47
.claude/skills/dootask-release/scripts/version_bump.js
vendored
Normal file
47
.claude/skills/dootask-release/scripts/version_bump.js
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
// 计算并写入新版本号到 package.json(version + codeVerson),算法对齐 bin/version.js。
|
||||
// 不生成 CHANGELOG(在技能流程内撰写),只输出版本号与 changelog 的提交区间。
|
||||
//
|
||||
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../../..');
|
||||
const pkgFile = path.join(ROOT, 'package.json');
|
||||
const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致)
|
||||
const codeOffset = 35; // 代码版本号偏移量
|
||||
|
||||
function git(cmd) {
|
||||
return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim();
|
||||
}
|
||||
|
||||
const verCount = parseInt(git('git rev-list --count HEAD'), 10);
|
||||
const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10);
|
||||
const num = verOffset + verCount;
|
||||
if (Number.isNaN(num)) {
|
||||
console.error(`版本计算失败:rev-list count=${verCount}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
|
||||
const codeVersion = codeOffset + codeCount;
|
||||
|
||||
let pkg = fs.readFileSync(pkgFile, 'utf8');
|
||||
const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || '';
|
||||
pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
|
||||
pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
|
||||
fs.writeFileSync(pkgFile, pkg, 'utf8');
|
||||
|
||||
// 上一个 release 提交作为 changelog 区间下界
|
||||
let prevReleaseCommit = '';
|
||||
try {
|
||||
prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H");
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version,
|
||||
codeVersion,
|
||||
prevVersion,
|
||||
prevReleaseCommit,
|
||||
changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)',
|
||||
}, null, 2));
|
||||
83
.claude/skills/dootask-update/SKILL.md
Normal file
83
.claude/skills/dootask-update/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: dootask-update
|
||||
description: 更新已部署的 DooTask:前置检查后执行 `sudo ./cmd update`(拉代码 + composer + 迁移 + 重启),本地有改动时停下交用户决定,不自动强制、失败即停。
|
||||
---
|
||||
|
||||
# DooTask 更新流程
|
||||
|
||||
**刚性技能**——前置检查 → 向用户确认一次 → 执行 → 报告结果。任何一步失败立即停止。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**违反字面规则 = 违反流程精神。** 不要擅自加步骤、绕过 sudo/确认,**尤其不要替用户决定强制更新**(会丢本地改动)。
|
||||
|
||||
`./cmd update` 已封装整套更新(检测本地改动→`git fetch`→必要时备份库→`git pull/reset`→`composer install`→`migrate`→重启 php+nginx→写 `UPDATE_TIME`)。本技能职责是**更新前把关、选对参数、处理本地改动这一关键岔路、执行前确认**。
|
||||
|
||||
## 前置检查(全部通过才能继续)
|
||||
|
||||
1. **已安装**:必须存在 `vendor/autoload.php`(脚本会查,没装则报"请先执行安装命令"——此时引导用户走 dootask-install)
|
||||
2. **工作目录**:在项目根
|
||||
3. **当前分支 / 目标分支**:默认更新当前分支;用户要切分支用 `--branch <分支>`。若用户没说,确认是否就更新当前分支
|
||||
4. **本地改动**(关键):`git status` 看是否有未提交改动
|
||||
5. **sudo**:`sudo ./cmd update` 需 root
|
||||
|
||||
检查通过后汇报结果,**向用户确认一次**再执行。
|
||||
|
||||
## 关键岔路:本地有改动
|
||||
|
||||
脚本检测到本地改动时会询问是否强制更新。**强制更新 = `git reset --hard origin/<分支>`,会丢弃所有本地改动。**
|
||||
|
||||
- 发现本地有改动 → **停下**,把改动清单报告用户,让**用户决定**:先提交/暂存改动,还是确认强制更新
|
||||
- **不要**替用户选 `--force`
|
||||
- 只有用户明确说"丢掉改动强制更新"时,才带 `--force`
|
||||
|
||||
## 参数选择
|
||||
|
||||
| 参数 | 作用 | 何时用 |
|
||||
|------|------|--------|
|
||||
| `--branch <分支>` | 切到指定分支再更新 | 用户要换分支(如切 `dev`/`pro`) |
|
||||
| `--force` | 强制更新:`git checkout -f` + `git reset --hard` | **危险**:仅用户明确接受"丢弃本地改动"后 |
|
||||
| `--local` | 本地更新模式:只备份库 + `migrate` + 重启,不拉远程代码 | 代码已就位(如手动改过/CI 拉过),只需迁移+重启 |
|
||||
|
||||
## 数据库
|
||||
|
||||
- 远程模式下,脚本检测到 `database/` 目录有迁移变动会**自动备份数据库**再继续——这是脚本内置的,无需手动。
|
||||
- 但若是大版本升级或用户在意数据,执行前提醒用户:本次可能含库迁移,已有自动备份兜底;如需可先 `./cmd mysql backup` 额外备份。
|
||||
|
||||
## 执行
|
||||
|
||||
确认(含本地改动决策)后执行:
|
||||
|
||||
```shell
|
||||
sudo ./cmd update
|
||||
# 切分支: sudo ./cmd update --branch pro
|
||||
# 强制(丢改动,用户确认后): sudo ./cmd update --force
|
||||
# 本地模式: sudo ./cmd update --local
|
||||
```
|
||||
|
||||
成功后报告:更新到的分支、是否做了库备份/迁移、服务是否重启完成。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 任何步骤失败立即停止,原样报告错误
|
||||
- **不要**自动重试、不要自动跳过、不要因为 `git pull` 失败就自己改成 `--force`
|
||||
- 常见失败:
|
||||
- `请先执行安装命令` → 走 dootask-install
|
||||
- `代码拉取失败,可能存在冲突` → 报告,让用户决定是否 `--force`(丢改动)或先处理冲突
|
||||
- 重启服务失败 → 脚本会尝试 `down` 后重起;若仍失败,报告交用户
|
||||
|
||||
## 禁止项
|
||||
|
||||
| 错误做法 | 正确做法 |
|
||||
|---------|---------|
|
||||
| 检测到本地改动就自动 `--force` | 停下,报告改动,交用户决定 |
|
||||
| `git pull` 失败就自动改用 `--force` | 报告冲突,交用户 |
|
||||
| 不加 sudo | `sudo ./cmd update` |
|
||||
| 未装就更新 | 先走 dootask-install |
|
||||
| 失败后自动重试/跳过 | 立即停止 |
|
||||
|
||||
## Red Flags —— 出现这些念头立即停下
|
||||
|
||||
- "有点本地改动,强制更新一下就好了" → 不,`--force` 会丢改动,必须用户拍板
|
||||
- "拉取冲突了,我 reset 一下" → 不,交用户决定
|
||||
- "已经装过了吧,直接更新" → 先确认 `vendor/autoload.php` 在
|
||||
432
.github/workflows/ios-publish.yml
vendored
Normal file
432
.github/workflows/ios-publish.yml
vendored
Normal file
@@ -0,0 +1,432 @@
|
||||
name: "iOS Publish"
|
||||
|
||||
# Required GitHub Secrets:
|
||||
#
|
||||
# IOS_CERTIFICATE_BASE64 - Apple distribution certificate (.p12) encoded in base64
|
||||
# IOS_CERTIFICATE_PASSWORD - Password for the .p12 certificate
|
||||
# IOS_PROVISION_PROFILE_BASE64 - App Store provisioning profile (.mobileprovision) encoded in base64
|
||||
# IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 - Share extension App Store provisioning profile (.mobileprovision) encoded in base64
|
||||
# ASC_API_KEY_P8_BASE64 - App Store Connect API key (.p8) encoded in base64
|
||||
# ASC_API_KEY_ID - App Store Connect API Key ID
|
||||
# ASC_ISSUER_ID - App Store Connect Issuer ID
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ios-publish-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare-assets:
|
||||
name: Prepare iOS Assets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
version: ${{ steps.get-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from package.json
|
||||
id: get-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install electron dependencies
|
||||
run: |
|
||||
pushd electron
|
||||
npm install
|
||||
popd
|
||||
|
||||
- name: Init mobile submodule
|
||||
run: |
|
||||
git submodule init
|
||||
git submodule update --remote "resources/mobile"
|
||||
|
||||
- name: Build app assets
|
||||
run: ./cmd appbuild publish
|
||||
|
||||
- name: Upload iOS platform artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-platform
|
||||
path: resources/mobile/platforms/ios/
|
||||
retention-days: 1
|
||||
|
||||
build-ios:
|
||||
name: Build & Submit iOS
|
||||
needs: prepare-assets
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
environment: build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Init mobile submodule
|
||||
run: |
|
||||
git submodule init
|
||||
git submodule update --remote "resources/mobile"
|
||||
|
||||
- name: Download prepared assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-platform
|
||||
path: resources/mobile/platforms/ios/
|
||||
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
if [ -f "resources/mobile/platforms/ios/eeuiApp/Podfile" ]; then
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
pod install
|
||||
fi
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -hex 20)
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
# Import certificate
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH"
|
||||
security import "$CERTIFICATE_PATH" \
|
||||
-P "$IOS_CERTIFICATE_PASSWORD" \
|
||||
-A \
|
||||
-t cert \
|
||||
-f pkcs12 \
|
||||
-k "$KEYCHAIN_PATH"
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Import provisioning profile
|
||||
env:
|
||||
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
|
||||
IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
APP_PROFILE_PATH=$RUNNER_TEMP/app.mobileprovision
|
||||
SHARE_PROFILE_PATH=$RUNNER_TEMP/share-extension.mobileprovision
|
||||
APP_PROFILE_PLIST=$RUNNER_TEMP/app-profile.plist
|
||||
SHARE_PROFILE_PLIST=$RUNNER_TEMP/share-extension-profile.plist
|
||||
|
||||
echo "$IOS_PROVISION_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH"
|
||||
echo "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > "$SHARE_PROFILE_PATH"
|
||||
|
||||
security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST"
|
||||
security cms -D -i "$SHARE_PROFILE_PATH" > "$SHARE_PROFILE_PLIST"
|
||||
|
||||
APP_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$APP_PROFILE_PLIST")
|
||||
SHARE_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$SHARE_PROFILE_PLIST")
|
||||
IOS_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" "$APP_PROFILE_PLIST")
|
||||
APP_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$APP_PROFILE_PLIST")
|
||||
SHARE_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$SHARE_PROFILE_PLIST")
|
||||
|
||||
if [ "$APP_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task" ]; then
|
||||
echo "Expected app profile for $IOS_TEAM_ID.com.dootask.task, got $APP_PROFILE_APP_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SHARE_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task.shareExtension" ]; then
|
||||
echo "Expected share extension profile for $IOS_TEAM_ID.com.dootask.task.shareExtension, got $SHARE_PROFILE_APP_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$APP_PROFILE_PLIST" >/dev/null; then
|
||||
echo "The DooTask app profile must include Push Notifications."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$APP_PROFILE_PLIST" | grep -q "group.im.dootask"; then
|
||||
echo "The DooTask app profile must include App Group group.im.dootask."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$SHARE_PROFILE_PLIST" | grep -q "group.im.dootask"; then
|
||||
echo "The share extension profile must include App Group group.im.dootask."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp "$APP_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
cp "$SHARE_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
|
||||
echo "APP_PROFILE_NAME=$APP_PROFILE_NAME" >> $GITHUB_ENV
|
||||
echo "SHARE_PROFILE_NAME=$SHARE_PROFILE_NAME" >> $GITHUB_ENV
|
||||
echo "IOS_TEAM_ID=$IOS_TEAM_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Configure manual signing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ruby <<'RUBY'
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = 'resources/mobile/platforms/ios/eeuiApp/eeuiApp.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
{
|
||||
'DooTask' => ENV.fetch('APP_PROFILE_NAME'),
|
||||
'ShareExtension' => ENV.fetch('SHARE_PROFILE_NAME')
|
||||
}.each do |target_name, profile_name|
|
||||
target = project.targets.find { |item| item.name == target_name }
|
||||
abort "Target #{target_name} not found in #{project_path}" unless target
|
||||
|
||||
target.build_configurations.each do |config|
|
||||
next unless config.name == 'Release'
|
||||
|
||||
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
|
||||
config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('IOS_TEAM_ID')
|
||||
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
|
||||
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile_name
|
||||
end
|
||||
end
|
||||
|
||||
project.save
|
||||
RUBY
|
||||
|
||||
- name: Resolve iOS build number
|
||||
env:
|
||||
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ruby <<'RUBY'
|
||||
require 'base64'
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'openssl'
|
||||
require 'uri'
|
||||
|
||||
BUNDLE_ID = 'com.dootask.task'
|
||||
VERSION_CONFIG_PATH = 'resources/mobile/platforms/ios/eeuiApp/Config/Version.xcconfig'
|
||||
|
||||
def base64url(value)
|
||||
Base64.urlsafe_encode64(value).delete('=')
|
||||
end
|
||||
|
||||
def jwt_es256_signature(private_key, unsigned)
|
||||
der_signature = private_key.sign('SHA256', unsigned)
|
||||
sequence = OpenSSL::ASN1.decode(der_signature)
|
||||
|
||||
sequence.value.map { |integer|
|
||||
integer.value.to_s(2).rjust(32, "\0")[-32, 32]
|
||||
}.join
|
||||
end
|
||||
|
||||
def asc_token
|
||||
key_id = ENV.fetch('ASC_API_KEY_ID')
|
||||
issuer_id = ENV.fetch('ASC_ISSUER_ID')
|
||||
private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64')))
|
||||
now = Time.now.to_i
|
||||
|
||||
header = { alg: 'ES256', kid: key_id, typ: 'JWT' }
|
||||
payload = {
|
||||
iss: issuer_id,
|
||||
iat: now,
|
||||
exp: now + 20 * 60,
|
||||
aud: 'appstoreconnect-v1'
|
||||
}
|
||||
|
||||
unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}"
|
||||
signature = jwt_es256_signature(private_key, unsigned)
|
||||
"#{unsigned}.#{base64url(signature)}"
|
||||
end
|
||||
|
||||
def asc_get(path, params, token)
|
||||
uri = URI::HTTPS.build(
|
||||
host: 'api.appstoreconnect.apple.com',
|
||||
path: path,
|
||||
query: URI.encode_www_form(params)
|
||||
)
|
||||
|
||||
request_uri = uri
|
||||
loop do
|
||||
response = Net::HTTP.start(request_uri.host, request_uri.port, use_ssl: true) do |http|
|
||||
request = Net::HTTP::Get.new(request_uri)
|
||||
request['Authorization'] = "Bearer #{token}"
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
abort "App Store Connect API request failed: #{response.code} #{response.body}"
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
yield parsed
|
||||
|
||||
next_link = parsed.dig('links', 'next')
|
||||
break unless next_link
|
||||
|
||||
request_uri = URI(next_link)
|
||||
end
|
||||
end
|
||||
|
||||
token = asc_token
|
||||
app_id = nil
|
||||
|
||||
asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page|
|
||||
app_id = page.fetch('data').first&.fetch('id')
|
||||
end
|
||||
|
||||
abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id
|
||||
|
||||
existing_versions = []
|
||||
asc_get('/v1/builds', {
|
||||
'filter[app]' => app_id,
|
||||
'fields[builds]' => 'version',
|
||||
'limit' => 200
|
||||
}, token) do |page|
|
||||
existing_versions.concat(
|
||||
page.fetch('data').map { |build| build.dig('attributes', 'version').to_s }
|
||||
)
|
||||
end
|
||||
|
||||
max_build_number = existing_versions
|
||||
.select { |version| version.match?(/\A\d+\z/) }
|
||||
.map(&:to_i)
|
||||
.max || 0
|
||||
|
||||
next_build_number = max_build_number + 1
|
||||
config_content = File.exist?(VERSION_CONFIG_PATH) ? File.read(VERSION_CONFIG_PATH) : ''
|
||||
|
||||
if config_content.match?(/^VERSION_CODE\s*=/)
|
||||
config_content = config_content.gsub(/^VERSION_CODE\s*=.*$/, "VERSION_CODE = #{next_build_number}")
|
||||
else
|
||||
config_content = "#{config_content.rstrip}\nVERSION_CODE = #{next_build_number}\n"
|
||||
end
|
||||
|
||||
File.write(VERSION_CONFIG_PATH, config_content)
|
||||
File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build_number}" }
|
||||
|
||||
puts "Latest App Store Connect build number: #{max_build_number}"
|
||||
puts "Resolved iOS build number: #{next_build_number}"
|
||||
RUBY
|
||||
|
||||
- name: Build archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
xcodebuild archive \
|
||||
-workspace eeuiApp.xcworkspace \
|
||||
-scheme eeuiApp \
|
||||
-configuration Release \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
|
||||
-allowProvisioningUpdates \
|
||||
DEVELOPMENT_TEAM=$IOS_TEAM_ID \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
| xcpretty
|
||||
|
||||
if [ ! -d "$RUNNER_TEMP/eeuiApp.xcarchive" ]; then
|
||||
echo "Archive was not created at $RUNNER_TEMP/eeuiApp.xcarchive"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Export IPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd resources/mobile/platforms/ios/eeuiApp
|
||||
|
||||
# Generate ExportOptions.plist
|
||||
cat > $RUNNER_TEMP/ExportOptions.plist << PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>teamID</key>
|
||||
<string>${IOS_TEAM_ID}</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.dootask.task</key>
|
||||
<string>${APP_PROFILE_NAME}</string>
|
||||
<key>com.dootask.task.shareExtension</key>
|
||||
<string>${SHARE_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath $RUNNER_TEMP/eeuiApp.xcarchive \
|
||||
-exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \
|
||||
-exportPath $RUNNER_TEMP/ipa-output \
|
||||
-allowProvisioningUpdates \
|
||||
| xcpretty
|
||||
|
||||
- name: Submit to App Store Connect
|
||||
env:
|
||||
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Prepare API key
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > ~/private_keys/AuthKey_${ASC_API_KEY_ID}.p8
|
||||
|
||||
# Find and upload IPA
|
||||
IPA_PATH=$(find $RUNNER_TEMP/ipa-output -name "*.ipa" | head -1)
|
||||
if [ -z "$IPA_PATH" ]; then
|
||||
echo "No IPA file found in $RUNNER_TEMP/ipa-output"
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploading: $IPA_PATH"
|
||||
|
||||
xcrun altool --upload-app \
|
||||
-f "$IPA_PATH" \
|
||||
--type ios \
|
||||
--apiKey "$ASC_API_KEY_ID" \
|
||||
--apiIssuer "$ASC_ISSUER_ID"
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true
|
||||
rm -f $RUNNER_TEMP/certificate.p12
|
||||
rm -f $RUNNER_TEMP/app.mobileprovision
|
||||
rm -f $RUNNER_TEMP/share-extension.mobileprovision
|
||||
rm -f $RUNNER_TEMP/app-profile.plist
|
||||
rm -f $RUNNER_TEMP/share-extension-profile.plist
|
||||
rm -rf ~/private_keys
|
||||
89
.github/workflows/publish.yml
vendored
89
.github/workflows/publish.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "pro"
|
||||
- "dev"
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
@@ -53,53 +52,18 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// 获取最新的 tag
|
||||
const { data: tags } = await github.rest.repos.listTags({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 1
|
||||
});
|
||||
const fs = require('fs');
|
||||
const version = '${{ needs.check-version.outputs.version }}';
|
||||
|
||||
// 获取提交日志
|
||||
// 从 CHANGELOG.md 提取当前版本段落
|
||||
let changelog = '';
|
||||
if (tags.length > 0) {
|
||||
const { data: commits } = await github.rest.repos.compareCommits({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
base: tags[0].name,
|
||||
head: 'HEAD'
|
||||
});
|
||||
|
||||
// 按类型分组提交
|
||||
const groups = {
|
||||
'feat:': { title: '## Features', commits: new Set() },
|
||||
'fix:': { title: '## Bug Fixes', commits: new Set() },
|
||||
'perf:': { title: '## Performance Improvements', commits: new Set() }
|
||||
};
|
||||
|
||||
// 分类收集提交,使用 Set 去重
|
||||
commits.commits.forEach(commit => {
|
||||
const message = commit.commit.message.split('\n')[0].trim();
|
||||
for (const [prefix, group] of Object.entries(groups)) {
|
||||
if (message.startsWith(prefix)) {
|
||||
// 移除前缀后添加到对应分组
|
||||
const cleanMessage = message.slice(prefix.length).trim();
|
||||
group.commits.add(cleanMessage); // 使用 Set.add 自动去重
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 生成更新日志
|
||||
const sections = [];
|
||||
for (const group of Object.values(groups)) {
|
||||
if (group.commits.size > 0) {
|
||||
sections.push(`${group.title}\n\n${Array.from(group.commits).map(msg => `- ${msg}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
changelog = '# Changelog\n\n' + sections.join('\n\n');
|
||||
const changelogPath = 'CHANGELOG.md';
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
const content = fs.readFileSync(changelogPath, 'utf-8');
|
||||
const regex = new RegExp(`## \\[${version.replace(/\./g, '\\.')}\\][\\s\\S]*?(?=\\n## \\[|$)`);
|
||||
const match = content.match(regex);
|
||||
if (match) {
|
||||
changelog = match[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +71,8 @@ jobs:
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${{ needs.check-version.outputs.version }}`,
|
||||
name: `${{ needs.check-version.outputs.version }}`,
|
||||
tag_name: `v${version}`,
|
||||
name: version,
|
||||
body: changelog || 'No significant changes in this release.',
|
||||
draft: true,
|
||||
prerelease: false
|
||||
@@ -216,7 +180,11 @@ jobs:
|
||||
- name: (Android) Upload File
|
||||
if: matrix.build_type == 'android'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
run: |
|
||||
node ./electron/build.js android-upload
|
||||
|
||||
@@ -254,7 +222,11 @@ jobs:
|
||||
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
@@ -264,7 +236,11 @@ jobs:
|
||||
- name: (Windows) Build Client
|
||||
if: matrix.build_type == 'windows'
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
@@ -295,11 +271,16 @@ jobs:
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
- name: Publish Official
|
||||
- name: Upload Changelog & Publish to Website
|
||||
env:
|
||||
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
run: |
|
||||
pushd electron || exit
|
||||
npm install
|
||||
popd || exit
|
||||
node ./electron/build.js published
|
||||
node ./electron/build.js upload-changelog
|
||||
node ./electron/build.js release
|
||||
|
||||
45
.github/workflows/sync-gitee.yml
vendored
Normal file
45
.github/workflows/sync-gitee.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "Sync to Gitee"
|
||||
|
||||
# Required GitHub Secrets:
|
||||
#
|
||||
# GITEE_SSH_PRIVATE_KEY - SSH private key with push access to gitee.com/aipaw/dootask
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Push to Gitee
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup SSH key
|
||||
env:
|
||||
GITEE_SSH_PRIVATE_KEY: ${{ secrets.GITEE_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$GITEE_SSH_PRIVATE_KEY" > ~/.ssh/gitee_key
|
||||
chmod 600 ~/.ssh/gitee_key
|
||||
cat >> ~/.ssh/config << EOF
|
||||
Host gitee.com
|
||||
HostName gitee.com
|
||||
IdentityFile ~/.ssh/gitee_key
|
||||
StrictHostKeyChecking no
|
||||
EOF
|
||||
|
||||
- name: Push to Gitee
|
||||
run: |
|
||||
git remote add gitee git@gitee.com:aipaw/dootask.git
|
||||
git push gitee pro
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: rm -rf ~/.ssh/gitee_key
|
||||
57
.gitignore
vendored
57
.gitignore
vendored
@@ -1,32 +1,67 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/vendor
|
||||
|
||||
# Build and temporary files
|
||||
/build
|
||||
/public/hot
|
||||
/public/tmp
|
||||
/tmp
|
||||
/backup
|
||||
|
||||
# Uploads and user-generated content
|
||||
/public/summary
|
||||
/public/uploads/*
|
||||
/public/.well-known
|
||||
/public/.user.ini
|
||||
/storage/*.key
|
||||
|
||||
# Storage and configuration
|
||||
/config/LICENSE
|
||||
/vendor
|
||||
/build
|
||||
/tmp
|
||||
._*
|
||||
/storage/*.key
|
||||
|
||||
# Environment and configuration
|
||||
.env
|
||||
vars.yaml
|
||||
|
||||
# IDE and editor files
|
||||
.cursor/*
|
||||
!.cursor/rules/
|
||||
!.cursor/rules/**
|
||||
.idea
|
||||
.vscode
|
||||
.vagrant
|
||||
.windsurfrules
|
||||
.phpunit.result.cache
|
||||
|
||||
# Development tools
|
||||
.vagrant
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
# Development file
|
||||
/index.html
|
||||
|
||||
# Testing
|
||||
.phpunit.result.cache
|
||||
test.*
|
||||
|
||||
# Logs and debug files
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
test.*
|
||||
|
||||
# Lock files
|
||||
dootask.lock
|
||||
package-lock.json
|
||||
|
||||
# Laravel/Swoole specific
|
||||
laravels-timer-process.pid
|
||||
.DS_Store
|
||||
vars.yaml
|
||||
laravels.conf
|
||||
laravels.pid
|
||||
|
||||
# System files
|
||||
._*
|
||||
.DS_Store
|
||||
|
||||
# Documentation
|
||||
README_LOCAL.md
|
||||
dootask.lock
|
||||
|
||||
# playwright
|
||||
.playwright-mcp/
|
||||
|
||||
13
.gitpod.yml
13
.gitpod.yml
@@ -1,13 +0,0 @@
|
||||
# This configuration file was automatically generated by Gitpod.
|
||||
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
|
||||
# and commit this file to your remote git repository to share the goodness with others.
|
||||
|
||||
tasks:
|
||||
- init: sudo ./cmd install
|
||||
command: ./cmd dev
|
||||
|
||||
ports:
|
||||
- port: 2222
|
||||
visibility: public
|
||||
- port: 22222
|
||||
visibility: public
|
||||
4305
CHANGELOG.md
4305
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
62
CLAUDE.md
Normal file
62
CLAUDE.md
Normal file
@@ -0,0 +1,62 @@
|
||||
## 项目概述
|
||||
|
||||
Laravel 8 (LaravelS/Swoole) + Vue 2 (Vite) + Electron。开源任务/项目管理系统。
|
||||
|
||||
## 开发命令
|
||||
|
||||
所有命令通过 `./cmd` 脚本执行(不要直接运行 `php artisan` 等):
|
||||
|
||||
- `./cmd artisan ...` / `./cmd composer ...` / `./cmd php ...` — PHP 相关命令
|
||||
|
||||
### AI 不要主动执行的命令
|
||||
|
||||
以下命令仅由用户人工触发,AI 不要主动跑——包括"任务完成后 sanity check"、"看下能不能编译"等场景:
|
||||
|
||||
- `./cmd dev` — 用户已自行运行 dev server,改完会自己 reload;AI 再跑会争抢进程
|
||||
- `./cmd prod` / `./cmd build` — 发版才用,走 `/release` 流程
|
||||
|
||||
前端代码改动只做 Edit/Write,不要为了"验证"启动 dev server。用户明确说"跑一下 / 出包"时除外。
|
||||
|
||||
## Gotchas
|
||||
|
||||
### LaravelS/Swoole
|
||||
|
||||
- **避免在静态属性、单例、全局变量中存储请求级状态**——请求间共享进程,会导致数据串联和内存泄漏
|
||||
- 构造函数、服务提供者、`boot()` 方法不会在每个请求重新执行
|
||||
- 配置/路由变更需要 `./cmd php restart` 或容器重启才能生效
|
||||
- 长生命周期逻辑(WebSocket、定时器)应复用现有模式,避免阻塞协程/事件循环
|
||||
|
||||
### 后端
|
||||
|
||||
- **非 REST 路由**:API 控制器(继承 `InvokeController`)在 `routes/web.php` 按资源注册路由,URL 段映射为控制器方法(如 `api/project/lists` → `lists()`,带 action 则用双下划线:`api/project/invite/join` → `invite__join()`)
|
||||
- 路由最多两段:方法名最多一个双下划线(`method__action`),不支持 `method__action__xxx`(无对应路由,访问 404)
|
||||
- **响应格式**:统一使用 `Base::retSuccess($msg, $data)` / `Base::retError($msg)`,返回 `{"ret": 1, "msg": "...", "data": {...}}`——不要用 `response()->json()`
|
||||
- 业务异常通过 `App\Exceptions\ApiException` 抛出,不要用通用 Exception
|
||||
- 模型继承 `AbstractModel`,使用 `Model::createInstance($params)` 创建——不要用 `new Model()` 或 `Model::create()`
|
||||
- 认证使用 `Doo::userId()`——不要用 `auth()->user()`
|
||||
- 参数校验在控制器方法中手动进行——不要创建 FormRequest 类
|
||||
- 异步任务使用 Swoole Task(`app/Tasks/`)——不要用 Laravel Queue
|
||||
- `app/Module/` 存放跨控制器/跨模型的业务逻辑(非标准 Laravel 目录)
|
||||
- 所有表结构变更必须通过 Laravel migration,禁止直接改库
|
||||
|
||||
### 前端
|
||||
|
||||
- API 调用使用 `store.dispatch("call", params)`,不要在组件中直接 axios/fetch
|
||||
- `$A.modalXXX`、`$A.messageXXX`、`$A.noticeXXX` 内部自动处理 `$L` 翻译,调用方不要额外包 `$L`。仅当传入 `language: false` 时由调用方自行处理翻译
|
||||
|
||||
### 国际化
|
||||
|
||||
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
|
||||
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
|
||||
|
||||
## Playwright 测试
|
||||
|
||||
- Playwright 测试结果放在 `tests/playwright-results/`,包含测试环境、测试用例、结果截图等信息
|
||||
|
||||
## 交互规范
|
||||
|
||||
- **提问时附带建议**:当需要向用户提问或请求澄清时,应同时提供具体的建议选项或推荐方案,帮助用户快速决策,而非仅抛出开放式问题
|
||||
|
||||
## 语言偏好
|
||||
|
||||
- 回复一律使用简体中文,除非用户明确要求其他语言
|
||||
@@ -22,6 +22,7 @@ English | **[中文文档](./README_CN.md)**
|
||||
- Required: `Docker v20.10+` and `Docker Compose v2.0+`
|
||||
- Supported Systems: `CentOS/Debian/Ubuntu/macOS` and other Linux/Unix systems
|
||||
- Hardware Recommendation: 2+ cores, 4GB+ memory
|
||||
- Database: MariaDB (provided by the default Docker Compose `mariadb` service)
|
||||
- Special Note: Windows users can install Linux environment using WSL2 before installing DooTask.
|
||||
|
||||
### Deploy Project
|
||||
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
After installing the new project, follow these steps to complete migration:
|
||||
|
||||
1、Backup original database
|
||||
1、Backup the MariaDB database
|
||||
|
||||
```bash
|
||||
# Run command in the old project
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` is the CLI subcommand name; backups run against the MariaDB container.
|
||||
|
||||
2、Copy the following files and directories from old project to the same paths in new project
|
||||
|
||||
- `Database backup file`
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- 必须安装:`Docker v20.10+` 和 `Docker Compose v2.0+`
|
||||
- 支持环境:`Centos/Debian/Ubuntu/macOS` 等 linux/unix 系统
|
||||
- 硬件建议:2核4G以上
|
||||
- 数据库:MariaDB(默认 Docker Compose 中的 `mariadb` 服务)
|
||||
- 特别说明:Windows 可以使用 WSL2 安装 Linux 环境后再安装 DooTask。
|
||||
|
||||
### 部署项目
|
||||
@@ -115,13 +116,15 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
在新项目安装好之后按照以下步骤完成项目迁移:
|
||||
|
||||
1、备份原数据库
|
||||
1、备份 MariaDB 数据库
|
||||
|
||||
```bash
|
||||
# 在旧的项目下执行指令
|
||||
./cmd mysql backup
|
||||
```
|
||||
|
||||
> `./cmd mysql` 为 CLI 子命令名称,实际操作的是 MariaDB 容器。
|
||||
|
||||
2、将旧项目以下文件和目录拷贝至新项目同路径位置
|
||||
|
||||
- `数据库备份文件`
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
## 发布版本
|
||||
|
||||
> 翻译、版本号、更新日志改由 `dootask-release` 技能完成(见 `.claude/skills/dootask-release/`)。
|
||||
|
||||
```shell
|
||||
npm run translate # 翻译(可选)
|
||||
npm run version # 生成版本
|
||||
npm run build # 编译前端
|
||||
```
|
||||
|
||||
|
||||
205
app/Console/Commands/GenerateManticoreVectors.php
Normal file
205
app/Console/Commands/GenerateManticoreVectors.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\File;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 异步向量生成命令
|
||||
*
|
||||
* 用于后台批量生成已索引数据的向量,与全文索引解耦
|
||||
* 使用双指针追踪:sync:xxxLastId(全文已同步)和 vector:xxxLastId(向量已生成)
|
||||
*
|
||||
* 运行模式:
|
||||
* - 持续处理直到所有待处理数据完成
|
||||
* - 每批处理完成后休眠几秒,避免 API 过载
|
||||
* - 定时器只作为兜底触发机制
|
||||
*/
|
||||
class GenerateManticoreVectors extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
protected $signature = 'manticore:generate-vectors
|
||||
{--type=all : 类型 (msg/file/task/project/user/all)}
|
||||
{--batch=50 : 每批 embedding 数量}
|
||||
{--sleep=3 : 每批处理后休眠秒数}
|
||||
{--reset : 重置向量进度指针}';
|
||||
|
||||
protected $description = '批量生成 Manticore 已索引数据的向量';
|
||||
|
||||
/**
|
||||
* 类型配置
|
||||
*/
|
||||
private const TYPE_CONFIG = [
|
||||
'msg' => [
|
||||
'syncKey' => 'sync:manticoreMsgLastId',
|
||||
'vectorKey' => 'vector:manticoreMsgLastId',
|
||||
'class' => ManticoreMsg::class,
|
||||
'model' => WebSocketDialogMsg::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'file' => [
|
||||
'syncKey' => 'sync:manticoreFileLastId',
|
||||
'vectorKey' => 'vector:manticoreFileLastId',
|
||||
'class' => ManticoreFile::class,
|
||||
'model' => File::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'task' => [
|
||||
'syncKey' => 'sync:manticoreTaskLastId',
|
||||
'vectorKey' => 'vector:manticoreTaskLastId',
|
||||
'class' => ManticoreTask::class,
|
||||
'model' => ProjectTask::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'project' => [
|
||||
'syncKey' => 'sync:manticoreProjectLastId',
|
||||
'vectorKey' => 'vector:manticoreProjectLastId',
|
||||
'class' => ManticoreProject::class,
|
||||
'model' => Project::class,
|
||||
'idField' => 'id',
|
||||
],
|
||||
'user' => [
|
||||
'syncKey' => 'sync:manticoreUserLastId',
|
||||
'vectorKey' => 'vector:manticoreUserLastId',
|
||||
'class' => ManticoreUser::class,
|
||||
'model' => User::class,
|
||||
'idField' => 'userid',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("ai")) {
|
||||
$this->error("应用「AI」未安装,无法生成向量");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$type = $this->option('type');
|
||||
$batchSize = intval($this->option('batch'));
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$reset = $this->option('reset');
|
||||
|
||||
if ($type === 'all') {
|
||||
$types = array_keys(self::TYPE_CONFIG);
|
||||
} else {
|
||||
if (!isset(self::TYPE_CONFIG[$type])) {
|
||||
$this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all");
|
||||
$this->releaseLock();
|
||||
return 1;
|
||||
}
|
||||
$types = [$type];
|
||||
}
|
||||
|
||||
// 持续处理直到所有类型都没有待处理数据
|
||||
$round = 0;
|
||||
do {
|
||||
$round++;
|
||||
$totalPending = 0;
|
||||
|
||||
foreach ($types as $t) {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
$pending = $this->processType($t, $batchSize, $reset && $round === 1);
|
||||
$totalPending += $pending;
|
||||
}
|
||||
|
||||
// 如果还有待处理数据,休眠后继续
|
||||
if ($totalPending > 0 && !$this->shouldStop) {
|
||||
$this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n");
|
||||
sleep($sleepSeconds);
|
||||
$this->setLock(); // 刷新锁
|
||||
}
|
||||
} while ($totalPending > 0 && !$this->shouldStop);
|
||||
|
||||
$this->info("\n向量生成完成(共 {$round} 轮)");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个类型的向量生成(每次处理一批)
|
||||
*
|
||||
* @param string $type 类型
|
||||
* @param int $batchSize 每批数量
|
||||
* @param bool $reset 是否重置进度
|
||||
* @return int 剩余待处理数量
|
||||
*/
|
||||
private function processType(string $type, int $batchSize, bool $reset): int
|
||||
{
|
||||
$config = self::TYPE_CONFIG[$type];
|
||||
|
||||
// 获取进度指针
|
||||
$syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0));
|
||||
$vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0));
|
||||
|
||||
if ($reset) {
|
||||
ManticoreKeyValue::set($config['vectorKey'], 0);
|
||||
$this->info("[{$type}] 已重置向量进度指针");
|
||||
}
|
||||
|
||||
// 计算待处理范围
|
||||
$pendingCount = $syncLastId - $vectorLastId;
|
||||
if ($pendingCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API)
|
||||
$modelClass = $config['model'];
|
||||
$idField = $config['idField'];
|
||||
$fetchCount = $batchSize * 5;
|
||||
|
||||
$ids = $modelClass::where($idField, '>', $vectorLastId)
|
||||
->where($idField, '<=', $syncLastId)
|
||||
->orderBy($idField)
|
||||
->limit($fetchCount)
|
||||
->pluck($idField)
|
||||
->toArray();
|
||||
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 批量生成向量
|
||||
$manticoreClass = $config['class'];
|
||||
$successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize);
|
||||
|
||||
$currentLastId = end($ids);
|
||||
|
||||
// 更新向量进度指针
|
||||
ManticoreKeyValue::set($config['vectorKey'], $currentLastId);
|
||||
|
||||
$remaining = $pendingCount - count($ids);
|
||||
$this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount},ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}");
|
||||
|
||||
// 刷新锁
|
||||
$this->setLock();
|
||||
|
||||
return max(0, $remaining);
|
||||
}
|
||||
}
|
||||
188
app/Console/Commands/RetryManticoreSync.php
Normal file
188
app/Console/Commands/RetryManticoreSync.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\File;
|
||||
use App\Models\ManticoreSyncFailure;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreBase;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RetryManticoreSync extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
protected $signature = 'manticore:retry-failures {--limit=100 : 每次处理的最大数量} {--stats : 显示统计信息}';
|
||||
protected $description = '重试 Manticore 同步失败的记录';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 显示统计信息
|
||||
if ($this->option('stats')) {
|
||||
$this->showStats();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('开始重试失败的同步任务...');
|
||||
|
||||
$limit = intval($this->option('limit'));
|
||||
$failures = ManticoreSyncFailure::getPendingRetries($limit);
|
||||
|
||||
if ($failures->isEmpty()) {
|
||||
$this->info('无待重试的记录');
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("找到 {$failures->count()} 条待重试记录");
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($failures as $failure) {
|
||||
if ($this->shouldStop) {
|
||||
$this->info('收到停止信号,退出处理');
|
||||
break;
|
||||
}
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$result = $this->retryOne($failure);
|
||||
|
||||
if ($result) {
|
||||
$successCount++;
|
||||
$this->info(" [成功] {$failure->data_type}:{$failure->data_id} ({$failure->action})");
|
||||
} else {
|
||||
$failCount++;
|
||||
$this->warn(" [失败] {$failure->data_type}:{$failure->data_id} ({$failure->action}) - 第 {$failure->retry_count} 次");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("\n重试完成: 成功 {$successCount}, 失败 {$failCount}");
|
||||
$this->releaseLock();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试单条失败记录
|
||||
*/
|
||||
private function retryOne(ManticoreSyncFailure $failure): bool
|
||||
{
|
||||
$type = $failure->data_type;
|
||||
$id = $failure->data_id;
|
||||
$action = $failure->action;
|
||||
|
||||
try {
|
||||
if ($action === 'delete') {
|
||||
// 删除操作直接调用通用删除方法
|
||||
return ManticoreBase::deleteVector($type, $id);
|
||||
}
|
||||
|
||||
// sync 操作需要根据类型获取模型并同步
|
||||
return $this->retrySyncByType($type, $id);
|
||||
} catch (\Throwable $e) {
|
||||
// 记录失败(会自动更新重试次数和时间)
|
||||
ManticoreSyncFailure::recordFailure($type, $id, $action, $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型重试同步
|
||||
*/
|
||||
private function retrySyncByType(string $type, int $id): bool
|
||||
{
|
||||
switch ($type) {
|
||||
case 'msg':
|
||||
$model = WebSocketDialogMsg::find($id);
|
||||
if (!$model) {
|
||||
// 数据已删除,移除失败记录
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreMsg::sync($model);
|
||||
|
||||
case 'file':
|
||||
$model = File::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreFile::sync($model);
|
||||
|
||||
case 'task':
|
||||
$model = ProjectTask::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreTask::sync($model);
|
||||
|
||||
case 'project':
|
||||
$model = Project::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreProject::sync($model);
|
||||
|
||||
case 'user':
|
||||
$model = User::find($id);
|
||||
if (!$model) {
|
||||
ManticoreSyncFailure::removeSuccess($type, $id, 'sync');
|
||||
return true;
|
||||
}
|
||||
return ManticoreUser::sync($model);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示统计信息
|
||||
*/
|
||||
private function showStats(): void
|
||||
{
|
||||
$stats = ManticoreSyncFailure::getStats();
|
||||
|
||||
$this->info('Manticore 同步失败统计:');
|
||||
$this->info(" 总数: {$stats['total']}");
|
||||
|
||||
if (!empty($stats['by_type'])) {
|
||||
$this->info(' 按类型:');
|
||||
foreach ($stats['by_type'] as $type => $count) {
|
||||
$this->info(" - {$type}: {$count}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($stats['by_action'])) {
|
||||
$this->info(' 按操作:');
|
||||
foreach ($stats['by_action'] as $action => $count) {
|
||||
$this->info(" - {$action}: {$count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/Console/Commands/SyncFileToManticore.php
Normal file
155
app/Console/Commands/SyncFileToManticore.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\File;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncFileToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-files {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步文件数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 清除索引
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreKeyValue::clear();
|
||||
ManticoreFile::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步文件数据...');
|
||||
$this->syncFiles();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步文件数据
|
||||
*/
|
||||
private function syncFiles(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreFileLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
$maxFileSize = ManticoreFile::getMaxFileSize();
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步文件数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步文件数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = File::where('id', '>', $lastId)
|
||||
->where('type', '!=', 'folder')
|
||||
->where('size', '<=', $maxFileSize)
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个文件");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$files = File::where('id', '>', $lastId)
|
||||
->where('type', '!=', 'folder')
|
||||
->where('size', '<=', $maxFileSize)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($files);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
$this->info("{$num}/{$count} ({$progress}%) 文件ID {$files->first()->id} ~ {$files->last()->id}");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$syncCount = ManticoreFile::batchSync($files);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $files->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($files) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = File::where('id', '>', $lastId)
|
||||
->where('type', '!=', 'folder')
|
||||
->where('size', '<=', $maxFileSize)
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新文件,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步文件结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引文件数量: " . ManticoreFile::getIndexedCount());
|
||||
}
|
||||
}
|
||||
232
app/Console/Commands/SyncMsgToManticore.php
Normal file
232
app/Console/Commands/SyncMsgToManticore.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncMsgToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --dialog: 指定对话ID
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-msgs {--f} {--i} {--c} {--batch=100} {--dialog=} {--sleep=3}';
|
||||
protected $description = '同步消息数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 清除索引
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreMsg::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dialogId = $this->option('dialog') ? intval($this->option('dialog')) : 0;
|
||||
|
||||
if ($dialogId > 0) {
|
||||
$this->info("开始同步对话 {$dialogId} 的消息数据...");
|
||||
$this->syncDialogMsgs($dialogId);
|
||||
} else {
|
||||
$this->info('开始同步消息数据...');
|
||||
$this->syncMsgs();
|
||||
}
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步所有消息
|
||||
*/
|
||||
private function syncMsgs(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreMsgLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
// 持续处理循环(增量模式下)
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步消息数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步消息数据...");
|
||||
}
|
||||
}
|
||||
|
||||
// 构建基础查询条件
|
||||
$count = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 条消息");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$msgs = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($msgs->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($msgs);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
$this->info("{$num}/{$count} ({$progress}%) 消息ID {$msgs->first()->id} ~ {$msgs->last()->id}");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$syncCount = ManticoreMsg::batchSync($msgs);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $msgs->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($msgs) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 条,最后ID {$lastId}");
|
||||
|
||||
// 增量模式下,检查是否有新数据,有则继续
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 条新数据,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break; // 非增量模式或无新数据,退出循环
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步消息结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引消息数量: " . ManticoreMsg::getIndexedCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步指定对话的消息
|
||||
*
|
||||
* @param int $dialogId 对话ID
|
||||
*/
|
||||
private function syncDialogMsgs(int $dialogId): void
|
||||
{
|
||||
$this->info("\n同步对话 {$dialogId} 的消息数据...");
|
||||
|
||||
$baseQuery = WebSocketDialogMsg::where('dialog_id', $dialogId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES);
|
||||
|
||||
$num = 0;
|
||||
$count = $baseQuery->count();
|
||||
$batchSize = $this->option('batch');
|
||||
$lastId = 0;
|
||||
|
||||
$total = 0;
|
||||
$lastNum = 0;
|
||||
|
||||
do {
|
||||
$msgs = WebSocketDialogMsg::where('dialog_id', $dialogId)
|
||||
->where('id', '>', $lastId)
|
||||
->whereNull('deleted_at')
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->whereIn('type', ManticoreMsg::INDEXABLE_TYPES)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($msgs->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($msgs);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
if ($progress < 100) {
|
||||
$progress = number_format($progress, 2);
|
||||
}
|
||||
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$msgs->first()->id} ~ {$msgs->last()->id} ({$total}|{$lastNum})");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$lastNum = ManticoreMsg::batchSync($msgs);
|
||||
$total += $lastNum;
|
||||
|
||||
$lastId = $msgs->last()->id;
|
||||
} while (count($msgs) == $batchSize);
|
||||
|
||||
$this->info("同步对话 {$dialogId} 消息结束");
|
||||
$this->info("该对话已索引消息数量: " . \App\Module\Manticore\ManticoreBase::getDialogIndexedMsgCount($dialogId));
|
||||
}
|
||||
}
|
||||
146
app/Console/Commands/SyncProjectToManticore.php
Normal file
146
app/Console/Commands/SyncProjectToManticore.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\Project;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncProjectToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-projects {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步项目数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreProject::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步项目数据...');
|
||||
$this->syncProjects();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function syncProjects(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreProjectLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步项目数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步项目数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = Project::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个项目");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$projects = Project::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($projects->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($projects);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
$this->info("{$num}/{$count} ({$progress}%) 项目ID {$projects->first()->id} ~ {$projects->last()->id}");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$syncCount = ManticoreProject::batchSync($projects);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $projects->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($projects) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = Project::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新项目,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步项目结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引项目数量: " . ManticoreProject::getIndexedCount());
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/SyncTaskToManticore.php
Normal file
149
app/Console/Commands/SyncTaskToManticore.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncTaskToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-tasks {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步任务数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreTask::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步任务数据...');
|
||||
$this->syncTasks();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function syncTasks(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreTaskLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步任务数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步任务数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = ProjectTask::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个任务");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$tasks = ProjectTask::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($tasks);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
$this->info("{$num}/{$count} ({$progress}%) 任务ID {$tasks->first()->id} ~ {$tasks->last()->id}");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$syncCount = ManticoreTask::batchSync($tasks);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $tasks->last()->id;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($tasks) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = ProjectTask::where('id', '>', $lastId)
|
||||
->whereNull('archived_at')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新任务,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步任务结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引任务数量: " . ManticoreTask::getIndexedCount());
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\ZincSearch\ZincSearchKeyValue;
|
||||
use App\Module\ZincSearch\ZincSearchDialogMsg;
|
||||
use Cache;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncUserMsgToZincSearch extends Command
|
||||
{
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新(从上次更新的最后一个ID接上)
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*/
|
||||
|
||||
protected $signature = 'zinc:sync-user-msg {--f} {--i} {--c} {--batch=1000}';
|
||||
protected $description = '同步聊天会话用户和消息到 ZincSearch';
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「ZincSearch」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 注册信号处理器(仅在支持pcntl扩展的环境下)
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_async_signals(true); // 启用异步信号处理
|
||||
pcntl_signal(SIGINT, [$this, 'handleSignal']); // Ctrl+C
|
||||
pcntl_signal(SIGTERM, [$this, 'handleSignal']); // kill
|
||||
}
|
||||
|
||||
// 检查锁,如果已被占用则退出
|
||||
$lockInfo = $this->getLock();
|
||||
if ($lockInfo) {
|
||||
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 设置锁
|
||||
$this->setLock();
|
||||
|
||||
// 清除索引
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ZincSearchKeyValue::clear();
|
||||
ZincSearchDialogMsg::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步聊天数据...');
|
||||
|
||||
// 同步消息数据
|
||||
$this->syncDialogMsgs();
|
||||
|
||||
// 完成
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锁信息
|
||||
*
|
||||
* @return array|null 如果锁存在返回锁信息,否则返回null
|
||||
*/
|
||||
private function getLock(): ?array
|
||||
{
|
||||
$lockKey = md5($this->signature);
|
||||
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置锁
|
||||
*/
|
||||
private function setLock(): void
|
||||
{
|
||||
$lockKey = md5($this->signature);
|
||||
$lockInfo = [
|
||||
'started_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
Cache::put($lockKey, $lockInfo, 300); // 5分钟
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁
|
||||
*/
|
||||
private function releaseLock(): void
|
||||
{
|
||||
$lockKey = md5($this->signature);
|
||||
Cache::forget($lockKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理终端信号
|
||||
*
|
||||
* @param int $signal
|
||||
* @return void
|
||||
*/
|
||||
public function handleSignal(int $signal): void
|
||||
{
|
||||
// 释放锁
|
||||
$this->releaseLock();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步消息数据
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function syncDialogMsgs(): void
|
||||
{
|
||||
// 获取上次同步的最后ID
|
||||
$lastKey = "sync:dialogUserMsgLastId";
|
||||
$lastId = $this->option('i') ? intval(ZincSearchKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n同步消息数据({$lastId})...");
|
||||
} else {
|
||||
$this->info("\n同步消息数据...");
|
||||
}
|
||||
|
||||
$num = 0;
|
||||
$count = WebSocketDialogMsg::where('id', '>', $lastId)->count();
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$total = 0;
|
||||
$lastNum = 0;
|
||||
|
||||
do {
|
||||
// 获取一批
|
||||
$dialogMsgs = WebSocketDialogMsg::where('id', '>', $lastId)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($dialogMsgs->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($dialogMsgs);
|
||||
$progress = round($num / $count * 100, 2);
|
||||
if ($progress < 100) {
|
||||
$progress = number_format($progress, 2);
|
||||
}
|
||||
$this->info("{$num}/{$count} ({$progress}%) 正在同步消息ID {$dialogMsgs->first()->id} ~ {$dialogMsgs->last()->id} ({$total}|{$lastNum})");
|
||||
|
||||
// 刷新锁
|
||||
$this->setLock();
|
||||
|
||||
// 同步数据
|
||||
$lastNum = ZincSearchDialogMsg::batchSync($dialogMsgs);
|
||||
$total += $lastNum;
|
||||
|
||||
// 更新最后ID
|
||||
$lastId = $dialogMsgs->last()->id;
|
||||
ZincSearchKeyValue::set($lastKey, $lastId);
|
||||
} while (count($dialogMsgs) == $batchSize);
|
||||
|
||||
$this->info("同步消息结束 - 最后ID {$lastId}");
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/SyncUserToManticore.php
Normal file
149
app/Console/Commands/SyncUserToManticore.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\ManticoreSyncLock;
|
||||
use App\Models\User;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use App\Module\Manticore\ManticoreKeyValue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncUserToManticore extends Command
|
||||
{
|
||||
use ManticoreSyncLock;
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* --f: 全量更新 (默认)
|
||||
* --i: 增量更新
|
||||
*
|
||||
* 清理数据
|
||||
* --c: 清除索引
|
||||
*
|
||||
* 其他选项
|
||||
* --sleep: 每批处理完成后休眠秒数
|
||||
*/
|
||||
|
||||
protected $signature = 'manticore:sync-users {--f} {--i} {--c} {--batch=100} {--sleep=3}';
|
||||
protected $description = '同步用户数据到 Manticore Search';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
$this->error("应用「Manticore Search」未安装");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->option('c')) {
|
||||
$this->info('清除索引...');
|
||||
ManticoreUser::clear();
|
||||
$this->info("索引删除成功");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('开始同步用户数据...');
|
||||
$this->syncUsers();
|
||||
|
||||
$this->info("\n同步完成");
|
||||
$this->releaseLock();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function syncUsers(): void
|
||||
{
|
||||
$lastKey = "sync:manticoreUserLastId";
|
||||
$isIncremental = $this->option('i');
|
||||
$sleepSeconds = intval($this->option('sleep'));
|
||||
$batchSize = $this->option('batch');
|
||||
|
||||
$round = 0;
|
||||
|
||||
do {
|
||||
$round++;
|
||||
$lastId = $isIncremental ? intval(ManticoreKeyValue::get($lastKey, 0)) : 0;
|
||||
|
||||
if ($round === 1) {
|
||||
if ($lastId > 0) {
|
||||
$this->info("\n增量同步用户数据(从ID {$lastId} 开始)...");
|
||||
} else {
|
||||
$this->info("\n全量同步用户数据...");
|
||||
}
|
||||
}
|
||||
|
||||
$count = User::where('userid', '>', $lastId)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
if ($round === 1) {
|
||||
$this->info("无待同步数据");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->info("[第 {$round} 轮] 待同步 {$count} 个用户");
|
||||
|
||||
$num = 0;
|
||||
$total = 0;
|
||||
|
||||
do {
|
||||
if ($this->shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
$users = User::where('userid', '>', $lastId)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->orderBy('userid')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$num += count($users);
|
||||
$progress = $count > 0 ? round($num / $count * 100, 2) : 100;
|
||||
$this->info("{$num}/{$count} ({$progress}%) 用户ID {$users->first()->userid} ~ {$users->last()->userid}");
|
||||
|
||||
$this->setLock();
|
||||
|
||||
$syncCount = ManticoreUser::batchSync($users);
|
||||
$total += $syncCount;
|
||||
|
||||
$lastId = $users->last()->userid;
|
||||
ManticoreKeyValue::set($lastKey, $lastId);
|
||||
} while (count($users) == $batchSize && !$this->shouldStop);
|
||||
|
||||
$this->info("[第 {$round} 轮] 完成,同步 {$total} 个,最后ID {$lastId}");
|
||||
|
||||
if ($isIncremental && !$this->shouldStop) {
|
||||
$newCount = User::where('userid', '>', $lastId)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->count();
|
||||
|
||||
if ($newCount > 0) {
|
||||
$this->info("发现 {$newCount} 个新用户,{$sleepSeconds} 秒后继续...");
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} while (!$this->shouldStop);
|
||||
|
||||
$this->info("同步用户结束(共 {$round} 轮)- 最后ID: " . ManticoreKeyValue::get($lastKey, 0));
|
||||
$this->info("已索引用户数量: " . ManticoreUser::getIndexedCount());
|
||||
}
|
||||
}
|
||||
90
app/Console/Commands/Traits/ManticoreSyncLock.php
Normal file
90
app/Console/Commands/Traits/ManticoreSyncLock.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Traits;
|
||||
|
||||
use Cache;
|
||||
|
||||
/**
|
||||
* Manticore 同步命令通用锁机制
|
||||
*
|
||||
* 提供:
|
||||
* - 锁的获取、设置、释放
|
||||
* - 信号处理(优雅退出)
|
||||
* - 通用的命令初始化检查
|
||||
*/
|
||||
trait ManticoreSyncLock
|
||||
{
|
||||
private bool $shouldStop = false;
|
||||
|
||||
/**
|
||||
* 获取锁信息
|
||||
*/
|
||||
private function getLock(): ?array
|
||||
{
|
||||
$lockKey = $this->getLockKey();
|
||||
return Cache::has($lockKey) ? Cache::get($lockKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置锁(30分钟有效期,持续处理时需不断刷新)
|
||||
*/
|
||||
private function setLock(): void
|
||||
{
|
||||
$lockKey = $this->getLockKey();
|
||||
Cache::put($lockKey, ['started_at' => date('Y-m-d H:i:s')], 1800);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁
|
||||
*/
|
||||
private function releaseLock(): void
|
||||
{
|
||||
$lockKey = $this->getLockKey();
|
||||
Cache::forget($lockKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锁的缓存键
|
||||
*/
|
||||
private function getLockKey(): string
|
||||
{
|
||||
return md5($this->signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 信号处理器(SIGINT/SIGTERM)
|
||||
*/
|
||||
public function handleSignal(int $signal): void
|
||||
{
|
||||
$this->info("\n收到信号,将在当前批次完成后退出...");
|
||||
$this->shouldStop = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册信号处理器
|
||||
*/
|
||||
private function registerSignalHandlers(): void
|
||||
{
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_async_signals(true);
|
||||
pcntl_signal(SIGINT, [$this, 'handleSignal']);
|
||||
pcntl_signal(SIGTERM, [$this, 'handleSignal']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查命令是否可以启动(锁检查)
|
||||
*
|
||||
* @return bool 返回 true 表示可以启动,false 表示已被占用
|
||||
*/
|
||||
private function acquireLock(): bool
|
||||
{
|
||||
$lockInfo = $this->getLock();
|
||||
if ($lockInfo) {
|
||||
$this->error("命令已在运行中,开始时间: {$lockInfo['started_at']}");
|
||||
return false;
|
||||
}
|
||||
$this->setLock();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Request;
|
||||
use Session;
|
||||
use Response;
|
||||
use Madzipper;
|
||||
use Carbon\Carbon;
|
||||
use App\Module\Down;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
@@ -41,7 +41,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/verifyToken 01. 验证APi登录
|
||||
* @api {get} api/approve/verifyToken 验证APi登录
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -63,7 +63,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procdef/all 02. 查询流程定义
|
||||
* @api {post} api/approve/procdef/all 查询流程定义
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -90,7 +90,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/procdef/del 03. 删除流程定义
|
||||
* @api {get} api/approve/procdef/del 删除流程定义
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -116,7 +116,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/start 04. 启动流程(审批中)
|
||||
* @api {post} api/approve/process/start 启动流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -179,7 +179,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/addGlobalComment 05. 添加全局评论
|
||||
* @api {post} api/approve/process/addGlobalComment 添加全局评论
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -224,7 +224,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/task/complete 06. 审批
|
||||
* @api {post} api/approve/task/complete 审批
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -304,7 +304,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/task/withdraw 07. 撤回
|
||||
* @api {post} api/approve/task/withdraw 撤回
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -349,7 +349,38 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/findTask 08. 查询需要我审批的流程(审批中)
|
||||
* @api {post} api/approve/process/delById 删除审批(流程实例)
|
||||
*
|
||||
* @apiDescription 需要token身份;仅可删除已结束的审批,且仅发起人或管理员可删
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup approve
|
||||
* @apiName process__delById
|
||||
*
|
||||
* @apiQuery {Number} proc_inst_id 流程实例ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function process__delById()
|
||||
{
|
||||
$user = User::auth();
|
||||
$data['userid'] = (string)$user->userid;
|
||||
$data['proc_inst_id'] = intval(Request::input('proc_inst_id'));
|
||||
$data['is_admin'] = $user->isAdmin();
|
||||
if ($data['proc_inst_id'] <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
$ret = Ihttp::ihttp_post($this->flow_url . '/api/v1/workflow/process/delById', json_encode(Base::arrayKeyToCamel($data)));
|
||||
$task = json_decode($ret['ret'] == 1 ? $ret['data'] : '{}', true);
|
||||
if (!$task || $task['status'] != 200) {
|
||||
return Base::retError($task['message'] ?? '删除失败');
|
||||
}
|
||||
return Base::retSuccess('已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/findTask 查询需要我审批的流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -392,7 +423,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/startByMyselfAll 09. 查询我启动的流程(全部)
|
||||
* @api {post} api/approve/process/startByMyselfAll 查询我启动的流程(全部)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -435,7 +466,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/startByMyself 10. 查询我启动的流程(审批中)
|
||||
* @api {post} api/approve/process/startByMyself 查询我启动的流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -473,7 +504,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/process/findProcNotify 11. 查询抄送我的流程(审批中)
|
||||
* @api {post} api/approve/process/findProcNotify 查询抄送我的流程(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -517,7 +548,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/identitylink/findParticipant 12. 查询流程实例的参与者(审批中)
|
||||
* @api {get} api/approve/identitylink/findParticipant 查询流程实例的参与者(审批中)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -552,7 +583,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procHistory/findTask 13. 查询需要我审批的流程(已结束)
|
||||
* @api {post} api/approve/procHistory/findTask 查询需要我审批的流程(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -595,7 +626,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procHistory/startByMyself 14. 查询我启动的流程(已结束)
|
||||
* @api {post} api/approve/procHistory/startByMyself 查询我启动的流程(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -633,7 +664,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/procHistory/findProcNotify 15. 查询抄送我的流程(已结束)
|
||||
* @api {post} api/approve/procHistory/findProcNotify 查询抄送我的流程(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -677,7 +708,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/identitylinkHistory/findParticipant 16. 查询流程实例的参与者(已结束)
|
||||
* @api {get} api/approve/identitylinkHistory/findParticipant 查询流程实例的参与者(已结束)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -712,7 +743,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/process/detail 17. 根据流程ID查询流程详情
|
||||
* @api {get} api/approve/process/detail 根据流程ID查询流程详情
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -734,7 +765,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/approve/export 18. 导出数据
|
||||
* @api {post} api/approve/export 导出数据
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -775,7 +806,8 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
||||
//
|
||||
go(function () use ($data, $user, $botUser, $dialog) {
|
||||
$doo = Doo::load();
|
||||
go(function () use ($doo, $data, $user, $botUser, $dialog) {
|
||||
Coroutine::sleep(1);
|
||||
//
|
||||
$content = [];
|
||||
@@ -802,30 +834,30 @@ class ApproveController extends AbstractController
|
||||
$res = Base::arrayKeyToUnderline($process['data']);
|
||||
//
|
||||
$headings = [];
|
||||
$headings[] = Doo::translate('申请编号');
|
||||
$headings[] = Doo::translate('标题');
|
||||
$headings[] = Doo::translate('申请状态');
|
||||
$headings[] = Doo::translate('发起时间');
|
||||
$headings[] = Doo::translate('完成时间');
|
||||
$headings[] = Doo::translate('发起人工号');
|
||||
$headings[] = Doo::translate('发起人User ID');
|
||||
$headings[] = Doo::translate('发起人姓名');
|
||||
$headings[] = Doo::translate('发起人部门');
|
||||
$headings[] = Doo::translate('发起人部门ID');
|
||||
$headings[] = Doo::translate('部门负责人');
|
||||
$headings[] = Doo::translate('历史审批人');
|
||||
$headings[] = Doo::translate('历史办理人');
|
||||
$headings[] = Doo::translate('审批记录');
|
||||
$headings[] = Doo::translate('当前处理人');
|
||||
$headings[] = Doo::translate('审批节点');
|
||||
$headings[] = Doo::translate('审批人数');
|
||||
$headings[] = Doo::translate('审批耗时');
|
||||
$headings[] = Doo::translate('假期类型');
|
||||
$headings[] = Doo::translate('开始时间');
|
||||
$headings[] = Doo::translate('结束时间');
|
||||
$headings[] = Doo::translate('时长');
|
||||
$headings[] = Doo::translate('请假事由');
|
||||
$headings[] = Doo::translate('请假单位');
|
||||
$headings[] = $doo->translate('申请编号');
|
||||
$headings[] = $doo->translate('标题');
|
||||
$headings[] = $doo->translate('申请状态');
|
||||
$headings[] = $doo->translate('发起时间');
|
||||
$headings[] = $doo->translate('完成时间');
|
||||
$headings[] = $doo->translate('发起人工号');
|
||||
$headings[] = $doo->translate('发起人User ID');
|
||||
$headings[] = $doo->translate('发起人姓名');
|
||||
$headings[] = $doo->translate('发起人部门');
|
||||
$headings[] = $doo->translate('发起人部门ID');
|
||||
$headings[] = $doo->translate('部门负责人');
|
||||
$headings[] = $doo->translate('历史审批人');
|
||||
$headings[] = $doo->translate('历史办理人');
|
||||
$headings[] = $doo->translate('审批记录');
|
||||
$headings[] = $doo->translate('当前处理人');
|
||||
$headings[] = $doo->translate('审批节点');
|
||||
$headings[] = $doo->translate('审批人数');
|
||||
$headings[] = $doo->translate('审批耗时');
|
||||
$headings[] = $doo->translate('假期类型');
|
||||
$headings[] = $doo->translate('开始时间');
|
||||
$headings[] = $doo->translate('结束时间');
|
||||
$headings[] = $doo->translate('时长');
|
||||
$headings[] = $doo->translate('请假事由');
|
||||
$headings[] = $doo->translate('请假单位');
|
||||
//
|
||||
$datas = [];
|
||||
foreach ($res as $val) {
|
||||
@@ -845,12 +877,12 @@ class ApproveController extends AbstractController
|
||||
// 计算审批耗时
|
||||
$startTime = Carbon::parse($val['start_time'])->timestamp;
|
||||
$endTime = $val['end_time'] ? Carbon::parse($val['end_time'])->timestamp : time();
|
||||
$approval_time = Doo::translate(Timer::timeDiff($startTime, $endTime)); // 审批耗时
|
||||
$approval_time = $doo->translate(Timer::timeDiff($startTime, $endTime)); // 审批耗时
|
||||
// 计算时长
|
||||
$varStartTime = Carbon::parse($val['var']['start_time']);
|
||||
$varEndTime = Carbon::parse($val['var']['end_time']);
|
||||
$duration = $varEndTime->floatDiffInHours($varStartTime);
|
||||
$duration_unit = Doo::translate('小时'); // 时长单位
|
||||
$duration_unit = $doo->translate('小时'); // 时长单位
|
||||
$datas[] = [
|
||||
$val['id'], // 申请编号
|
||||
$val['proc_def_name'], // 标题
|
||||
@@ -891,7 +923,7 @@ class ApproveController extends AbstractController
|
||||
return;
|
||||
}
|
||||
//
|
||||
$title = Doo::translate("审批记录");
|
||||
$title = $doo->translate("审批记录");
|
||||
$sheets = [
|
||||
BillExport::create()->setTitle($title)->setHeadings($headings)->setData($datas)->setStyles(["A1:Y1" => ["font" => ["bold" => true]]])
|
||||
];
|
||||
@@ -924,11 +956,10 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
//
|
||||
if (file_exists($zipPath)) {
|
||||
$base64 = base64_encode(Base::array2string([
|
||||
$key = Down::cache_encode([
|
||||
'file' => $zipFile,
|
||||
]));
|
||||
$fileUrl = Base::fillUrl('api/approve/down?key=' . urlencode($base64));
|
||||
Session::put('approve::export:userid', $user->userid);
|
||||
]);
|
||||
$fileUrl = Base::fillUrl('api/approve/down?key=' . $key);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'file_download',
|
||||
'title' => '导出审批数据已完成',
|
||||
@@ -970,7 +1001,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/down 19. 下载导出的审批数据
|
||||
* @api {get} api/approve/down 下载导出的审批数据
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup approve
|
||||
@@ -982,15 +1013,10 @@ class ApproveController extends AbstractController
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$userid = Session::get('approve::export:userid');
|
||||
if (empty($userid)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
|
||||
}
|
||||
//
|
||||
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
|
||||
$array = Down::cache_decode();
|
||||
$file = $array['file'];
|
||||
if (empty($file) || !file_exists(storage_path($file))) {
|
||||
return Base::ajaxError("文件不存在!", [], 0, 502);
|
||||
return Base::ajaxError("文件不存在!", [], 0, 403);
|
||||
}
|
||||
return Response::download(storage_path($file));
|
||||
}
|
||||
@@ -1197,7 +1223,7 @@ class ApproveController extends AbstractController
|
||||
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/user/status 20. 获取用户审批状态
|
||||
* @api {get} api/approve/user/status 获取用户审批状态
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup approve
|
||||
@@ -1217,7 +1243,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/approve/process/doto 21. 查询需要我审批的流程数量
|
||||
* @api {get} api/approve/process/doto 查询需要我审批的流程数量
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
|
||||
307
app/Http/Controllers/Api/AssistantController.php
Normal file
307
app/Http/Controllers/Api/AssistantController.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\AiAssistantSession;
|
||||
use App\Models\User;
|
||||
use App\Module\AI;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* @apiDefine assistant
|
||||
*
|
||||
* 助手
|
||||
*/
|
||||
class AssistantController extends AbstractController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Apps::isInstalledThrow('ai');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/assistant/auth 生成授权码
|
||||
*
|
||||
* @apiDescription 需要token身份,生成 AI 流式会话的 stream_key
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName auth
|
||||
*
|
||||
* @apiParam {String} model_type 模型类型
|
||||
* @apiParam {String} model_name 模型名称
|
||||
* @apiParam {JSON} context 上下文数组
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.stream_key 流式会话凭证
|
||||
*/
|
||||
public function auth()
|
||||
{
|
||||
$user = User::auth();
|
||||
$user->checkChatInformation();
|
||||
|
||||
$modelType = trim(Request::input('model_type', ''));
|
||||
$modelName = trim(Request::input('model_name', ''));
|
||||
$contextInput = Request::input('context', []);
|
||||
|
||||
return AI::createStreamKey($modelType, $modelName, $contextInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/assistant/models 获取AI模型
|
||||
*
|
||||
* @apiDescription 获取所有AI机器人模型设置
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName models
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function models()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
$setting = array_filter($setting, function ($value, $key) {
|
||||
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/assistant/match-elements 元素向量匹配
|
||||
*
|
||||
* @apiDescription 通过向量相似度匹配页面元素,用于智能查找与查询语义相关的元素
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName match_elements
|
||||
*
|
||||
* @apiParam {String} query 搜索关键词
|
||||
* @apiParam {Array} elements 元素列表,每个元素包含 ref 和 name 字段
|
||||
* @apiParam {Number} [top_k=10] 返回的匹配数量,最大50
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {Array} data.matches 匹配结果数组,按相似度降序排列
|
||||
*/
|
||||
public function match_elements()
|
||||
{
|
||||
User::auth();
|
||||
|
||||
$query = trim(Request::input('query', ''));
|
||||
$elements = Request::input('elements', []);
|
||||
$topK = min(intval(Request::input('top_k', 10)), 50);
|
||||
|
||||
if (empty($query) || empty($elements)) {
|
||||
return Base::retError('参数不能为空');
|
||||
}
|
||||
|
||||
// 获取查询向量
|
||||
$queryResult = AI::getEmbedding($query);
|
||||
if (Base::isError($queryResult)) {
|
||||
return $queryResult;
|
||||
}
|
||||
$queryVector = $queryResult['data'];
|
||||
|
||||
// 计算相似度并排序
|
||||
$scored = [];
|
||||
foreach ($elements as $el) {
|
||||
$name = $el['name'] ?? '';
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$elResult = AI::getEmbedding($name);
|
||||
if (Base::isError($elResult)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$similarity = $this->cosineSimilarity($queryVector, $elResult['data']);
|
||||
$scored[] = [
|
||||
'element' => $el,
|
||||
'similarity' => $similarity,
|
||||
];
|
||||
}
|
||||
|
||||
// 按相似度降序排序
|
||||
usort($scored, fn($a, $b) => $b['similarity'] <=> $a['similarity']);
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'matches' => array_slice($scored, 0, $topK),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个向量的余弦相似度
|
||||
*/
|
||||
private function cosineSimilarity(array $a, array $b): float
|
||||
{
|
||||
$dotProduct = 0;
|
||||
$normA = 0;
|
||||
$normB = 0;
|
||||
$count = count($a);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$dotProduct += $a[$i] * $b[$i];
|
||||
$normA += $a[$i] * $a[$i];
|
||||
$normB += $b[$i] * $b[$i];
|
||||
}
|
||||
$denominator = sqrt($normA) * sqrt($normB);
|
||||
if ($denominator == 0) {
|
||||
return 0;
|
||||
}
|
||||
return $dotProduct / $denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
public function session__list()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
|
||||
$sessions = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
$list = [];
|
||||
foreach ($sessions as $session) {
|
||||
$data = Base::json2array($session->data);
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $imageId => $path) {
|
||||
$images[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
$list[] = [
|
||||
'id' => $session->session_id,
|
||||
'title' => $session->title,
|
||||
'responses' => $data,
|
||||
'images' => $images,
|
||||
'sceneKey' => $session->scene_key,
|
||||
'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0,
|
||||
'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*/
|
||||
public function session__save()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$sceneKey = trim(Request::input('scene_key', ''));
|
||||
$title = trim(Request::input('title', ''));
|
||||
$data = Request::input('data', []);
|
||||
$newImages = Request::input('new_images', []);
|
||||
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
|
||||
$newImageUrls = [];
|
||||
if (is_array($newImages)) {
|
||||
$path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/';
|
||||
foreach ($newImages as $img) {
|
||||
$imageId = $img['imageId'] ?? '';
|
||||
$dataUrl = $img['dataUrl'] ?? '';
|
||||
if (empty($imageId) || empty($dataUrl)) {
|
||||
continue;
|
||||
}
|
||||
$result = Base::image64save([
|
||||
'image64' => $dataUrl,
|
||||
'path' => $path,
|
||||
'autoThumb' => false,
|
||||
]);
|
||||
if (Base::isSuccess($result)) {
|
||||
$newImageUrls[$imageId] = $result['data']['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey)
|
||||
->where('session_id', $sessionId)
|
||||
->first();
|
||||
|
||||
$imageMap = $newImageUrls;
|
||||
if ($session) {
|
||||
$existingImages = Base::json2array($session->images);
|
||||
$imageMap = array_merge($existingImages, $newImageUrls);
|
||||
}
|
||||
|
||||
$session = AiAssistantSession::createInstance([
|
||||
'userid' => $user->userid,
|
||||
'session_key' => $sessionKey,
|
||||
'session_id' => $sessionId,
|
||||
'scene_key' => $sceneKey,
|
||||
'title' => mb_substr($title, 0, 255),
|
||||
'data' => Base::array2json(is_array($data) ? $data : []),
|
||||
'images' => Base::array2json($imageMap),
|
||||
], $session?->id);
|
||||
$session->save();
|
||||
|
||||
// 仅返回本次新增的图片URL
|
||||
$urls = [];
|
||||
foreach ($newImageUrls as $imageId => $path) {
|
||||
$urls[$imageId] = Base::fillUrl($path);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'image_urls' => $urls,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
public function session__delete()
|
||||
{
|
||||
$user = User::auth();
|
||||
$sessionKey = trim(Request::input('session_key', 'default'));
|
||||
$sessionId = trim(Request::input('session_id', ''));
|
||||
$clearAll = Request::input('clear_all', false);
|
||||
|
||||
$query = AiAssistantSession::where('userid', $user->userid)
|
||||
->where('session_key', $sessionKey);
|
||||
|
||||
if ($clearAll) {
|
||||
$sessions = $query->get();
|
||||
foreach ($sessions as $session) {
|
||||
$this->deleteSessionImages($session);
|
||||
}
|
||||
$query->delete();
|
||||
} else {
|
||||
if (empty($sessionId)) {
|
||||
return Base::retError('session_id 不能为空');
|
||||
}
|
||||
$session = $query->where('session_id', $sessionId)->first();
|
||||
if ($session) {
|
||||
$this->deleteSessionImages($session);
|
||||
$session->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
private function deleteSessionImages(AiAssistantSession $session)
|
||||
{
|
||||
$images = Base::json2array($session->images);
|
||||
foreach ($images as $path) {
|
||||
$fullPath = public_path($path);
|
||||
if (file_exists($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,18 @@ use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
|
||||
/**
|
||||
* @apiDefine dialog
|
||||
* @apiDefine complaint
|
||||
*
|
||||
* 投诉
|
||||
*/
|
||||
class ComplaintController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/complaint/lists 01. 获取举报投诉列表
|
||||
* @api {get} api/complaint/lists 获取举报投诉列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiGroup complaint
|
||||
* @apiName lists
|
||||
*
|
||||
* @apiParam {Number} [type] 类型
|
||||
@@ -33,6 +33,34 @@ class ComplaintController extends AbstractController
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response-Data:
|
||||
* {
|
||||
* "current_page": 1,
|
||||
* "data": [
|
||||
* {
|
||||
* "id": 1,
|
||||
* "dialog_id": 100,
|
||||
* "userid": 1,
|
||||
* "type": 1,
|
||||
* "reason": "举报原因",
|
||||
* "imgs": [],
|
||||
* "status": 0,
|
||||
* "created_at": "2025-01-01 00:00:00",
|
||||
* "updated_at": "2025-01-01 00:00:00"
|
||||
* }
|
||||
* ],
|
||||
* "first_page_url": "http://example.com/api/complaint/lists?page=1",
|
||||
* "from": 1,
|
||||
* "last_page": 1,
|
||||
* "last_page_url": "http://example.com/api/complaint/lists?page=1",
|
||||
* "next_page_url": null,
|
||||
* "path": "http://example.com/api/complaint/lists",
|
||||
* "per_page": 50,
|
||||
* "prev_page_url": null,
|
||||
* "to": 1,
|
||||
* "total": 1
|
||||
* }
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
@@ -56,21 +84,25 @@ class ComplaintController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/complaint/submit 02. 举报投诉
|
||||
* @api {post} api/complaint/submit 举报投诉
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiGroup complaint
|
||||
* @apiName submit
|
||||
*
|
||||
* @apiParam {Number} dialog_id 对话ID
|
||||
* @apiParam {Number} type 类型
|
||||
* @apiParam {String} reason 原因
|
||||
* @apiParam {String} imgs 图片
|
||||
* @apiBody {Number} dialog_id 对话ID
|
||||
* @apiBody {Number} type 类型
|
||||
* @apiBody {String} reason 原因
|
||||
* @apiBody {Object[]} [imgs] 图片数组(可选)
|
||||
* @apiBody {String} imgs.path 图片路径
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response-Data:
|
||||
* []
|
||||
*/
|
||||
public function submit()
|
||||
{
|
||||
@@ -125,19 +157,22 @@ class ComplaintController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/complaint/action 03. 举报投诉 - 操作
|
||||
* @api {post} api/complaint/action 举报投诉 - 操作
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiDescription 需要token身份(管理员权限)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiGroup complaint
|
||||
* @apiName action
|
||||
*
|
||||
* @apiParam {Number} id ID
|
||||
* @apiParam {Number} type 类型
|
||||
* @apiBody {Number} id 投诉ID
|
||||
* @apiBody {String} type 操作类型:handle=已处理,delete=删除
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response-Data:
|
||||
* []
|
||||
*/
|
||||
public function action()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,14 @@ use App\Models\FileContent;
|
||||
use App\Models\FileLink;
|
||||
use App\Models\FileUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecentItem;
|
||||
use App\Module\Base;
|
||||
use App\Module\Down;
|
||||
use App\Module\Lock;
|
||||
use App\Module\Timer;
|
||||
use App\Module\Ihttp;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use Response;
|
||||
use Session;
|
||||
use Swoole\Coroutine;
|
||||
use Carbon\Carbon;
|
||||
use Redirect;
|
||||
@@ -30,7 +33,7 @@ use ZipArchive;
|
||||
class FileController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/file/lists 01. 获取文件列表
|
||||
* @api {get} api/file/lists 获取文件列表
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -53,7 +56,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/one 02. 获取单条数据
|
||||
* @api {get} api/file/one 获取单条数据
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -63,6 +66,14 @@ class FileController extends AbstractController
|
||||
* @apiParam {Number|String} id
|
||||
* - Number 文件ID(需要登录)
|
||||
* - String 链接码(不需要登录,用于预览)
|
||||
* @apiParam {String} [with_url] 是否返回文件访问URL
|
||||
* - no: 不返回(默认)
|
||||
* - yes: 返回content_url字段
|
||||
* @apiParam {String} [with_text] 是否提取文件文本内容(用于AI阅读,支持分页)
|
||||
* - no: 不提取(默认)
|
||||
* - yes: 提取文本内容,支持 docx/xlsx/pptx/pdf/txt 等格式
|
||||
* @apiParam {Number} [text_offset] with_text=yes时有效,文本起始位置(字符数),默认0
|
||||
* @apiParam {Number} [text_limit] with_text=yes时有效,文本获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -71,11 +82,15 @@ class FileController extends AbstractController
|
||||
public function one()
|
||||
{
|
||||
$id = Request::input('id');
|
||||
$with_url = Request::input('with_url', 'no');
|
||||
$with_text = Request::input('with_text', 'no');
|
||||
$text_offset = intval(Request::input('text_offset', 0));
|
||||
$text_limit = intval(Request::input('text_limit', 50000));
|
||||
//
|
||||
$permission = 0;
|
||||
if (Base::isNumber($id)) {
|
||||
$user = User::auth();
|
||||
$file = File::permissionFind(intval($id), $user, 0, $permission);
|
||||
$file = File::permissionFind(intval($id), $user, $with_url === 'yes' ? 1 : 0, $permission);
|
||||
} elseif ($id) {
|
||||
$fileLink = FileLink::whereCode($id)->first();
|
||||
$file = $fileLink?->file;
|
||||
@@ -87,6 +102,12 @@ class FileController extends AbstractController
|
||||
}
|
||||
return Base::retError($msg, $data);
|
||||
}
|
||||
|
||||
// 如果文件不允许游客访问,则需要登录
|
||||
if (!$file->guest_access) {
|
||||
User::auth();
|
||||
}
|
||||
|
||||
$fileLink->increment("num");
|
||||
} else {
|
||||
return Base::retError('参数错误');
|
||||
@@ -94,20 +115,74 @@ class FileController extends AbstractController
|
||||
//
|
||||
$array = $file->toArray();
|
||||
$array['permission'] = $permission;
|
||||
|
||||
// 如果请求返回文件URL
|
||||
if ($with_url === 'yes') {
|
||||
$array['content_url'] = FileContent::getFileUrl($file->id);
|
||||
}
|
||||
|
||||
// 如果请求提取文本内容
|
||||
if ($with_text === 'yes') {
|
||||
$array['text_content'] = ManticoreFile::extractFileContentPaginated($file, $text_offset, $text_limit);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $array);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/search 03. 搜索文件列表
|
||||
* @api {get} api/file/fetch 通过路径获取文件文本内容
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiDescription 用于 MCP/AI 工具通过文件路径获取内容,支持分页获取大文件
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName fetch
|
||||
*
|
||||
* @apiParam {String} path 文件路径(相对于系统根目录,如 uploads/file/...)
|
||||
* @apiParam {Number} [offset] 起始位置(字符数),默认0
|
||||
* @apiParam {Number} [limit] 获取长度(字符数),默认50000,最大200000
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* - content: 文本内容
|
||||
* - total_length: 完整内容总长度
|
||||
* - offset: 当前起始位置
|
||||
* - limit: 本次获取长度
|
||||
* - has_more: 是否还有更多内容
|
||||
*/
|
||||
public function fetch()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
$path = trim(Request::input('path'));
|
||||
$offset = intval(Request::input('offset', 0));
|
||||
$limit = intval(Request::input('limit', 50000));
|
||||
|
||||
if (empty($path)) {
|
||||
return Base::retError('参数错误:path 不能为空');
|
||||
}
|
||||
|
||||
// 直接传入路径,ManticoreFile 内部处理 URL 解析
|
||||
$result = ManticoreFile::extractFileContentPaginated($path, $offset, $limit);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return Base::retError($result['error']);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/search 搜索文件列表
|
||||
*
|
||||
* @apiDescription 需要token身份(仅搜索文件名,AI 内容搜索请使用 api/search/file)
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName search
|
||||
*
|
||||
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
||||
* @apiParam {String} [key] 关键词
|
||||
* @apiParam {Number} [take] 获取数量(默认:50,最大:100)
|
||||
* @apiParam {String} [link] 通过分享地址搜索(如:https://t.hitosea.com/single/file/ODcwOCwzOSxpa0JBS2lmVQ==)
|
||||
* @apiParam {String} [key] 关键词
|
||||
* @apiParam {Number} [take] 获取数量(默认:50,最大:100)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -128,6 +203,7 @@ class FileController extends AbstractController
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索自己的
|
||||
$builder = File::whereUserid($user->userid);
|
||||
if ($id) {
|
||||
@@ -135,7 +211,9 @@ class FileController extends AbstractController
|
||||
}
|
||||
if ($key) {
|
||||
if (!$id && Base::isNumber($key)) {
|
||||
$builder->where("id", $key);
|
||||
$builder->where(function ($query) use ($key) {
|
||||
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
@@ -157,7 +235,13 @@ class FileController extends AbstractController
|
||||
$builder->where("id", $id);
|
||||
}
|
||||
if ($key) {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
if (Base::isNumber($key)) {
|
||||
$builder->where(function ($query) use ($key) {
|
||||
$query->where("id", $key)->orWhere("name", "like", "%{$key}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("name", "like", "%{$key}%");
|
||||
}
|
||||
}
|
||||
$list = $builder->take($take)->get();
|
||||
if ($list->isNotEmpty()) {
|
||||
@@ -175,7 +259,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/add 04. 添加、修改文件(夹)
|
||||
* @api {get} api/file/add 添加、修改文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -201,8 +285,8 @@ class FileController extends AbstractController
|
||||
$pid = intval(Request::input('pid'));
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('文件名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
return Base::retError('文件名称最多只能设置32个字');
|
||||
} elseif (mb_strlen($name) > 100) {
|
||||
return Base::retError('文件名称最多只能设置100个字');
|
||||
}
|
||||
$tmpName = preg_replace("/[\\\\\/:*?\"<>|]/", '', $name);
|
||||
if ($tmpName != $name) {
|
||||
@@ -284,7 +368,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/copy 05. 复制文件(夹)
|
||||
* @api {get} api/file/copy 复制文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -345,7 +429,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/move 06. 移动文件(夹)
|
||||
* @api {get} api/file/move 移动文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -392,7 +476,7 @@ class FileController extends AbstractController
|
||||
throw new ApiException("{$file->name} 内含有共享文件,无法移动到另一个共享文件夹内");
|
||||
}
|
||||
$file->userid = $toShareFile->userid;
|
||||
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $toShareFile->userid]);
|
||||
$file->updateChildFilesUserid($toShareFile->userid);
|
||||
}
|
||||
//
|
||||
$tmpId = $pid;
|
||||
@@ -404,7 +488,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
} else {
|
||||
$file->userid = $user->userid;
|
||||
File::where('pids', 'LIKE', "%,{$file->id},%")->update(['userid' => $user->userid]);
|
||||
$file->updateChildFilesUserid($user->userid);
|
||||
}
|
||||
//
|
||||
$file->pid = $pid;
|
||||
@@ -420,7 +504,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/remove 07. 删除文件(夹)
|
||||
* @api {get} api/file/remove 删除文件(夹)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -459,7 +543,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content 08. 获取文件内容
|
||||
* @api {get} api/file/content 获取文件内容
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -523,6 +607,16 @@ class FileController extends AbstractController
|
||||
$builder->whereId($history_id);
|
||||
}
|
||||
$content = $builder->orderByDesc('id')->first();
|
||||
if (isset($user)) {
|
||||
UserRecentItem::record(
|
||||
$user->userid,
|
||||
UserRecentItem::TYPE_FILE,
|
||||
$file->id,
|
||||
UserRecentItem::SOURCE_FILESYSTEM,
|
||||
intval($file->pid)
|
||||
);
|
||||
}
|
||||
|
||||
if ($down === 'preview') {
|
||||
return Redirect::to(FileContent::formatPreview($file, $content?->content));
|
||||
}
|
||||
@@ -530,7 +624,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/save 09. 保存文件内容
|
||||
* @api {get} api/file/content/save 保存文件内容
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -625,9 +719,9 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/office/token 10. 获取token
|
||||
* @api {get} api/file/office/token 获取token
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiDescription 用于生成office在线编辑的token
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName office__token
|
||||
@@ -640,8 +734,6 @@ class FileController extends AbstractController
|
||||
*/
|
||||
public function office__token()
|
||||
{
|
||||
User::auth();
|
||||
//
|
||||
File::isNeedInstallApp('office');
|
||||
//
|
||||
$config = Request::input('config');
|
||||
@@ -652,7 +744,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/office 11. 保存文件内容(office)
|
||||
* @api {get} api/file/content/office 保存文件内容(office)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -708,7 +800,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/upload 12. 保存文件内容(上传文件)
|
||||
* @api {get} api/file/content/upload 保存文件内容(上传文件)
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -729,14 +821,24 @@ class FileController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
$pid = intval(Request::input('pid'));
|
||||
$overwrite = intval(Request::input('cover'));
|
||||
$webkitRelativePath = Request::input('webkitRelativePath');
|
||||
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
|
||||
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
|
||||
// 同一用户往相同父目录上传时排队,避免并发导致数据库死锁
|
||||
try {
|
||||
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid) {
|
||||
$overwrite = intval(Request::input('cover'));
|
||||
$webkitRelativePath = Request::input('webkitRelativePath');
|
||||
$data = (new File)->contentUpload($user, $pid, $webkitRelativePath, $overwrite);
|
||||
return Base::retSuccess($data['data']['name'] . ' 上传成功', $data['addItem']);
|
||||
}, 120000, 120000);
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||
throw new ApiException('上传繁忙,请稍后再试');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/history 13. 获取内容历史
|
||||
* @api {get} api/file/content/history 获取内容历史
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -768,7 +870,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/content/restore 14. 恢复文件历史
|
||||
* @api {get} api/file/content/restore 恢复文件历史
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -810,7 +912,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share 15. 获取共享信息
|
||||
* @api {get} api/file/share 获取共享信息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -846,7 +948,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share/update 16. 设置共享
|
||||
* @api {get} api/file/share/update 设置共享
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -936,7 +1038,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/share/out 17. 退出共享
|
||||
* @api {get} api/file/share/out 退出共享
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -970,7 +1072,7 @@ class FileController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/link 18. 获取链接
|
||||
* @api {get} api/file/link 获取链接
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -981,6 +1083,9 @@ class FileController extends AbstractController
|
||||
* @apiParam {String} refresh 刷新链接
|
||||
* - no: 只获取(默认)
|
||||
* - yes: 刷新链接,之前的将失效
|
||||
* @apiParam {String} guest_access 是否允许游客访问
|
||||
* - no: 不允许(默认)
|
||||
* - yes: 允许游客访问
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -992,15 +1097,22 @@ class FileController extends AbstractController
|
||||
//
|
||||
$id = intval(Request::input('id'));
|
||||
$refresh = Request::input('refresh', 'no');
|
||||
$guestAccess = Request::input('guest_access', 'no');
|
||||
//
|
||||
$file = File::permissionFind($id, $user);
|
||||
|
||||
// 更新文件的游客访问权限
|
||||
$file->guest_access = $guestAccess === 'yes' ? 1 : 0;
|
||||
$file->save();
|
||||
|
||||
$fileLink = $file->getShareLink($user->userid, $refresh == 'yes');
|
||||
$fileLink['guest_access'] = $file->guest_access;
|
||||
//
|
||||
return Base::retSuccess('success', $fileLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/download/pack 19. 打包文件
|
||||
* @api {get} api/file/download/pack 打包文件
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1016,17 +1128,11 @@ class FileController extends AbstractController
|
||||
*/
|
||||
public function download__pack()
|
||||
{
|
||||
$key = Request::input('key');
|
||||
if ($key) {
|
||||
$userid = Session::get('file::pack:userid');
|
||||
if (empty($userid)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
|
||||
}
|
||||
//
|
||||
$array = Base::string2array(base64_decode(urldecode($key)));
|
||||
if (Request::has('key')) {
|
||||
$array = Down::cache_decode();
|
||||
$file = $array['file'];
|
||||
if (empty($file) || !file_exists(storage_path($file))) {
|
||||
return Base::ajaxError("文件不存在!", [], 0, 502);
|
||||
return Base::ajaxError("文件不存在!", [], 0, 403);
|
||||
}
|
||||
return Response::download(storage_path($file));
|
||||
}
|
||||
@@ -1091,11 +1197,10 @@ class FileController extends AbstractController
|
||||
return Base::retError('文件总大小已超过1GB,请分批下载');
|
||||
}
|
||||
|
||||
$base64 = base64_encode(Base::array2string([
|
||||
$key = Down::cache_encode([
|
||||
'file' => $zipFile,
|
||||
]));
|
||||
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . urlencode($base64));
|
||||
Session::put('file::pack:userid', $user->userid);
|
||||
]);
|
||||
$fileUrl = Base::fillUrl('api/file/download/pack?key=' . $key);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
Base::makeDir(dirname($zipPath));
|
||||
@@ -1104,17 +1209,18 @@ class FileController extends AbstractController
|
||||
return Base::retError('创建压缩文件失败');
|
||||
}
|
||||
|
||||
go(function () use ($zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
|
||||
$userid = $user->userid;
|
||||
go(function () use ($userid, $zipPath, $fileUrl, $zip, $files, $fileName, $botUser, $dialog) {
|
||||
Coroutine::sleep(0.1);
|
||||
// 压缩进度
|
||||
$progress = 0;
|
||||
$zip->registerProgressCallback(0.05, function ($ratio) use ($fileUrl, $fileName, &$progress) {
|
||||
$zip->registerProgressCallback(0.05, function ($ratio) use ($userid, $fileUrl, $fileName, &$progress) {
|
||||
$progress = round($ratio * 100);
|
||||
File::filePushMsg('compress', [
|
||||
File::pushMsgSimple('compress', [
|
||||
'name' => $fileName,
|
||||
'url' => $fileUrl,
|
||||
'progress' => $progress
|
||||
]);
|
||||
], $userid);
|
||||
});
|
||||
//
|
||||
foreach ($files as $file) {
|
||||
@@ -1123,11 +1229,11 @@ class FileController extends AbstractController
|
||||
$zip->close();
|
||||
//
|
||||
if ($progress < 100) {
|
||||
File::filePushMsg('compress', [
|
||||
File::pushMsgSimple('compress', [
|
||||
'name' => $fileName,
|
||||
'url' => $fileUrl,
|
||||
'progress' => 100
|
||||
]);
|
||||
], $userid);
|
||||
}
|
||||
//
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\Report;
|
||||
use App\Models\ReportAnalysis;
|
||||
use App\Models\ReportLink;
|
||||
use App\Models\ReportReceive;
|
||||
use App\Models\User;
|
||||
@@ -28,7 +29,7 @@ use Illuminate\Support\Facades\Validator;
|
||||
class ReportController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/report/my 01. 我发送的汇报
|
||||
* @api {get} api/report/my 我发送的汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -50,7 +51,9 @@ class ReportController extends AbstractController
|
||||
{
|
||||
$user = User::auth();
|
||||
//
|
||||
$builder = Report::with(['receivesUser'])->whereUserid($user->userid);
|
||||
$builder = Report::with(['receivesUser'])
|
||||
->select(Report::LIST_FIELDS)
|
||||
->whereUserid($user->userid);
|
||||
$keys = Request::input('keys');
|
||||
if (is_array($keys)) {
|
||||
if ($keys['key']) {
|
||||
@@ -58,6 +61,11 @@ class ReportController extends AbstractController
|
||||
$builder->whereHas('sendUser', function ($q2) use ($keys) {
|
||||
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} elseif (Base::isNumber($keys['key'])) {
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where("id", intval($keys['key']))
|
||||
->orWhere("title", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("title", "LIKE", "%{$keys['key']}%");
|
||||
}
|
||||
@@ -75,7 +83,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/receive 02. 我接收的汇报
|
||||
* @api {get} api/report/receive 我接收的汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -98,7 +106,8 @@ class ReportController extends AbstractController
|
||||
public function receive(): array
|
||||
{
|
||||
$user = User::auth();
|
||||
$builder = Report::with(['receivesUser']);
|
||||
$builder = Report::with(['receivesUser'])
|
||||
->select(Report::LIST_FIELDS);
|
||||
$builder->whereHas("receivesUser", function ($query) use ($user) {
|
||||
$query->where("report_receives.userid", $user->userid);
|
||||
});
|
||||
@@ -110,7 +119,11 @@ class ReportController extends AbstractController
|
||||
$q2->where("users.email", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} elseif (Base::isNumber($keys['key'])) {
|
||||
$builder->where("userid", intval($keys['key']));
|
||||
$builder->where(function ($query) use ($keys) {
|
||||
$query->where("userid", intval($keys['key']))
|
||||
->orWhere("id", intval($keys['key']))
|
||||
->orWhere("title", "LIKE", "%{$keys['key']}%");
|
||||
});
|
||||
} else {
|
||||
$builder->where("title", "LIKE", "%{$keys['key']}%");
|
||||
}
|
||||
@@ -143,7 +156,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/store 03. 保存并发送工作汇报
|
||||
* @api {get} api/report/store 保存并发送工作汇报
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -282,7 +295,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/template 04. 生成汇报模板
|
||||
* @api {get} api/report/template 生成汇报模板
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -326,6 +339,13 @@ class ReportController extends AbstractController
|
||||
$start_time->startOfWeek();
|
||||
$end_time = Carbon::instance($start_time)->endOfWeek();
|
||||
}
|
||||
// 周报时预计算下一周期时间范围(下周)
|
||||
$next_start_time = null;
|
||||
$next_end_time = null;
|
||||
if ($type === Report::WEEKLY) {
|
||||
$next_start_time = Carbon::instance($start_time)->copy()->addWeek();
|
||||
$next_end_time = Carbon::instance($end_time)->copy()->addWeek();
|
||||
}
|
||||
|
||||
// 生成唯一标识
|
||||
$sign = Report::generateSign($type, 0, Carbon::instance($start_time));
|
||||
@@ -361,6 +381,10 @@ class ReportController extends AbstractController
|
||||
->get();
|
||||
if ($complete_task->isNotEmpty()) {
|
||||
foreach ($complete_task as $task) {
|
||||
// 排除取消态任务:不将已取消任务计入“已完成工作”
|
||||
if (ProjectTask::isCanceledFlowName($task->flow_item_name)) {
|
||||
continue;
|
||||
}
|
||||
$complete_at = Carbon::parse($task->complete_at);
|
||||
$remark = $type == Report::WEEKLY ? ('<div style="text-align:center">[' . Doo::translate('周' . ['日', '一', '二', '三', '四', '五', '六'][$complete_at->dayOfWeek]) . ']</div>') : ' ';
|
||||
$completeDatas[] = [
|
||||
@@ -376,18 +400,7 @@ class ReportController extends AbstractController
|
||||
|
||||
// 未完成的任务
|
||||
$unfinishedDatas = [];
|
||||
$unfinished_task = ProjectTask::query()
|
||||
->join("projects", "projects.id", "=", "project_tasks.project_id")
|
||||
->whereNull("projects.archived_at")
|
||||
->whereNull("project_tasks.complete_at")
|
||||
->whereNotNull("project_tasks.start_at")
|
||||
->where("project_tasks.end_at", "<", $end_time->toDateTimeString())
|
||||
->whereHas("taskUser", function ($query) use ($user) {
|
||||
$query->where("userid", $user->userid);
|
||||
})
|
||||
->select("project_tasks.*")
|
||||
->orderByDesc("project_tasks.id")
|
||||
->get();
|
||||
$unfinished_task = ProjectTask::buildUnfinishedTaskQuery($user->userid, $start_time, $end_time, true)->get();
|
||||
if ($unfinished_task->isNotEmpty()) {
|
||||
foreach ($unfinished_task as $task) {
|
||||
empty($task->end_at) || $end_at = Carbon::parse($task->end_at);
|
||||
@@ -407,8 +420,10 @@ class ReportController extends AbstractController
|
||||
if ($type === Report::WEEKLY) {
|
||||
$title = $user->nickname . "的周报[" . $start_time->format("m/d") . "-" . $end_time->format("m/d") . "]";
|
||||
$title .= "[" . $start_time->month . "月第" . $start_time->weekOfMonth . "周]";
|
||||
$unfinishedTitle = '本周未完成的工作';
|
||||
} else {
|
||||
$title = $user->nickname . "的日报[" . $start_time->format("Y/m/d") . "]";
|
||||
$unfinishedTitle = '今日未完成的工作';
|
||||
}
|
||||
$title = Doo::translate($title);
|
||||
|
||||
@@ -421,22 +436,44 @@ class ReportController extends AbstractController
|
||||
])->render();
|
||||
|
||||
$contents[] = '<p> </p>';
|
||||
$contents[] = '<h2>' . Doo::translate('未完成的工作') . '</h2>';
|
||||
$contents[] = '<h2>' . Doo::translate($unfinishedTitle) . '</h2>';
|
||||
$contents[] = view('report', [
|
||||
'labels' => $labels,
|
||||
'datas' => $unfinishedDatas,
|
||||
])->render();
|
||||
|
||||
if ($type === Report::WEEKLY) {
|
||||
// 下周拟定计划:基于下周时间范围预生成候选任务
|
||||
$nextPlanDatas = [];
|
||||
if ($next_start_time && $next_end_time) {
|
||||
$next_tasks = ProjectTask::buildUnfinishedTaskQuery($user->userid, $next_start_time, $next_end_time, false)->get();
|
||||
if ($next_tasks->isNotEmpty()) {
|
||||
foreach ($next_tasks as $task) {
|
||||
$planTime = '-';
|
||||
if ($task->start_at || $task->end_at) {
|
||||
$startText = $task->start_at ? Carbon::parse($task->start_at)->format('Y-m-d H:i') : '';
|
||||
$endText = $task->end_at ? Carbon::parse($task->end_at)->format('Y-m-d H:i') : '';
|
||||
$planTime = trim($startText . ($endText ? (' ~ ' . $endText) : ''));
|
||||
}
|
||||
$nextPlanDatas[] = [
|
||||
'[' . $task->project->name . '] ' . $task->name,
|
||||
$planTime,
|
||||
$task->taskUser->where("owner", 1)->map(function ($item) {
|
||||
return User::userid2nickname($item->userid);
|
||||
})->implode(", "),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$contents[] = '<p> </p>';
|
||||
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $start_time->addWeek()->format("m/d") . "-" . $end_time->addWeek()->format("m/d") . "]</h2>";
|
||||
$contents[] = "<h2>" . Doo::translate("下周拟定计划") . "[" . $next_start_time->format("m/d") . "-" . $next_end_time->format("m/d") . "]</h2>";
|
||||
$contents[] = view('report', [
|
||||
'labels' => [
|
||||
Doo::translate('计划描述'),
|
||||
Doo::translate('计划时间'),
|
||||
Doo::translate('负责人'),
|
||||
],
|
||||
'datas' => [],
|
||||
'datas' => $nextPlanDatas,
|
||||
])->render();
|
||||
}
|
||||
|
||||
@@ -454,7 +491,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/detail 05. 报告详情
|
||||
* @api {get} api/report/detail 报告详情
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -501,11 +538,113 @@ class ReportController extends AbstractController
|
||||
$one->report_link = $link;
|
||||
$link->increment("num");
|
||||
}
|
||||
$analysis = ReportAnalysis::query()
|
||||
->whereRid($one->id)
|
||||
->whereUserid($user->userid)
|
||||
->first();
|
||||
if ($analysis) {
|
||||
$updatedAt = $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null;
|
||||
$one->setAttribute('ai_analysis', [
|
||||
'id' => $analysis->id,
|
||||
'text' => $analysis->analysis_text,
|
||||
'model' => $analysis->model,
|
||||
'updated_at' => $updatedAt,
|
||||
]);
|
||||
} else {
|
||||
$one->setAttribute('ai_analysis', null);
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", $one);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/mark 06. 标记已读/未读
|
||||
* @api {post} api/report/analysave 保存工作汇报 AI 分析
|
||||
*
|
||||
* @apiDescription 需要token身份,仅支持报告提交人或接收人保存分析
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup report
|
||||
* @apiName analysave
|
||||
*
|
||||
* @apiParam {Number} id 报告ID
|
||||
* @apiParam {String} text 分析内容(Markdown)
|
||||
* @apiParam {String} [model] 分析使用的模型标识(可选)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {Number} data.id 分析记录ID
|
||||
* @apiSuccess {String} data.text 分析内容(Markdown)
|
||||
* @apiSuccess {String} data.updated_at 最近更新时间
|
||||
*/
|
||||
public function analysave(): array
|
||||
{
|
||||
$user = User::auth();
|
||||
$id = intval(Request::input("id"));
|
||||
if ($id <= 0) {
|
||||
return Base::retError("缺少ID参数");
|
||||
}
|
||||
$text = trim((string)Request::input('text', ''));
|
||||
if ($text === '') {
|
||||
return Base::retError("分析内容不能为空");
|
||||
}
|
||||
$model = trim((string)Request::input('model', ''));
|
||||
|
||||
$report = Report::getOne($id);
|
||||
if (!$this->userCanAccessReport($report, $user)) {
|
||||
return Base::retError("无权访问该工作汇报");
|
||||
}
|
||||
|
||||
$analysis = ReportAnalysis::query()
|
||||
->whereRid($report->id)
|
||||
->whereUserid($user->userid)
|
||||
->first();
|
||||
|
||||
if (!$analysis) {
|
||||
$analysis = ReportAnalysis::fillInstance([
|
||||
'rid' => $report->id,
|
||||
'userid' => $user->userid,
|
||||
]);
|
||||
}
|
||||
|
||||
$viewerRole = $user->profession ?: (is_array($user->identity) && !empty($user->identity) ? implode('/', $user->identity) : null);
|
||||
$focusMeta = null;
|
||||
$focus = Request::input('focus');
|
||||
if (is_array($focus)) {
|
||||
$focusMeta = array_filter(array_map('trim', $focus));
|
||||
} elseif (is_string($focus) && trim($focus) !== '') {
|
||||
$focusMeta = [trim($focus)];
|
||||
}
|
||||
|
||||
$meta = array_filter([
|
||||
'viewer_role' => $viewerRole,
|
||||
'viewer_name' => $user->nickname ?? null,
|
||||
'focus' => $focusMeta,
|
||||
], function ($value) {
|
||||
if (is_array($value)) {
|
||||
return !empty($value);
|
||||
}
|
||||
return $value !== null && $value !== '';
|
||||
});
|
||||
|
||||
$analysis->updateInstance([
|
||||
'model' => $model,
|
||||
'analysis_text' => $text,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
$analysis->save();
|
||||
|
||||
$analysis->refresh();
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'id' => $analysis->id,
|
||||
'text' => $analysis->analysis_text,
|
||||
'model' => $analysis->model,
|
||||
'updated_at' => $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/mark 标记已读/未读
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -548,7 +687,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/share 07. 分享报告到消息
|
||||
* @api {get} api/report/share 分享报告到消息
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -610,7 +749,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/last_submitter 08. 获取最后一次提交的接收人
|
||||
* @api {get} api/report/last_submitter 获取最后一次提交的接收人
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -628,7 +767,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/unread 09. 获取未读
|
||||
* @api {get} api/report/unread 获取未读
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -653,7 +792,7 @@ class ReportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/report/read 10. 标记汇报已读,可批量
|
||||
* @api {get} api/report/read 标记汇报已读,可批量
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -691,4 +830,22 @@ class ReportController extends AbstractController
|
||||
}
|
||||
return Base::retSuccess("success", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否有权限查看/分析指定工作汇报
|
||||
* @param Report $report
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
protected function userCanAccessReport(Report $report, User $user): bool
|
||||
{
|
||||
if ($report->userid === $user->userid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ReportReceive::query()
|
||||
->whereRid($report->id)
|
||||
->whereUserid($user->userid)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
619
app/Http/Controllers/Api/SearchController.php
Normal file
619
app/Http/Controllers/Api/SearchController.php
Normal file
@@ -0,0 +1,619 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Request;
|
||||
use App\Models\File;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Base;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreFile;
|
||||
use App\Module\Manticore\ManticoreUser;
|
||||
use App\Module\Manticore\ManticoreProject;
|
||||
use App\Module\Manticore\ManticoreTask;
|
||||
use App\Module\Manticore\ManticoreMsg;
|
||||
|
||||
/**
|
||||
* @apiDefine search
|
||||
*
|
||||
* 智能搜索
|
||||
*/
|
||||
class SearchController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {get} api/search/contact 搜索联系人
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName contact
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function contact()
|
||||
{
|
||||
User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreUser::search($key, $searchType, $take);
|
||||
|
||||
// 补充用户完整信息
|
||||
$userids = array_column($results, 'userid');
|
||||
if (!empty($userids)) {
|
||||
$users = User::whereIn('userid', $userids)
|
||||
->select(User::$basicField)
|
||||
->get()
|
||||
->keyBy('userid');
|
||||
|
||||
foreach ($results as &$item) {
|
||||
$userData = $users->get($item['userid']);
|
||||
if ($userData) {
|
||||
// 标签直接从 Manticore 搜索结果获取(空格分隔的字符串转数组)
|
||||
$tagsStr = $item['tags'] ?? '';
|
||||
$searchTags = !empty($tagsStr) ? preg_split('/\s+/', trim($tagsStr)) : [];
|
||||
|
||||
$item = array_merge($userData->toArray(), [
|
||||
'relevance' => $item['relevance'] ?? 0,
|
||||
'introduction_preview' => $item['introduction_preview'] ?? null,
|
||||
'search_tags' => $searchTags,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchContactByMysql($key, $take);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索联系人
|
||||
*
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchContactByMysql(string $key, int $take): array
|
||||
{
|
||||
$users = User::select(User::$basicField)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->searchByKeyword($key)
|
||||
->orderByDesc('line_at')
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
// 获取用户标签
|
||||
$userids = $users->pluck('userid')->toArray();
|
||||
$userTags = $this->getUserTagsMap($userids);
|
||||
|
||||
return $users->map(function ($user) use ($userTags) {
|
||||
return array_merge($user->toArray(), [
|
||||
'relevance' => 0,
|
||||
'introduction_preview' => null,
|
||||
'search_tags' => $userTags[$user->userid] ?? [],
|
||||
]);
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/project 搜索项目
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName project
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function project()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreProject::search($user->userid, $key, $searchType, $take);
|
||||
|
||||
// 补充项目完整信息
|
||||
$projectIds = array_column($results, 'project_id');
|
||||
if (!empty($projectIds)) {
|
||||
$projects = Project::whereIn('id', $projectIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($results as &$item) {
|
||||
$projectData = $projects->get($item['project_id']);
|
||||
if ($projectData) {
|
||||
$item = array_merge($projectData->toArray(), [
|
||||
'relevance' => $item['relevance'] ?? 0,
|
||||
'desc_preview' => $item['desc_preview'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchProjectByMysql($user->userid, $key, $take);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索项目
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchProjectByMysql(int $userid, string $key, int $take): array
|
||||
{
|
||||
$projects = Project::authData()
|
||||
->whereNull('projects.archived_at')
|
||||
->searchByKeyword($key)
|
||||
->orderByDesc('projects.id')
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
return $projects->map(function ($project) {
|
||||
$array = $project->toArray();
|
||||
$array['relevance'] = 0;
|
||||
$array['desc_preview'] = null;
|
||||
return $array;
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/task 搜索任务
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName task
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function task()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreTask::search($user->userid, $key, $searchType, $take);
|
||||
|
||||
// 补充任务完整信息
|
||||
$taskIds = array_column($results, 'task_id');
|
||||
if (!empty($taskIds)) {
|
||||
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
|
||||
->whereIn('id', $taskIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($results as &$item) {
|
||||
$taskData = $tasks->get($item['task_id']);
|
||||
if ($taskData) {
|
||||
$item = array_merge($taskData->toArray(), [
|
||||
'relevance' => $item['relevance'] ?? 0,
|
||||
'desc_preview' => $item['desc_preview'] ?? null,
|
||||
'content_preview' => $item['content_preview'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchTaskByMysql($user->userid, $key, $take);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索任务
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchTaskByMysql(int $userid, string $key, int $take): array
|
||||
{
|
||||
$tasks = ProjectTask::with(['taskUser', 'taskTag'])
|
||||
->whereIn('project_tasks.project_id', function ($query) use ($userid) {
|
||||
$query->select('project_id')
|
||||
->from('project_users')
|
||||
->where('userid', $userid);
|
||||
})
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->searchByKeyword($key)
|
||||
->orderByDesc('project_tasks.id')
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
return $tasks->map(function ($task) {
|
||||
$array = $task->toArray();
|
||||
$array['relevance'] = 0;
|
||||
$array['desc_preview'] = null;
|
||||
$array['content_preview'] = null;
|
||||
return $array;
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/file 搜索文件
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName file
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function file()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreFile::search($user->userid, $key, $searchType, 0, $take);
|
||||
|
||||
// 补充文件完整信息
|
||||
$fileIds = array_column($results, 'file_id');
|
||||
if (!empty($fileIds)) {
|
||||
$files = File::whereIn('id', $fileIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$formattedResults = [];
|
||||
foreach ($results as $item) {
|
||||
$fileData = $files->get($item['file_id']);
|
||||
if ($fileData) {
|
||||
$formattedResults[] = array_merge($fileData->toArray(), [
|
||||
'relevance' => $item['relevance'] ?? 0,
|
||||
'content_preview' => $item['content_preview'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', $formattedResults);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', []);
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchFileByMysql($user->userid, $key, $take);
|
||||
return Base::retSuccess('success', $results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索文件
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @return array
|
||||
*/
|
||||
private function searchFileByMysql(int $userid, string $key, int $take): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
// 搜索用户自己的文件
|
||||
$ownFiles = File::where('userid', $userid)
|
||||
->searchByKeyword($key)
|
||||
->take($take)
|
||||
->get();
|
||||
|
||||
foreach ($ownFiles as $file) {
|
||||
$results[] = array_merge($file->toArray(), [
|
||||
'relevance' => 0,
|
||||
'content_preview' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// 搜索共享给用户的文件
|
||||
$remaining = $take - count($results);
|
||||
if ($remaining > 0) {
|
||||
$sharedFiles = File::sharedToUser($userid)
|
||||
->searchByKeyword($key)
|
||||
->take($remaining)
|
||||
->get();
|
||||
|
||||
foreach ($sharedFiles as $file) {
|
||||
$temp = $file->toArray();
|
||||
if ($file->pshare === $file->id) {
|
||||
$temp['pid'] = 0;
|
||||
}
|
||||
$temp['relevance'] = 0;
|
||||
$temp['content_preview'] = null;
|
||||
$results[] = $temp;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/search/message 搜索消息
|
||||
*
|
||||
* @apiDescription 需要token身份,优先使用 Manticore Search,未安装则使用 MySQL 搜索
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup search
|
||||
* @apiName message
|
||||
*
|
||||
* @apiParam {String} key 搜索关键词
|
||||
* @apiParam {String} [search_type] 搜索类型(text/vector/hybrid,默认:hybrid,仅 Manticore 有效)
|
||||
* @apiParam {Number} [take] 获取数量(默认:20,最大:50)
|
||||
* @apiParam {String} [mode] 返回模式(message/position/dialog,默认:message)
|
||||
* - message: 返回消息详细信息
|
||||
* - position: 只返回消息ID
|
||||
* - dialog: 返回对话级数据
|
||||
* @apiParam {Number} [dialog_id] 对话ID(筛选指定对话内的消息)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function message()
|
||||
{
|
||||
$user = User::auth();
|
||||
|
||||
$key = trim(Request::input('key'));
|
||||
$searchType = Request::input('search_type', 'hybrid');
|
||||
$take = Base::getPaginate(50, 20, 'take');
|
||||
$mode = Request::input('mode', 'message');
|
||||
$dialogId = intval(Request::input('dialog_id', 0));
|
||||
|
||||
// 验证 mode 参数
|
||||
if (!in_array($mode, ['message', 'position', 'dialog'])) {
|
||||
$mode = 'message';
|
||||
}
|
||||
|
||||
if (empty($key)) {
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
|
||||
// 如果指定了 dialog_id,需要验证用户有权限访问该对话
|
||||
if ($dialogId > 0) {
|
||||
WebSocketDialog::checkDialog($dialogId);
|
||||
}
|
||||
|
||||
// 优先使用 Manticore 搜索
|
||||
if (Apps::isInstalled('search')) {
|
||||
$results = ManticoreMsg::search($user->userid, $key, $searchType, 0, $take, $dialogId);
|
||||
} else {
|
||||
// MySQL 回退搜索
|
||||
$results = $this->searchMessageByMysql($user->userid, $key, $take, $dialogId);
|
||||
}
|
||||
|
||||
// 根据 mode 返回不同格式的数据
|
||||
return $this->formatMessageResults($results, $mode, $user->userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索消息
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $key 搜索关键词
|
||||
* @param int $take 获取数量
|
||||
* @param int $dialogId 对话ID(0表示不限制)
|
||||
* @return array
|
||||
*/
|
||||
private function searchMessageByMysql(int $userid, string $key, int $take, int $dialogId = 0): array
|
||||
{
|
||||
$builder = WebSocketDialogMsg::select([
|
||||
'id as msg_id',
|
||||
'dialog_id',
|
||||
'userid',
|
||||
'type',
|
||||
'msg',
|
||||
'created_at',
|
||||
])
|
||||
->accessibleByUser($userid)
|
||||
->where('bot', 0)
|
||||
->searchByKeyword($key);
|
||||
|
||||
if ($dialogId > 0) {
|
||||
$builder->where('dialog_id', $dialogId);
|
||||
}
|
||||
|
||||
$items = $builder->orderByDesc('id')
|
||||
->limit($take)
|
||||
->get();
|
||||
|
||||
return $items->map(function ($item) {
|
||||
return [
|
||||
'msg_id' => $item->msg_id,
|
||||
'dialog_id' => $item->dialog_id,
|
||||
'userid' => $item->userid,
|
||||
'type' => $item->type,
|
||||
'msg' => $item->msg,
|
||||
'created_at' => $item->created_at,
|
||||
'relevance' => 0,
|
||||
'content_preview' => null,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息搜索结果
|
||||
*
|
||||
* @param array $results 搜索结果
|
||||
* @param string $mode 返回模式
|
||||
* @param int $userid 用户ID
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
private function formatMessageResults(array $results, string $mode, int $userid)
|
||||
{
|
||||
switch ($mode) {
|
||||
case 'position':
|
||||
// 只返回消息ID
|
||||
$data = array_column($results, 'msg_id');
|
||||
return Base::retSuccess('success', compact('data'));
|
||||
|
||||
case 'dialog':
|
||||
// 返回对话级数据
|
||||
$list = [];
|
||||
$seenDialogs = [];
|
||||
foreach ($results as $item) {
|
||||
$dialogIdFromResult = $item['dialog_id'];
|
||||
// 每个对话只返回一次
|
||||
if (isset($seenDialogs[$dialogIdFromResult])) {
|
||||
continue;
|
||||
}
|
||||
$seenDialogs[$dialogIdFromResult] = true;
|
||||
|
||||
if ($dialog = WebSocketDialog::find($dialogIdFromResult)) {
|
||||
$dialogData = array_merge($dialog->toArray(), [
|
||||
'search_msg_id' => $item['msg_id'],
|
||||
]);
|
||||
$list[] = WebSocketDialog::synthesizeData($dialogData, $userid);
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', ['data' => $list]);
|
||||
|
||||
case 'message':
|
||||
default:
|
||||
// 返回消息详细信息(默认行为)
|
||||
$msgIds = array_column($results, 'msg_id');
|
||||
if (!empty($msgIds)) {
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $msgIds)
|
||||
->with(['user' => function ($query) {
|
||||
$query->select(User::$basicField);
|
||||
}])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 创建结果映射以保持原始顺序和额外字段
|
||||
$resultsMap = [];
|
||||
foreach ($results as $item) {
|
||||
$resultsMap[$item['msg_id']] = $item;
|
||||
}
|
||||
|
||||
$formattedResults = [];
|
||||
foreach ($msgIds as $msgId) {
|
||||
$msgData = $msgs->get($msgId);
|
||||
$originalItem = $resultsMap[$msgId] ?? [];
|
||||
if ($msgData) {
|
||||
$formattedResults[] = [
|
||||
'id' => $msgData->id,
|
||||
'msg_id' => $msgData->id,
|
||||
'dialog_id' => $msgData->dialog_id,
|
||||
'userid' => $msgData->userid,
|
||||
'type' => $msgData->type,
|
||||
'msg' => $msgData->msg,
|
||||
'created_at' => $msgData->created_at,
|
||||
'user' => $msgData->user,
|
||||
'relevance' => $originalItem['relevance'] ?? 0,
|
||||
'content_preview' => $originalItem['content_preview'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', $formattedResults);
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取用户标签映射
|
||||
*
|
||||
* @param array $userids 用户ID数组
|
||||
* @return array 用户ID => 标签名称数组的映射
|
||||
*/
|
||||
private function getUserTagsMap(array $userids): array
|
||||
{
|
||||
if (empty($userids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取所有用户的标签(带认可数)
|
||||
$tags = UserTag::whereIn('user_id', $userids)
|
||||
->withCount('recognitions')
|
||||
->get();
|
||||
|
||||
// 按用户分组,每个用户取 Top 10 标签
|
||||
$result = [];
|
||||
foreach ($userids as $userid) {
|
||||
$result[$userid] = [];
|
||||
}
|
||||
|
||||
$userTags = $tags->groupBy('user_id');
|
||||
foreach ($userTags as $userid => $tagCollection) {
|
||||
$result[$userid] = $tagCollection
|
||||
->sortByDesc('recognitions_count')
|
||||
->take(10)
|
||||
->pluck('name')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Models\UserDevice;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\AI;
|
||||
use App\Module\Down;
|
||||
use Request;
|
||||
use Session;
|
||||
use Response;
|
||||
use Madzipper;
|
||||
use Carbon\Carbon;
|
||||
@@ -15,7 +16,6 @@ use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\Timer;
|
||||
use App\Models\Setting;
|
||||
use App\Module\Extranet;
|
||||
use LdapRecord\Container;
|
||||
use App\Module\BillExport;
|
||||
use Guanguans\Notify\Factory;
|
||||
@@ -35,7 +35,7 @@ class SystemController extends AbstractController
|
||||
{
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting 01. 获取设置、保存设置
|
||||
* @api {get} api/system/setting 获取设置、保存设置
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -44,7 +44,7 @@ class SystemController extends AbstractController
|
||||
* @apiParam {String} type
|
||||
* - get: 获取(默认)
|
||||
* - all: 获取所有(需要管理员权限)
|
||||
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'voice2text', 'translation', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local'])
|
||||
* - save: 保存设置(参数:['reg', 'reg_identity', 'reg_invite', 'temp_account_alias', 'login_code', 'password_policy', 'project_invite', 'chat_information', 'anon_message', 'convert_video', 'compress_video', 'e2e_message', 'auto_archived', 'archived_day', 'task_visible', 'task_default_time', 'task_user_limit', 'all_group_mute', 'all_group_autoin', 'user_private_chat_mute', 'user_group_chat_mute', 'system_alias', 'system_welcome', 'image_compress', 'image_quality', 'image_save_local'])
|
||||
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@@ -69,10 +69,10 @@ class SystemController extends AbstractController
|
||||
'login_code',
|
||||
'password_policy',
|
||||
'project_invite',
|
||||
'project_add_permission',
|
||||
'project_add_userids',
|
||||
'chat_information',
|
||||
'anon_message',
|
||||
'voice2text',
|
||||
'translation',
|
||||
'convert_video',
|
||||
'compress_video',
|
||||
'e2e_message',
|
||||
@@ -82,6 +82,7 @@ class SystemController extends AbstractController
|
||||
'archived_day',
|
||||
'task_visible',
|
||||
'task_default_time',
|
||||
'task_user_limit',
|
||||
'all_group_mute',
|
||||
'all_group_autoin',
|
||||
'user_private_chat_mute',
|
||||
@@ -94,6 +95,9 @@ class SystemController extends AbstractController
|
||||
'file_upload_limit',
|
||||
'unclaimed_task_reminder',
|
||||
'unclaimed_task_reminder_time',
|
||||
'task_ai_auto_analyze',
|
||||
'department_owner_project_view',
|
||||
'todo_set_permission',
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
}
|
||||
@@ -106,12 +110,6 @@ class SystemController extends AbstractController
|
||||
return Base::retError('自动归档时间不可大于100天!');
|
||||
}
|
||||
}
|
||||
if ($all['voice2text'] == 'open' && !Setting::AIOpen()) {
|
||||
return Base::retError('开启语音转文字功能需要在应用中开启 ChatGPT AI 机器人。');
|
||||
}
|
||||
if ($all['translation'] == 'open' && !Setting::AIOpen()) {
|
||||
return Base::retError('开启翻译功能需要在应用中开启 ChatGPT AI 机器人。');
|
||||
}
|
||||
if ($all['system_alias'] == env('APP_NAME')) {
|
||||
$all['system_alias'] = '';
|
||||
}
|
||||
@@ -138,8 +136,6 @@ class SystemController extends AbstractController
|
||||
$setting['project_invite'] = $setting['project_invite'] ?: 'open';
|
||||
$setting['chat_information'] = $setting['chat_information'] ?: 'optional';
|
||||
$setting['anon_message'] = $setting['anon_message'] ?: 'open';
|
||||
$setting['voice2text'] = $setting['voice2text'] ?: 'close';
|
||||
$setting['translation'] = $setting['translation'] ?: 'close';
|
||||
$setting['convert_video'] = $setting['convert_video'] ?: 'close';
|
||||
$setting['compress_video'] = $setting['compress_video'] ?: 'close';
|
||||
$setting['e2e_message'] = $setting['e2e_message'] ?: 'close';
|
||||
@@ -149,20 +145,27 @@ class SystemController extends AbstractController
|
||||
$setting['archived_day'] = floatval($setting['archived_day']) ?: 7;
|
||||
$setting['task_visible'] = $setting['task_visible'] ?: 'close';
|
||||
$setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open';
|
||||
$setting['todo_set_permission'] = $setting['todo_set_permission'] ?: 'open';
|
||||
$setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes';
|
||||
$setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open';
|
||||
$setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open';
|
||||
$setting['file_upload_limit'] = $setting['file_upload_limit'] ?: '';
|
||||
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
|
||||
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
|
||||
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
|
||||
$setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
|
||||
$setting['server_timezone'] = config('app.timezone');
|
||||
$setting['server_version'] = Base::getVersion();
|
||||
// 指定人员名单仅管理员可见
|
||||
if ($type != 'all' && $type != 'save') {
|
||||
unset($setting['project_add_userids']);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/email 02. 获取邮箱设置、保存邮箱设置(限管理员)
|
||||
* @api {get} api/system/setting/email 获取邮箱设置、保存邮箱设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -228,11 +231,11 @@ class SystemController extends AbstractController
|
||||
$setting = array_intersect_key($setting, array_flip(['reg_verify']));
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/meeting 03. 获取会议设置、保存会议设置(限管理员)
|
||||
* @api {get} api/system/setting/meeting 获取会议设置、保存会议设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -282,11 +285,21 @@ class SystemController extends AbstractController
|
||||
$setting['api_secret'] = substr($setting['api_secret'], 0, 4) . str_repeat('*', strlen($setting['api_secret']) - 8) . substr($setting['api_secret'], -4);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot 04. 获取会议设置、保存AI机器人设置(限管理员)
|
||||
* AI助手设置(限管理员)
|
||||
*
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__ai()
|
||||
{
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot 获取AI设置、保存AI机器人设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -340,70 +353,31 @@ class SystemController extends AbstractController
|
||||
}
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot_models 05. 获取AI模型
|
||||
* 获取AI模型
|
||||
*
|
||||
* @apiDescription 获取所有AI机器人模型设置
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName aibot_models
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__aibot_models()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
$setting = array_filter($setting, function($value, $key) {
|
||||
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/aibot_defmodels 06. 获取AI默认模型
|
||||
* 获取AI默认模型
|
||||
*
|
||||
* @apiDescription 获取AI机器人默认模型
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName setting__aibot_defmodels
|
||||
*
|
||||
* @apiParam {String} type AI类型
|
||||
* @apiParam {String} [base_url] 基础URL(仅 type=ollama 时有效)
|
||||
* @apiParam {String} [key] Key(仅 type=ollama 时有效)
|
||||
* @apiParam {String} [agency] 使用代理(仅 type=ollama 时有效)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @deprecated 已废弃方法,仅保留路由占位,后续版本中移除
|
||||
*/
|
||||
public function setting__aibot_defmodels()
|
||||
{
|
||||
$type = trim(Request::input('type'));
|
||||
if ($type == 'ollama') {
|
||||
$baseUrl = trim(Request::input('base_url'));
|
||||
$key = trim(Request::input('key'));
|
||||
$agency = trim(Request::input('agency'));
|
||||
if (empty($baseUrl)) {
|
||||
return Base::retError('请先填写 Base URL');
|
||||
}
|
||||
return Extranet::ollamaModels($baseUrl, $key, $agency);
|
||||
}
|
||||
$models = Setting::AIDefaultModels($type);
|
||||
if (empty($models)) {
|
||||
return Base::retError('未找到默认模型');
|
||||
}
|
||||
return Base::retSuccess('success', [
|
||||
'models' => $models
|
||||
]);
|
||||
Base::checkClientVersion('1.4.35');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/checkin 07. 获取签到设置、保存签到设置(限管理员)
|
||||
* @api {get} api/system/setting/checkin 获取签到设置、保存签到设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -439,8 +413,13 @@ class SystemController extends AbstractController
|
||||
'face_remark',
|
||||
'face_retip',
|
||||
'locat_remark',
|
||||
'locat_map_type',
|
||||
'locat_bd_lbs_key',
|
||||
'locat_bd_lbs_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
|
||||
'locat_amap_key',
|
||||
'locat_amap_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
|
||||
'locat_tencent_key',
|
||||
'locat_tencent_point', // 格式:{"lng":116.404, "lat":39.915, "radius":500}
|
||||
'manual_remark',
|
||||
'modes',
|
||||
'key',
|
||||
@@ -458,14 +437,25 @@ class SystemController extends AbstractController
|
||||
}
|
||||
if (is_array($all['modes'])) {
|
||||
if (in_array('locat', $all['modes'])) {
|
||||
if (empty($all['locat_bd_lbs_key'])) {
|
||||
return Base::retError('请填写百度地图AK');
|
||||
$mapTypes = [
|
||||
'baidu' => ['key' => 'locat_bd_lbs_key', 'point' => 'locat_bd_lbs_point', 'msg' => '请填写百度地图AK'],
|
||||
'amap' => ['key' => 'locat_amap_key', 'point' => 'locat_amap_point', 'msg' => '请填写高德地图Key'],
|
||||
'tencent' => ['key' => 'locat_tencent_key', 'point' => 'locat_tencent_point', 'msg' => '请填写腾讯地图Key'],
|
||||
];
|
||||
$type = $all['locat_map_type'];
|
||||
if (!isset($mapTypes[$type])) {
|
||||
return Base::retError('请选择地图类型');
|
||||
}
|
||||
if (!is_array($all['locat_bd_lbs_point'])) {
|
||||
$conf = $mapTypes[$type];
|
||||
if (empty($all[$conf['key']])) {
|
||||
return Base::retError($conf['msg']);
|
||||
}
|
||||
if (!is_array($all[$conf['point']])) {
|
||||
return Base::retError('请选择允许签到位置');
|
||||
}
|
||||
$all['locat_bd_lbs_point']['radius'] = intval($all['locat_bd_lbs_point']['radius']);
|
||||
if (empty($all['locat_bd_lbs_point']['lng']) || empty($all['locat_bd_lbs_point']['lat']) || empty($all['locat_bd_lbs_point']['radius'])) {
|
||||
$all[$conf['point']]['radius'] = intval($all[$conf['point']]['radius']);
|
||||
$point = $all[$conf['point']];
|
||||
if (empty($point['lng']) || empty($point['lat']) || empty($point['radius'])) {
|
||||
return Base::retError('请选择有效的签到位置');
|
||||
}
|
||||
}
|
||||
@@ -478,6 +468,24 @@ class SystemController extends AbstractController
|
||||
if ($all['modes']) {
|
||||
$all['modes'] = array_intersect($all['modes'], ['auto', 'manual', 'locat', 'face']);
|
||||
}
|
||||
// 验证提前和延后时间是否重叠(跨天打卡支持)
|
||||
if ($all['open'] === 'open') {
|
||||
$times = is_array($all['time']) ? $all['time'] : Base::json2array($all['time']);
|
||||
if (count($times) >= 2) {
|
||||
$startMinutes = intval(substr($times[0], 0, 2)) * 60 + intval(substr($times[0], 3, 2));
|
||||
$endMinutes = intval(substr($times[1], 0, 2)) * 60 + intval(substr($times[1], 3, 2));
|
||||
$shiftDuration = $endMinutes - $startMinutes;
|
||||
if ($shiftDuration <= 0) {
|
||||
$shiftDuration += 24 * 60; // 处理跨天班次
|
||||
}
|
||||
$advance = intval($all['advance']) ?: 120;
|
||||
$delay = intval($all['delay']) ?: 120;
|
||||
$maxAllowed = 24 * 60 - $shiftDuration;
|
||||
if ($advance + $delay >= $maxAllowed) {
|
||||
return Base::retError('提前和延后时间设置存在重叠,最大提前+延后时间不能超过 ' . ($maxAllowed - 1) . ' 分钟');
|
||||
}
|
||||
}
|
||||
}
|
||||
$setting = Base::setting('checkinSetting', Base::newTrim($all));
|
||||
} else {
|
||||
$setting = Base::setting('checkinSetting');
|
||||
@@ -497,7 +505,10 @@ class SystemController extends AbstractController
|
||||
$setting['face_remark'] = $setting['face_remark'] ?: Doo::translate('考勤机');
|
||||
$setting['face_retip'] = $setting['face_retip'] ?: 'open';
|
||||
$setting['locat_remark'] = $setting['locat_remark'] ?: Doo::translate('定位签到');
|
||||
$setting['locat_map_type'] = $setting['locat_map_type'] ?: 'baidu';
|
||||
$setting['locat_bd_lbs_point'] = is_array($setting['locat_bd_lbs_point']) ? $setting['locat_bd_lbs_point'] : ['radius' => 500];
|
||||
$setting['locat_amap_point'] = is_array($setting['locat_amap_point']) ? $setting['locat_amap_point'] : ['radius' => 500];
|
||||
$setting['locat_tencent_point'] = is_array($setting['locat_tencent_point']) ? $setting['locat_tencent_point'] : ['radius' => 500];
|
||||
$setting['manual_remark'] = $setting['manual_remark'] ?: Doo::translate('手动签到');
|
||||
$setting['time'] = $setting['time'] ? Base::json2array($setting['time']) : ['09:00', '18:00'];
|
||||
$setting['advance'] = intval($setting['advance']) ?: 120;
|
||||
@@ -511,11 +522,11 @@ class SystemController extends AbstractController
|
||||
$setting['cmd'] = base64_encode($setting['cmd']);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/apppush 08. 获取APP推送设置、保存APP推送设置(限管理员)
|
||||
* @api {get} api/system/setting/apppush 获取APP推送设置、保存APP推送设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -556,11 +567,11 @@ class SystemController extends AbstractController
|
||||
//
|
||||
$setting['push'] = $setting['push'] ?: 'close';
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/thirdaccess 09. 第三方帐号(限管理员)
|
||||
* @api {get} api/system/setting/thirdaccess 第三方帐号(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -611,6 +622,7 @@ class SystemController extends AbstractController
|
||||
'ldap_password',
|
||||
'ldap_user_dn',
|
||||
'ldap_base_dn',
|
||||
'ldap_login_attr',
|
||||
'ldap_sync_local'
|
||||
])) {
|
||||
unset($all[$key]);
|
||||
@@ -624,13 +636,14 @@ class SystemController extends AbstractController
|
||||
//
|
||||
$setting['ldap_open'] = $setting['ldap_open'] ?: 'close';
|
||||
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
|
||||
$setting['ldap_login_attr'] = $setting['ldap_login_attr'] ?: 'cn';
|
||||
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/setting/file 10. 文件设置(限管理员)
|
||||
* @api {get} api/system/setting/file 文件设置(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -666,11 +679,11 @@ class SystemController extends AbstractController
|
||||
$setting = Base::setting('fileSetting');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting ?: json_decode('{}'));
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/demo 11. 获取演示帐号
|
||||
* @api {get} api/system/demo 获取演示帐号
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -694,7 +707,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/priority 12. 任务优先级
|
||||
* @api {post} api/system/priority 任务优先级
|
||||
*
|
||||
* @apiDescription 获取任务优先级、保存任务优先级
|
||||
* @apiVersion 1.0.0
|
||||
@@ -716,34 +729,64 @@ class SystemController extends AbstractController
|
||||
if ($type == 'save') {
|
||||
User::auth('admin');
|
||||
$list = Request::input('list');
|
||||
$array = [];
|
||||
if (empty($list) || !is_array($list)) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
foreach ($list AS $item) {
|
||||
if (empty($item['name']) || empty($item['color']) || empty($item['priority'])) {
|
||||
continue;
|
||||
}
|
||||
$array[] = [
|
||||
'name' => $item['name'],
|
||||
'color' => $item['color'],
|
||||
'days' => intval($item['days']),
|
||||
'priority' => intval($item['priority']),
|
||||
];
|
||||
}
|
||||
$array = Setting::normalizeTaskPriorityList($list);
|
||||
if (empty($array)) {
|
||||
return Base::retError('参数为空');
|
||||
}
|
||||
$setting = Base::setting('priority', $array);
|
||||
} else {
|
||||
$setting = Base::setting('priority');
|
||||
$setting = Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting);
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/column/template 13. 创建项目模板
|
||||
* @api {post} api/system/microapp_menu 自定义应用菜单
|
||||
*
|
||||
* @apiDescription 获取或保存自定义微应用菜单,仅管理员可配置
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName microapp_menu
|
||||
*
|
||||
* @apiParam {String} type
|
||||
* - get: 获取(默认)
|
||||
* - save: 保存(限管理员)
|
||||
* @apiParam {Array} list 菜单列表,格式:[{id,name,version,menu_items}]
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function microapp_menu()
|
||||
{
|
||||
$type = trim(Request::input('type'));
|
||||
$user = User::auth();
|
||||
if ($type == 'save') {
|
||||
User::auth('admin');
|
||||
$list = Request::input('list');
|
||||
if (empty($list) || !is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
$apps = Setting::normalizeCustomMicroApps($list);
|
||||
$setting = Base::setting('microapp_menu', $apps);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
||||
} else {
|
||||
$setting = Base::setting('microapp_menu');
|
||||
if (!is_array($setting)) {
|
||||
$setting = [];
|
||||
}
|
||||
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
|
||||
$setting = Setting::formatCustomMicroAppsForResponse($setting);
|
||||
}
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/column/template 创建项目模板
|
||||
*
|
||||
* @apiDescription 获取创建项目模板、保存创建项目模板
|
||||
* @apiVersion 1.0.0
|
||||
@@ -786,11 +829,11 @@ class SystemController extends AbstractController
|
||||
$setting = Base::setting('columnTemplate');
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $setting);
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/license 14. License
|
||||
* @api {post} api/system/license License
|
||||
*
|
||||
* @apiDescription 获取License信息、保存License(限管理员)
|
||||
* @apiVersion 1.0.0
|
||||
@@ -856,11 +899,11 @@ class SystemController extends AbstractController
|
||||
];
|
||||
}
|
||||
//
|
||||
return Base::retSuccess('success', $data);
|
||||
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $data ?: json_decode('{}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/info 15. 获取终端详细信息
|
||||
* @api {get} api/system/get/info 获取终端详细信息
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -879,8 +922,6 @@ class SystemController extends AbstractController
|
||||
}
|
||||
return Base::retSuccess('success', [
|
||||
'ip' => Base::getIp(),
|
||||
'ip-info' => Extranet::getIpInfo(Base::getIp()),
|
||||
'ip-gcj02' => Extranet::getIpGcj02(Base::getIp()),
|
||||
'ip-iscn' => Base::isCnIp(Base::getIp()),
|
||||
'header' => Request::header(),
|
||||
'token' => Doo::userToken(),
|
||||
@@ -889,7 +930,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/ip 16. 获取IP地址
|
||||
* @api {get} api/system/get/ip 获取IP地址
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -904,7 +945,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/cnip 17. 是否中国IP地址
|
||||
* @api {get} api/system/get/cnip 是否中国IP地址
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -921,41 +962,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/ipgcj02 18. 获取IP地址经纬度
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName get__ipgcj02
|
||||
*
|
||||
* @apiParam {String} ip IP值
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function get__ipgcj02() {
|
||||
return Extranet::getIpGcj02(Request::input("ip"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/ipinfo 19. 获取IP地址详细信息
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
* @apiName get__ipinfo
|
||||
*
|
||||
* @apiParam {String} ip IP值
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function get__ipinfo() {
|
||||
return Extranet::getIpInfo(Request::input("ip"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/imgupload 20. 上传图片
|
||||
* @api {post} api/system/imgupload 上传图片
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1021,7 +1028,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/imgview 21. 浏览图片空间
|
||||
* @api {get} api/system/get/imgview 浏览图片空间
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1118,7 +1125,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/system/fileupload 22. 上传文件
|
||||
* @api {post} api/system/fileupload 上传文件
|
||||
*
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1162,7 +1169,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/get/updatelog 23. 获取更新日志
|
||||
* @api {get} api/system/get/updatelog 获取更新日志
|
||||
*
|
||||
* @apiDescription 获取更新日志
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1205,7 +1212,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/email/check 24. 邮件发送测试(限管理员)
|
||||
* @api {get} api/system/email/check 邮件发送测试(限管理员)
|
||||
*
|
||||
* @apiDescription 测试配置邮箱是否能发送邮件
|
||||
* @apiVersion 1.0.0
|
||||
@@ -1251,7 +1258,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/checkin/export 25. 导出签到数据(限管理员)
|
||||
* @api {get} api/system/checkin/export 导出签到数据(限管理员)
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -1296,6 +1303,8 @@ class SystemController extends AbstractController
|
||||
//
|
||||
$secondStart = strtotime("2000-01-01 {$time[0]}") - strtotime("2000-01-01 00:00:00");
|
||||
$secondEnd = strtotime("2000-01-01 {$time[1]}") - strtotime("2000-01-01 00:00:00");
|
||||
// 获取延后时间配置(用于跨天打卡导出)
|
||||
$delaySeconds = (intval($setting['delay']) ?: 120) * 60;
|
||||
//
|
||||
$botUser = User::botGetOrCreate('system-msg');
|
||||
if (empty($botUser)) {
|
||||
@@ -1303,18 +1312,19 @@ class SystemController extends AbstractController
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $user->userid);
|
||||
//
|
||||
go(function () use ($secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog) {
|
||||
$doo = Doo::load();
|
||||
go(function () use ($doo, $secondStart, $secondEnd, $time, $userid, $date, $user, $botUser, $dialog, $delaySeconds) {
|
||||
Coroutine::sleep(1);
|
||||
//
|
||||
$headings = [];
|
||||
$headings[] = Doo::translate('签到人');
|
||||
$headings[] = Doo::translate('签到日期');
|
||||
$headings[] = Doo::translate('班次时间');
|
||||
$headings[] = Doo::translate('首次签到时间');
|
||||
$headings[] = Doo::translate('首次签到结果');
|
||||
$headings[] = Doo::translate('最后签到时间');
|
||||
$headings[] = Doo::translate('最后签到结果');
|
||||
$headings[] = Doo::translate('参数数据');
|
||||
$headings[] = $doo->translate('签到人');
|
||||
$headings[] = $doo->translate('签到日期');
|
||||
$headings[] = $doo->translate('班次时间');
|
||||
$headings[] = $doo->translate('首次签到时间');
|
||||
$headings[] = $doo->translate('首次签到结果');
|
||||
$headings[] = $doo->translate('最后签到时间');
|
||||
$headings[] = $doo->translate('最后签到结果');
|
||||
$headings[] = $doo->translate('参数数据');
|
||||
//
|
||||
$content = [];
|
||||
$content[] = [
|
||||
@@ -1340,9 +1350,10 @@ class SystemController extends AbstractController
|
||||
$index++;
|
||||
$sameDate = date("Y-m-d", $startT);
|
||||
$sameTimes = $recordTimes[$sameDate] ?? [];
|
||||
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes);
|
||||
$sameCollect = UserCheckinRecord::atCollect($sameDate, $sameTimes, $time[0]);
|
||||
$firstBetween = [Carbon::createFromTimestamp($startT), Carbon::createFromTimestamp($startT + $secondEnd - 1)];
|
||||
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400)];
|
||||
// 扩展下班打卡范围以支持跨天打卡
|
||||
$lastBetween = [Carbon::createFromTimestamp($startT + $secondStart + 1), Carbon::createFromTimestamp($startT + 86400 + $delaySeconds)];
|
||||
$firstRecord = $sameCollect?->whereBetween("datetime", $firstBetween)->first();
|
||||
$lastRecord = $sameCollect?->whereBetween("datetime", $lastBetween)->last();
|
||||
$firstTimestamp = $firstRecord['timestamp'] ?: 0;
|
||||
@@ -1350,12 +1361,12 @@ class SystemController extends AbstractController
|
||||
if (Timer::time() < $startT + $secondStart) {
|
||||
$firstResult = "-";
|
||||
} else {
|
||||
$firstResult = Doo::translate("正常");
|
||||
$firstResult = $doo->translate("正常");
|
||||
if (empty($firstTimestamp)) {
|
||||
$firstResult = Doo::translate("缺卡");
|
||||
$firstResult = $doo->translate("缺卡");
|
||||
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
|
||||
} elseif ($firstTimestamp > $startT + $secondStart) {
|
||||
$firstResult = Doo::translate("迟到");
|
||||
$firstResult = $doo->translate("迟到");
|
||||
$styles["E{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
|
||||
}
|
||||
}
|
||||
@@ -1363,12 +1374,12 @@ class SystemController extends AbstractController
|
||||
$lastResult = "-";
|
||||
$lastTimestamp = 0;
|
||||
} else {
|
||||
$lastResult = Doo::translate("正常");
|
||||
$lastResult = $doo->translate("正常");
|
||||
if (empty($lastTimestamp) || $lastTimestamp === $firstTimestamp) {
|
||||
$lastResult = Doo::translate("缺卡");
|
||||
$lastResult = $doo->translate("缺卡");
|
||||
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "ff0000"]]];
|
||||
} elseif ($lastTimestamp < $startT + $secondEnd) {
|
||||
$lastResult = Doo::translate("早退");
|
||||
$lastResult = $doo->translate("早退");
|
||||
$styles["G{$index}"] = ["font" => ["color" => ["rgb" => "436FF6"]]];
|
||||
}
|
||||
}
|
||||
@@ -1411,7 +1422,7 @@ class SystemController extends AbstractController
|
||||
} else {
|
||||
$fileName .= '的签到记录';
|
||||
}
|
||||
$fileName = Doo::translate($fileName) . '_' . Timer::time() . '.xlsx';
|
||||
$fileName = $doo->translate($fileName) . '_' . Timer::time() . '.xlsx';
|
||||
$filePath = "temp/checkin/export/" . date("Ym", Timer::time());
|
||||
$export = new BillMultipleExport($sheets);
|
||||
$res = $export->store($filePath . "/" . $fileName);
|
||||
@@ -1439,11 +1450,10 @@ class SystemController extends AbstractController
|
||||
}
|
||||
//
|
||||
if (file_exists($zipPath)) {
|
||||
$base64 = base64_encode(Base::array2string([
|
||||
$key = Down::cache_encode([
|
||||
'file' => $zipFile,
|
||||
]));
|
||||
$fileUrl = Base::fillUrl('api/system/checkin/down?key=' . urlencode($base64));
|
||||
Session::put('checkin::export:userid', $user->userid);
|
||||
]);
|
||||
$fileUrl = Base::fillUrl('api/system/checkin/down?key=' . $key);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'file_download',
|
||||
'title' => '导出签到数据已完成',
|
||||
@@ -1473,7 +1483,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/checkin/down 26. 下载导出的签到数据
|
||||
* @api {get} api/system/checkin/down 下载导出的签到数据
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -1485,21 +1495,16 @@ class SystemController extends AbstractController
|
||||
*/
|
||||
public function checkin__down()
|
||||
{
|
||||
$userid = Session::get('checkin::export:userid');
|
||||
if (empty($userid)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 502);
|
||||
}
|
||||
//
|
||||
$array = Base::string2array(base64_decode(urldecode(Request::input('key'))));
|
||||
$array = Down::cache_decode();
|
||||
$file = $array['file'];
|
||||
if (empty($file) || !file_exists(storage_path($file))) {
|
||||
return Base::ajaxError("文件不存在!", [], 0, 502);
|
||||
return Base::ajaxError("文件不存在!", [], 0, 403);
|
||||
}
|
||||
return Response::download(storage_path($file));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/version 27. 获取版本号
|
||||
* @api {get} api/system/version 获取版本号
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
@@ -1545,7 +1550,7 @@ class SystemController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/system/prefetch 28. 预加载的资源
|
||||
* @api {get} api/system/prefetch 预加载的资源
|
||||
*
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup system
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
378
app/Http/Controllers/Api/apidoc.md
Normal file
378
app/Http/Controllers/Api/apidoc.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# apiDoc 参数标签说明(完整速查)
|
||||
|
||||
apiDoc 使用内联注释为 RESTful API 自动生成文档。
|
||||
以下为所有官方支持的参数与其说明。
|
||||
|
||||
---
|
||||
|
||||
## @api
|
||||
**定义 API 方法的基本信息**
|
||||
|
||||
```js
|
||||
@api {method} path title
|
||||
```
|
||||
|
||||
- **method**:请求方法,如 `GET`、`POST`、`PUT`、`DELETE` 等
|
||||
- **path**:请求路径,例如 `/user/:id`
|
||||
- **title**:简短标题(显示在文档中)
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@api {get} /user/:id Get user info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiBody
|
||||
**定义请求体参数**
|
||||
|
||||
```js
|
||||
@apiBody [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
- `{type}` 参数类型(如 String, Number, Object, String[])
|
||||
- `[field]` 可选字段(方括号表示可选)
|
||||
- `=defaultValue` 默认值
|
||||
- `description` 参数说明
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiBody {String} lastname Mandatory Lastname.
|
||||
@apiBody {Object} [address] Optional address object.
|
||||
@apiBody {String} [address[city]] Optional city.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiDefine
|
||||
**定义可复用的文档块**
|
||||
|
||||
```js
|
||||
@apiDefine name [title] [description]
|
||||
```
|
||||
|
||||
- `name`:唯一标识
|
||||
- `title`:简短标题
|
||||
- `description`:多行描述
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDefine MyError
|
||||
@apiError UserNotFound The <code>id</code> of the User was not found.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiDeprecated
|
||||
**标记接口为弃用状态**
|
||||
|
||||
```js
|
||||
@apiDeprecated [text]
|
||||
```
|
||||
|
||||
- `text`:提示文本,可带链接到新方法
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDeprecated use now (#User:GetDetails)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiDescription
|
||||
**描述接口详细说明**
|
||||
|
||||
```js
|
||||
@apiDescription text
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDescription This is the Description.
|
||||
It is multiline capable.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiError
|
||||
**定义错误返回参数**
|
||||
|
||||
```js
|
||||
@apiError [(group)] [{type}] field [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiError UserNotFound The id of the User was not found.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiErrorExample
|
||||
**定义错误返回示例**
|
||||
|
||||
```js
|
||||
@apiErrorExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiErrorExample {json} Error-Response:
|
||||
HTTP/1.1 404 Not Found
|
||||
{ "error": "UserNotFound" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiExample
|
||||
**定义接口使用示例**
|
||||
|
||||
```js
|
||||
@apiExample [{type}] title
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiExample {curl} Example usage:
|
||||
curl -i http://localhost/user/4711
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiGroup
|
||||
**定义所属分组**
|
||||
|
||||
```js
|
||||
@apiGroup name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiGroup User
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiHeader
|
||||
**定义请求头参数**
|
||||
|
||||
```js
|
||||
@apiHeader [(group)] [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiHeader {String} access-key Users unique access-key.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiHeaderExample
|
||||
**定义请求头示例**
|
||||
|
||||
```js
|
||||
@apiHeaderExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiHeaderExample {json} Header-Example:
|
||||
{
|
||||
"Accept-Encoding": "gzip, deflate"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiIgnore
|
||||
**忽略当前文档块**
|
||||
|
||||
```js
|
||||
@apiIgnore [hint]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiIgnore Not finished method
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiName
|
||||
**定义接口唯一名称**
|
||||
|
||||
```js
|
||||
@apiName name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiName GetUser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiParam
|
||||
**定义请求参数**
|
||||
|
||||
```js
|
||||
@apiParam [(group)] [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiParam {Number} id Users unique ID.
|
||||
@apiParam {String} [firstname] Optional firstname.
|
||||
@apiParam {String} country="DE" Mandatory with default.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiParamExample
|
||||
**定义参数请求示例**
|
||||
|
||||
```js
|
||||
@apiParamExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiParamExample {json} Request-Example:
|
||||
{ "id": 4711 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiPermission
|
||||
**定义权限要求**
|
||||
|
||||
```js
|
||||
@apiPermission name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiPermission admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiPrivate
|
||||
**标记接口为私有(可过滤)**
|
||||
|
||||
```js
|
||||
@apiPrivate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiQuery
|
||||
**定义查询参数(?query)**
|
||||
|
||||
```js
|
||||
@apiQuery [{type}] [field=defaultValue] [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiQuery {Number} id Users unique ID.
|
||||
@apiQuery {String} [sort="asc"] Sort order.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiSampleRequest
|
||||
**定义接口测试请求 URL**
|
||||
|
||||
```js
|
||||
@apiSampleRequest url
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiSampleRequest http://test.github.com/some_path/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiSuccess
|
||||
**定义成功返回参数**
|
||||
|
||||
```js
|
||||
@apiSuccess [(group)] [{type}] field [description]
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiSuccess {String} firstname Firstname of the User.
|
||||
@apiSuccess {String} lastname Lastname of the User.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiSuccessExample
|
||||
**定义成功返回示例**
|
||||
|
||||
```js
|
||||
@apiSuccessExample [{type}] [title]
|
||||
example
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiSuccessExample {json} Success-Response:
|
||||
HTTP/1.1 200 OK
|
||||
{ "firstname": "John", "lastname": "Doe" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiUse
|
||||
**引用定义块(@apiDefine)**
|
||||
|
||||
```js
|
||||
@apiUse name
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiDefine MySuccess
|
||||
@apiSuccess {String} firstname User firstname.
|
||||
|
||||
@apiUse MySuccess
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @apiVersion
|
||||
**定义接口版本**
|
||||
|
||||
```js
|
||||
@apiVersion version
|
||||
```
|
||||
|
||||
📘 示例:
|
||||
```js
|
||||
@apiVersion 1.6.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 附录:常用标签速查表
|
||||
|
||||
| 标签 | 作用 | 示例 |
|
||||
|------|------|------|
|
||||
| `@api` | 定义接口 | `@api {get} /user/:id` |
|
||||
| `@apiName` | 唯一名称 | `@apiName GetUser` |
|
||||
| `@apiGroup` | 所属分组 | `@apiGroup User` |
|
||||
| `@apiParam` | 请求参数 | `@apiParam {Number} id Users unique ID.` |
|
||||
| `@apiBody` | 请求体参数 | `@apiBody {String} name Username.` |
|
||||
| `@apiQuery` | 查询参数 | `@apiQuery {String} keyword Search term.` |
|
||||
| `@apiHeader` | Header 参数 | `@apiHeader {String} token Auth token.` |
|
||||
| `@apiSuccess` | 成功返回字段 | `@apiSuccess {String} name Username.` |
|
||||
| `@apiError` | 错误返回字段 | `@apiError NotFound User not found.` |
|
||||
| `@apiVersion` | 版本号 | `@apiVersion 1.0.0` |
|
||||
@@ -1,89 +1,137 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 给apidoc项目增加顺序编号
|
||||
* 给apidoc项目增加顺序编号 / 支持恢复
|
||||
*/
|
||||
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
$path = dirname(__FILE__). '/';
|
||||
$lists = scandir($path);
|
||||
//
|
||||
foreach ($lists AS $item) {
|
||||
$fillPath = $path . $item;
|
||||
if (str_ends_with($fillPath, 'Controller.php')) {
|
||||
$content = file_get_contents($fillPath);
|
||||
preg_match_all("/\* @api \{(.+?)\} (.*?)\n/i", $content, $matchs);
|
||||
$i = 1;
|
||||
foreach ($matchs[2] AS $key=>$text) {
|
||||
if (in_array(strtolower($matchs[1][$key]), array('get', 'post'))) {
|
||||
$expl = explode(" ", __sRemove($text));
|
||||
$end = $expl[1];
|
||||
if ($expl[2]) {
|
||||
$end = '';
|
||||
foreach ($expl AS $k=>$v) { if ($k >= 2) { $end.= " ".$v; } }
|
||||
}
|
||||
$newtext = "* @api {".$matchs[1][$key]."} ".$expl[0]." ".__zeroFill($i, 2).". ".trim($end);
|
||||
$content = str_replace("* @api {".$matchs[1][$key]."} ".$text, $newtext, $content);
|
||||
$i++;
|
||||
//
|
||||
echo $newtext;
|
||||
echo "\r\n";
|
||||
}
|
||||
}
|
||||
if ($i > 1) {
|
||||
file_put_contents($fillPath, $content);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "Success \n";
|
||||
const NUMBER_WIDTH = 2;
|
||||
|
||||
/** ************************************************************** */
|
||||
/** ************************************************************** */
|
||||
/** ************************************************************** */
|
||||
$isRestore = isset($argv[1]) && strtolower($argv[1]) === 'restore';
|
||||
|
||||
/**
|
||||
* 替换所有空格
|
||||
* @param $str
|
||||
* @return mixed
|
||||
*/
|
||||
function __sRemove($str) {
|
||||
$str = str_replace(" ", " ", $str);
|
||||
if (__strExists($str, " ")) {
|
||||
return __sRemove($str);
|
||||
}
|
||||
return $str;
|
||||
$basePath = dirname(__FILE__) . '/';
|
||||
$controllerFiles = glob($basePath . '*Controller.php');
|
||||
|
||||
if (!$controllerFiles) {
|
||||
echo "No Controller.php files found\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
foreach ($controllerFiles as $filePath) {
|
||||
$original = file_get_contents($filePath);
|
||||
[$updated, $linesChanged] = processFile($original, $isRestore);
|
||||
|
||||
if (count($linesChanged) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($filePath, $updated);
|
||||
|
||||
foreach ($linesChanged as $line) {
|
||||
echo $line . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo $isRestore ? "Restore Success \n" : "Success \n";
|
||||
|
||||
/**
|
||||
* 是否包含字符
|
||||
* @param $string
|
||||
* @param $find
|
||||
* @return bool
|
||||
* 处理单个文件内容
|
||||
*
|
||||
* @param string $content
|
||||
* @param bool $restore
|
||||
* @return array{string, array<int, string>}
|
||||
*/
|
||||
function __strExists($string, $find)
|
||||
function processFile(string $content, bool $restore): array
|
||||
{
|
||||
return str_contains($string, $find);
|
||||
$lineChanges = [];
|
||||
$counter = 1;
|
||||
|
||||
$pattern = '/\* @api \{([^\}]+)\}\s+([^\s]+)([^\r\n]*)(\r?\n)/';
|
||||
|
||||
$updated = preg_replace_callback(
|
||||
$pattern,
|
||||
function (array $matches) use ($restore, &$counter, &$lineChanges) {
|
||||
$method = trim($matches[1]);
|
||||
if (!in_array(strtolower($method), ['get', 'post'], true)) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
$endpoint = trim($matches[2]);
|
||||
$suffix = normalizeDescription(stripExistingNumbering($matches[3]));
|
||||
|
||||
if (!$restore) {
|
||||
$numberedSuffix = formatNumber($counter) . '.';
|
||||
if ($suffix !== '') {
|
||||
$numberedSuffix .= ' ' . $suffix;
|
||||
}
|
||||
$counter++;
|
||||
} else {
|
||||
$numberedSuffix = $suffix;
|
||||
}
|
||||
|
||||
$newLine = renderAnnotation($method, $endpoint, $numberedSuffix);
|
||||
|
||||
if ($newLine !== rtrim($matches[0], "\r\n")) {
|
||||
$lineChanges[] = $newLine;
|
||||
}
|
||||
|
||||
return $newLine . $matches[4];
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
if ($updated === null) {
|
||||
return [$content, []];
|
||||
}
|
||||
|
||||
return [$updated, $lineChanges];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $str 补零
|
||||
* @param int $length
|
||||
* @param int $after
|
||||
* @return bool|string
|
||||
* 生成格式化后的注释行
|
||||
*/
|
||||
function __zeroFill($str, $length = 0, $after = 1) {
|
||||
if (strlen($str) >= $length) {
|
||||
return $str;
|
||||
function renderAnnotation(string $method, string $endpoint, string $suffix = ''): string
|
||||
{
|
||||
$line = "* @api {" . $method . "} " . $endpoint;
|
||||
|
||||
if ($suffix !== '') {
|
||||
if ($suffix[0] !== ' ') {
|
||||
$line .= ' ';
|
||||
}
|
||||
$line .= $suffix;
|
||||
}
|
||||
$_str = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$_str .= '0';
|
||||
}
|
||||
if ($after) {
|
||||
$_ret = substr($_str . $str, $length * -1);
|
||||
} else {
|
||||
$_ret = substr($str . $_str, 0, $length);
|
||||
}
|
||||
return $_ret;
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除已有编号部分
|
||||
*/
|
||||
function stripExistingNumbering(string $text): string
|
||||
{
|
||||
$trimmed = ltrim($text);
|
||||
$pattern = '/^\d+\.\s*/';
|
||||
return preg_replace($pattern, '', $trimmed) ?? $trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩多余空格
|
||||
*/
|
||||
function normalizeDescription(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return preg_replace('/\s+/', ' ', $text) ?? $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成固定宽度的数字
|
||||
*/
|
||||
function formatNumber(int $number): string
|
||||
{
|
||||
return str_pad((string) $number, NUMBER_WIDTH, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ use App\Tasks\AutoArchivedTask;
|
||||
use App\Tasks\DeleteBotMsgTask;
|
||||
use App\Tasks\CheckinRemindTask;
|
||||
use App\Tasks\CloseMeetingRoomTask;
|
||||
use App\Tasks\ZincSearchSyncTask;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use App\Tasks\UnclaimedTaskRemindTask;
|
||||
use App\Tasks\TodoRemindTask;
|
||||
use App\Tasks\AiTaskLoopTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use Laravolt\Avatar\Avatar;
|
||||
|
||||
@@ -61,6 +63,10 @@ class IndexController extends InvokeController
|
||||
$array = Base::json2array(file_get_contents($hotFile));
|
||||
$style = null;
|
||||
$script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js"));
|
||||
$proxyUri = Base::liveEnv('VSCODE_PROXY_URI');
|
||||
if (is_string($proxyUri) && preg_match('/^https?:\/\//i', $proxyUri)) {
|
||||
$script = preg_replace('/^(https?:\/\/|\/\/)[^\/]+/', rtrim($proxyUri, '/'), $script, 1);
|
||||
}
|
||||
} else {
|
||||
$array = Base::json2array(file_get_contents($manifestFile));
|
||||
$style = asset_main($array['resources/assets/js/app.js']['css'][0]);
|
||||
@@ -254,6 +260,7 @@ class IndexController extends InvokeController
|
||||
Task::deliver(new DeleteTmpTask('file'));
|
||||
Task::deliver(new DeleteTmpTask('tmp_file', 24));
|
||||
Task::deliver(new DeleteTmpTask('user_device', 24));
|
||||
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
|
||||
// 删除机器人消息
|
||||
Task::deliver(new DeleteBotMsgTask());
|
||||
// 周期任务
|
||||
@@ -264,10 +271,14 @@ class IndexController extends InvokeController
|
||||
Task::deliver(new JokeSoupTask());
|
||||
// 未领取任务通知
|
||||
Task::deliver(new UnclaimedTaskRemindTask());
|
||||
// 待办提醒
|
||||
Task::deliver(new TodoRemindTask());
|
||||
// 关闭会议室
|
||||
Task::deliver(new CloseMeetingRoomTask());
|
||||
// ZincSearch 同步
|
||||
Task::deliver(new ZincSearchSyncTask());
|
||||
// Manticore Search 同步
|
||||
Task::deliver(new ManticoreSyncTask());
|
||||
// AI 任务建议
|
||||
Task::deliver(new AiTaskLoopTask());
|
||||
|
||||
return "success";
|
||||
}
|
||||
@@ -352,9 +363,7 @@ class IndexController extends InvokeController
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (empty($avaiPath)) {
|
||||
abort(404);
|
||||
}
|
||||
abort_if(empty($avaiPath), 404);
|
||||
$lists = Base::recursiveFiles($dirPath, false);
|
||||
$files = [];
|
||||
foreach ($lists as $file) {
|
||||
@@ -432,13 +441,9 @@ class IndexController extends InvokeController
|
||||
$path = Arr::get($data, 'path');
|
||||
$file = public_path($path);
|
||||
// 防止 ../ 穿越获取到系统文件
|
||||
if (!str_starts_with(realpath($file), public_path())) {
|
||||
abort(404);
|
||||
}
|
||||
//
|
||||
if (!file_exists($file)) {
|
||||
abort(404);
|
||||
}
|
||||
abort_if(!str_starts_with(realpath($file), public_path()), 404);
|
||||
// 如果文件不存在,直接返回 404
|
||||
abort_if(!file_exists($file), 404);
|
||||
//
|
||||
parse_str($data['query'], $query);
|
||||
$name = Arr::get($query, 'name');
|
||||
|
||||
@@ -10,14 +10,19 @@ class TrustProxies extends Middleware
|
||||
/**
|
||||
* The trusted proxies for this application.
|
||||
*
|
||||
* PHP(Swoole)只在内网被 nginx 访问,外部无法直连,故信任内网代理。
|
||||
*
|
||||
* @var array|string|null
|
||||
*/
|
||||
protected $proxies;
|
||||
protected $proxies = '*';
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
*
|
||||
* 只采信 X-Forwarded-Proto:nginx 已用 $the_scheme 覆盖该头(值由 nginx 控制),
|
||||
* 据此让 url() 实时跟随 https;host/for 一律不信,避免 Host 注入与 IP 伪造。
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||
protected $headers = Request::HEADER_X_FORWARDED_PROTO;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Http\Middleware;
|
||||
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Services\RequestContext;
|
||||
use Cache;
|
||||
use Closure;
|
||||
|
||||
class WebApi
|
||||
@@ -19,8 +21,7 @@ class WebApi
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
// 为每个请求生成唯一ID
|
||||
$request->requestId = RequestContext::generateRequestId();
|
||||
// 记录请求信息
|
||||
RequestContext::set('start_time', microtime(true));
|
||||
RequestContext::set('header_language', $request->header('language'));
|
||||
|
||||
@@ -30,6 +31,12 @@ class WebApi
|
||||
// 加载Doo类
|
||||
Doo::load();
|
||||
|
||||
// 记录 PC 端活跃时间
|
||||
$userid = Doo::userId();
|
||||
if ($userid > 0 && Base::isPc()) {
|
||||
Cache::put("user_pc_active:{$userid}", time(), 60);
|
||||
}
|
||||
|
||||
// 解密请求内容
|
||||
$encrypt = Doo::pgpParseStr($request->header('encrypt'));
|
||||
if ($request->isMethod('post')) {
|
||||
@@ -49,12 +56,6 @@ class WebApi
|
||||
}
|
||||
}
|
||||
|
||||
// 强制 https
|
||||
$APP_SCHEME = env('APP_SCHEME', 'auto');
|
||||
if (in_array(strtolower($APP_SCHEME), ['https', 'on', 'ssl', '1', 'true', 'yes'], true)) {
|
||||
$request->setTrustedProxies([$request->getClientIp()], $request::HEADER_X_FORWARDED_PROTO);
|
||||
}
|
||||
|
||||
// 执行下一个中间件
|
||||
$response = $next($request);
|
||||
|
||||
@@ -76,6 +77,6 @@ class WebApi
|
||||
public function terminate()
|
||||
{
|
||||
// 请求结束后清理上下文
|
||||
RequestContext::clear();
|
||||
RequestContext::clean();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Ldap;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Services\RequestContext;
|
||||
use LdapRecord\Configuration\ConfigurationException;
|
||||
use LdapRecord\Container;
|
||||
use LdapRecord\LdapRecordException;
|
||||
@@ -11,20 +13,18 @@ use LdapRecord\Models\Model;
|
||||
|
||||
class LdapUser extends Model
|
||||
{
|
||||
protected static $init = null;
|
||||
/**
|
||||
* The object classes of the LDAP model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $objectClasses = [
|
||||
'inetOrgPerson',
|
||||
'organizationalPerson',
|
||||
'person',
|
||||
'top',
|
||||
'posixAccount',
|
||||
];
|
||||
|
||||
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
|
||||
|
||||
/**
|
||||
* @return mixed|null
|
||||
*/
|
||||
@@ -68,19 +68,29 @@ class LdapUser extends Model
|
||||
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录属性名
|
||||
* @return string
|
||||
*/
|
||||
public static function getLoginAttr(): string
|
||||
{
|
||||
$attr = Base::settingFind('thirdAccessSetting', 'ldap_login_attr');
|
||||
return in_array($attr, ['cn', 'uid', 'mail', 'sAMAccountName', 'userPrincipalName']) ? $attr : 'cn';
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
* @return bool
|
||||
*/
|
||||
public static function initConfig()
|
||||
{
|
||||
if (is_bool(self::$init)) {
|
||||
return self::$init;
|
||||
if (RequestContext::has('ldap_init')) {
|
||||
return RequestContext::get('ldap_init');
|
||||
}
|
||||
//
|
||||
$setting = Base::setting('thirdAccessSetting');
|
||||
if ($setting['ldap_open'] !== 'open') {
|
||||
return self::$init = false;
|
||||
return RequestContext::save('ldap_init', false);
|
||||
}
|
||||
//
|
||||
$connection = Container::getDefaultConnection();
|
||||
@@ -92,15 +102,15 @@ class LdapUser extends Model
|
||||
"username" => $setting['ldap_user_dn'],
|
||||
"password" => $setting['ldap_password'],
|
||||
]);
|
||||
return self::$init = true;
|
||||
return RequestContext::save('ldap_init', true);
|
||||
} catch (ConfigurationException $e) {
|
||||
info($e->getMessage());
|
||||
return self::$init = false;
|
||||
return RequestContext::save('ldap_init', false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取
|
||||
* 通过管理员绑定搜索用户,然后用用户 DN 做 Bind 认证
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return Model|null
|
||||
@@ -111,16 +121,68 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
'userPassword' => $password
|
||||
])->first();
|
||||
$loginAttr = self::getLoginAttr();
|
||||
$row = self::static()
|
||||
->whereRaw($loginAttr, '=', $username)
|
||||
->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$connection = Container::getDefaultConnection();
|
||||
if (!$connection->auth()->attempt($row->getDn(), $password)) {
|
||||
return null;
|
||||
}
|
||||
// Swoole 下连接共享,必须恢复管理员绑定
|
||||
$connection->auth()->attempt(
|
||||
$connection->getConfiguration()->get('username'),
|
||||
$connection->getConfiguration()->get('password')
|
||||
);
|
||||
return $row;
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] auth fail: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邮箱查找 LDAP 用户
|
||||
* @param $email
|
||||
* @return Model|null
|
||||
*/
|
||||
public static function findByEmail($email): ?Model
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$row = self::static()->whereRaw($attr, '=', $email)->first();
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的邮箱(从 LDAP 记录中提取)
|
||||
* @param Model $row
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getUserEmail(Model $row): ?string
|
||||
{
|
||||
foreach (self::$emailAttrs as $attr) {
|
||||
$val = $row->getFirstAttribute($attr);
|
||||
if ($val && Base::isEmail($val)) {
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param $username
|
||||
@@ -138,7 +200,18 @@ class LdapUser extends Model
|
||||
return null;
|
||||
}
|
||||
if (empty($user)) {
|
||||
$user = User::reg($username, $password);
|
||||
$email = self::getUserEmail($row);
|
||||
if (empty($email)) {
|
||||
throw new ApiException('LDAP 用户缺少邮箱属性,请联系管理员配置');
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
// LDAP 用户通过 LDAP 认证,本地密码用随机值以满足密码策略
|
||||
$localPassword = Base::generatePassword(16) . 'Aa1!';
|
||||
$user = User::reg($email, $localPassword);
|
||||
} elseif (!$user->isLdap()) {
|
||||
info("[LDAP] merged with existing local account: userid={$user->userid}, email={$email}");
|
||||
}
|
||||
}
|
||||
if ($user) {
|
||||
$userimg = $row->getPhoto();
|
||||
@@ -173,7 +246,7 @@ class LdapUser extends Model
|
||||
}
|
||||
//
|
||||
if (self::isSyncLocal()) {
|
||||
$row = self::userFirst($user->email, $password);
|
||||
$row = self::findByEmail($user->email);
|
||||
if ($row) {
|
||||
return;
|
||||
}
|
||||
@@ -184,17 +257,18 @@ class LdapUser extends Model
|
||||
} else {
|
||||
$userimg = '';
|
||||
}
|
||||
self::static()->create([
|
||||
$attrs = [
|
||||
'cn' => $user->email,
|
||||
'gidNumber' => 0,
|
||||
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
|
||||
'sn' => $user->email,
|
||||
'uid' => $user->email,
|
||||
'uidNumber' => $user->userid,
|
||||
'userPassword' => $password,
|
||||
'displayName' => $user->nickname,
|
||||
'jpegPhoto' => $userimg,
|
||||
]);
|
||||
'mail' => $user->email,
|
||||
];
|
||||
if ($userimg) {
|
||||
$attrs['jpegPhoto'] = $userimg;
|
||||
}
|
||||
self::static()->create($attrs);
|
||||
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
|
||||
$user->save();
|
||||
} catch (LdapRecordException $e) {
|
||||
@@ -205,11 +279,11 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 更新
|
||||
* @param $username
|
||||
* @param $email
|
||||
* @param $array
|
||||
* @return void
|
||||
*/
|
||||
public static function userUpdate($username, $array)
|
||||
public static function userUpdate($email, $array)
|
||||
{
|
||||
if (empty($array)) {
|
||||
return;
|
||||
@@ -218,10 +292,7 @@ class LdapUser extends Model
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row = self::findByEmail($email);
|
||||
$row?->update($array);
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] update fail: " . $e->getMessage());
|
||||
@@ -230,19 +301,16 @@ class LdapUser extends Model
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @param $username
|
||||
* @param $email
|
||||
* @return void
|
||||
*/
|
||||
public static function userDelete($username)
|
||||
public static function userDelete($email)
|
||||
{
|
||||
if (!self::initConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = self::static()
|
||||
->where([
|
||||
'cn' => $username,
|
||||
])->first();
|
||||
$row = self::findByEmail($email);
|
||||
$row?->delete();
|
||||
} catch (\Exception $e) {
|
||||
info("[LDAP] delete fail: " . $e->getMessage());
|
||||
|
||||
@@ -20,9 +20,7 @@ use Illuminate\Support\Facades\DB;
|
||||
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Model|object|static|null cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|static with($relations)
|
||||
* @method static \Illuminate\Database\Query\Builder|static select($columns = [])
|
||||
* @method static \Illuminate\Database\Query\Builder|static whereIn($column, $values, $boolean = 'and', $not = false)
|
||||
* @method static \Illuminate\Database\Query\Builder|static whereNotIn($column, $values, $boolean = 'and')
|
||||
* @method static \Illuminate\Pagination\LengthAwarePaginator paginate(callable $callback)
|
||||
* @method int change(array $array)
|
||||
* @method int remove()
|
||||
* @mixin \Eloquent
|
||||
@@ -53,6 +51,8 @@ class AbstractModel extends Model
|
||||
|
||||
'read_at',
|
||||
'done_at',
|
||||
'remind_at',
|
||||
'reminded_at',
|
||||
|
||||
'created_at',
|
||||
'updated_at',
|
||||
|
||||
22
app/Models/AiAssistantSession.php
Normal file
22
app/Models/AiAssistantSession.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* AI 助手会话
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $userid
|
||||
* @property string $session_key
|
||||
* @property string $session_id
|
||||
* @property string $scene_key
|
||||
* @property string $title
|
||||
* @property string|null $data
|
||||
* @property string|null $images
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class AiAssistantSession extends AbstractModel
|
||||
{
|
||||
protected $table = 'ai_assistant_sessions';
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use Request;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Tasks\PushTask;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use App\Observers\AbstractObserver;
|
||||
use App\Exceptions\ApiException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
@@ -24,6 +26,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property int|null $size 大小(B)
|
||||
* @property int|null $userid 拥有者ID
|
||||
* @property int|null $share 是否共享
|
||||
* @property int|null $guest_access 是否允许游客访问
|
||||
* @property int|null $pshare 所属分享ID
|
||||
* @property int|null $created_id 创建者
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
@@ -39,11 +42,14 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File sharedToUser(int $userid)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereCreatedId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereDeletedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereExt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereGuestAccess($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|File wherePid($value)
|
||||
@@ -126,6 +132,45 @@ class File extends AbstractModel
|
||||
*/
|
||||
const zipMaxSize = 1024 * 1024 * 1024; // 1G
|
||||
|
||||
/**
|
||||
* 按关键词搜索文件(Scope)
|
||||
* 支持:文件ID(纯数字)、文件名
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
if (is_numeric($keyword)) {
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("id", intval($keyword))
|
||||
->orWhere("name", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
return $query->where("name", "like", "%{$keyword}%");
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选用户可访问的共享文件(Scope)
|
||||
* 不包括用户自己的文件,仅返回他人共享给该用户的文件
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param int $userid 用户ID
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSharedToUser($query, int $userid)
|
||||
{
|
||||
return $query->whereIn('pshare', function ($subQuery) use ($userid) {
|
||||
$subQuery->select('files.id')
|
||||
->from('files')
|
||||
->join('file_users', 'files.id', '=', 'file_users.file_id')
|
||||
->where('files.userid', '!=', $userid)
|
||||
->where(function ($q) use ($userid) {
|
||||
$q->whereIn('file_users.userid', [0, $userid]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
@@ -582,6 +627,26 @@ class File extends AbstractModel
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新子文件的 userid 并同步到 Manticore
|
||||
* @param int $userid 新的 userid
|
||||
* @return int 更新的文件数量
|
||||
*/
|
||||
public function updateChildFilesUserid(int $userid): int
|
||||
{
|
||||
self::where('pids', 'like', "%,{$this->id},%")->update(['userid' => $userid]);
|
||||
|
||||
// 批量 update 绕过 Observer,手动触发 Manticore 同步
|
||||
$childFileIds = self::where('pids', 'like', "%,{$this->id},%")
|
||||
->where('type', '!=', 'folder')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
foreach ($childFileIds as $childFileId) {
|
||||
AbstractObserver::taskDeliver(new ManticoreSyncTask('file_sync', ['id' => $childFileId]));
|
||||
}
|
||||
return count($childFileIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件分享链接
|
||||
* @param $userid
|
||||
@@ -642,6 +707,29 @@ class File extends AbstractModel
|
||||
Task::deliver($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件推送消息
|
||||
* @param $action
|
||||
* @param array|null $data 发送内容
|
||||
* @param int $userid 会员ID
|
||||
*/
|
||||
public static function pushMsgSimple($action, $data, $userid)
|
||||
{
|
||||
if (empty($data) || empty($userid)) {
|
||||
return;
|
||||
}
|
||||
$msg = [
|
||||
'type' => 'file',
|
||||
'action' => $action,
|
||||
'data' => $data,
|
||||
];
|
||||
$params = [
|
||||
'userid' => $userid,
|
||||
'msg' => $msg
|
||||
];
|
||||
Task::deliver(new PushTask($params));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推送会员
|
||||
* @param $action
|
||||
@@ -685,7 +773,7 @@ class File extends AbstractModel
|
||||
/**
|
||||
* code获取文件ID、名称
|
||||
* @param $code
|
||||
* @return File
|
||||
* @return File|null
|
||||
*/
|
||||
public static function code2IdName($code) {
|
||||
$arr = explode(",", base64_decode($code));
|
||||
@@ -956,30 +1044,6 @@ class File extends AbstractModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件推送消息
|
||||
* @param $action
|
||||
* @param array|null $data 发送内容
|
||||
* @param array $userid 会员ID
|
||||
*/
|
||||
public static function filePushMsg($action, $data = null, $userid = null)
|
||||
{
|
||||
$userid = User::userid();
|
||||
if (empty($userid)) {
|
||||
return;
|
||||
}
|
||||
$msg = [
|
||||
'type' => 'file',
|
||||
'action' => $action,
|
||||
'data' => $data,
|
||||
];
|
||||
$params = [
|
||||
'userid' => $userid,
|
||||
'msg' => $msg
|
||||
];
|
||||
Task::deliver(new PushTask($params));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件类型判断是否需要安装应用
|
||||
* @param $type
|
||||
|
||||
@@ -129,9 +129,7 @@ class FileContent extends AbstractModel
|
||||
],
|
||||
default => json_decode('{}'),
|
||||
};
|
||||
if ($download) {
|
||||
abort(403, "This file is empty.");
|
||||
}
|
||||
abort_if($download, 403, "This file is empty.");
|
||||
} else {
|
||||
$path = $content['url'];
|
||||
if ($file->ext) {
|
||||
@@ -147,16 +145,30 @@ class FileContent extends AbstractModel
|
||||
}
|
||||
if ($download) {
|
||||
$filePath = public_path($path);
|
||||
if (isset($filePath)) {
|
||||
return Base::DownloadFileResponse($filePath, $name);
|
||||
} else {
|
||||
abort(403, "This file not support download.");
|
||||
}
|
||||
abort_if(!isset($filePath),403, "This file not support download.");
|
||||
return Base::DownloadFileResponse($filePath, $name);
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', [ 'content' => $content ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件访问URL
|
||||
* @param int $fileId 文件ID
|
||||
* @return string|null 返回完整的文件URL,如果文件无内容则返回null
|
||||
*/
|
||||
public static function getFileUrl($fileId)
|
||||
{
|
||||
$content = self::whereFid($fileId)->orderByDesc('id')->first();
|
||||
if ($content) {
|
||||
$contentData = Base::json2array($content->content ?: []);
|
||||
if (!empty($contentData['url'])) {
|
||||
return Base::fillUrl($contentData['url']);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容
|
||||
* @param $id
|
||||
|
||||
@@ -45,7 +45,7 @@ class FileUser extends AbstractModel
|
||||
} else {
|
||||
FileLink::whereFileId($file_id)->delete();
|
||||
}
|
||||
FileUser::whereFileId($file_id)->delete();
|
||||
FileUser::whereFileId($file_id)->remove();
|
||||
});
|
||||
}
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ class FileUser extends AbstractModel
|
||||
{
|
||||
return AbstractModel::transaction(function() use ($userid, $file_id) {
|
||||
FileLink::whereFileId($file_id)->whereUserid($userid)->delete();
|
||||
return self::whereFileId($file_id)->whereUserid($userid)->delete();
|
||||
return self::whereFileId($file_id)->whereUserid($userid)->remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
132
app/Models/ManticoreSyncFailure.php
Normal file
132
app/Models/ManticoreSyncFailure.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Manticore 同步失败记录
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $data_type 数据类型: msg/file/task/project/user
|
||||
* @property int $data_id 数据ID
|
||||
* @property string $action 操作类型: sync/delete
|
||||
* @property string|null $error_message 错误信息
|
||||
* @property int $retry_count 重试次数
|
||||
* @property \Carbon\Carbon|null $last_retry_at 最后重试时间
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class ManticoreSyncFailure extends AbstractModel
|
||||
{
|
||||
protected $table = 'manticore_sync_failures';
|
||||
|
||||
protected $fillable = [
|
||||
'data_type',
|
||||
'data_id',
|
||||
'action',
|
||||
'error_message',
|
||||
'retry_count',
|
||||
'last_retry_at',
|
||||
];
|
||||
|
||||
protected $dates = [
|
||||
'last_retry_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 记录同步失败
|
||||
*
|
||||
* @param string $dataType 数据类型
|
||||
* @param int $dataId 数据ID
|
||||
* @param string $action 操作类型 sync/delete
|
||||
* @param string $errorMessage 错误信息
|
||||
*/
|
||||
public static function recordFailure(string $dataType, int $dataId, string $action, string $errorMessage = ''): void
|
||||
{
|
||||
self::updateOrCreate(
|
||||
[
|
||||
'data_type' => $dataType,
|
||||
'data_id' => $dataId,
|
||||
'action' => $action,
|
||||
],
|
||||
[
|
||||
'error_message' => mb_substr($errorMessage, 0, 500),
|
||||
'retry_count' => \DB::raw('retry_count + 1'),
|
||||
'last_retry_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成功记录
|
||||
*
|
||||
* @param string $dataType 数据类型
|
||||
* @param int $dataId 数据ID
|
||||
* @param string $action 操作类型
|
||||
*/
|
||||
public static function removeSuccess(string $dataType, int $dataId, string $action): void
|
||||
{
|
||||
self::where('data_type', $dataType)
|
||||
->where('data_id', $dataId)
|
||||
->where('action', $action)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待重试的记录
|
||||
* 根据重试次数决定间隔:1次=1分钟,2次=5分钟,3次=15分钟,4次+=30分钟
|
||||
*
|
||||
* @param int $limit 数量限制
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function getPendingRetries(int $limit = 100)
|
||||
{
|
||||
return self::where(function ($query) {
|
||||
$query->whereNull('last_retry_at')
|
||||
->orWhere(function ($q) {
|
||||
// 根据重试次数决定间隔
|
||||
$q->where(function ($sub) {
|
||||
// 重试1次:等待1分钟
|
||||
$sub->where('retry_count', 1)
|
||||
->where('last_retry_at', '<', now()->subMinutes(1));
|
||||
})->orWhere(function ($sub) {
|
||||
// 重试2次:等待5分钟
|
||||
$sub->where('retry_count', 2)
|
||||
->where('last_retry_at', '<', now()->subMinutes(5));
|
||||
})->orWhere(function ($sub) {
|
||||
// 重试3次:等待15分钟
|
||||
$sub->where('retry_count', 3)
|
||||
->where('last_retry_at', '<', now()->subMinutes(15));
|
||||
})->orWhere(function ($sub) {
|
||||
// 重试4次以上:等待30分钟
|
||||
$sub->where('retry_count', '>=', 4)
|
||||
->where('last_retry_at', '<', now()->subMinutes(30));
|
||||
});
|
||||
});
|
||||
})
|
||||
->orderBy('last_retry_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getStats(): array
|
||||
{
|
||||
return [
|
||||
'total' => self::count(),
|
||||
'by_type' => self::selectRaw('data_type, COUNT(*) as count')
|
||||
->groupBy('data_type')
|
||||
->pluck('count', 'data_type')
|
||||
->toArray(),
|
||||
'by_action' => self::selectRaw('action, COUNT(*) as count')
|
||||
->groupBy('action')
|
||||
->pluck('count', 'action')
|
||||
->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ use Request;
|
||||
* @property int|null $personal 是否个人项目
|
||||
* @property string|null $archive_method 自动归档方式
|
||||
* @property int|null $archive_days 自动归档天数
|
||||
* @property string|null $ai_auto_analyze AI自动分析
|
||||
* @property string|null $task_template_share 共享模板开关
|
||||
* @property string|null $department_owner_view 部门负责人视角可见开关
|
||||
* @property string|null $user_simple 成员总数|1,2,3
|
||||
* @property int|null $dialog_id 聊天会话ID
|
||||
* @property \Illuminate\Support\Carbon|null $archived_at 归档时间
|
||||
@@ -48,6 +51,7 @@ use Request;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveDays($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchiveMethod($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Project whereArchivedAt($value)
|
||||
@@ -76,6 +80,7 @@ class Project extends AbstractModel
|
||||
|
||||
protected $appends = [
|
||||
'owner_userid',
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -91,6 +96,58 @@ class Project extends AbstractModel
|
||||
return $this->appendattrs['owner_userid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目管理员 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目负责人(与 project_users.owner=1 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目管理员(与 project_users.owner=2 一致)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ProjectUser::whereProjectId($this->id)
|
||||
->whereUserid($userid)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含项目管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
@@ -129,6 +186,7 @@ class Project extends AbstractModel
|
||||
'projects.*',
|
||||
'project_users.owner',
|
||||
'project_users.top_at',
|
||||
'project_users.sort',
|
||||
])
|
||||
->leftJoin('project_users', function ($leftJoin) use ($userid) {
|
||||
$leftJoin
|
||||
@@ -153,6 +211,7 @@ class Project extends AbstractModel
|
||||
'projects.*',
|
||||
'project_users.owner',
|
||||
'project_users.top_at',
|
||||
'project_users.sort',
|
||||
])
|
||||
->join('project_users', 'projects.id', '=', 'project_users.project_id')
|
||||
->where('project_users.userid', $userid);
|
||||
@@ -162,6 +221,18 @@ class Project extends AbstractModel
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索项目(Scope)
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
return $query->where("projects.name", "like", "%{$keyword}%");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务统计数据
|
||||
* @param $userid
|
||||
@@ -212,21 +283,40 @@ class Project extends AbstractModel
|
||||
return;
|
||||
}
|
||||
AbstractModel::transaction(function() {
|
||||
$userids = $this->relationUserids();
|
||||
// 拉所有项目成员 + 各自 owner 值
|
||||
$userOwnerMap = ProjectUser::whereProjectId($this->id)
|
||||
->pluck('owner', 'userid');
|
||||
$userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray();
|
||||
foreach ($userids as $userid) {
|
||||
$owner = (int)$userOwnerMap[$userid];
|
||||
// 巧合:编码完全一致 owner 0/1/2 → role 0/1/2
|
||||
$role = $owner;
|
||||
WebSocketDialogUser::updateInsert([
|
||||
'dialog_id' => $this->dialog_id,
|
||||
'userid' => $userid,
|
||||
], [
|
||||
'important' => 1
|
||||
], function () use ($userid) {
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
], function () use ($userid, $role) {
|
||||
return [
|
||||
'important' => 1,
|
||||
'role' => $role,
|
||||
'bot' => User::isBot($userid) ? 1 : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove();
|
||||
WebSocketDialogUser::whereDialogId($this->dialog_id)
|
||||
->whereNotIn('userid', $userids)
|
||||
->whereImportant(1)
|
||||
->remove();
|
||||
// 同步 dialog.owner_id 到主负责人(owner=1):前端「群主」标签依赖此字段,
|
||||
// 必须随项目主负责人变更(含用户离职转移)一起刷新,否则会显示已离职用户
|
||||
$primaryUserid = $userOwnerMap->search(ProjectUser::OWNER_PRIMARY);
|
||||
if ($primaryUserid !== false && (int)$primaryUserid > 0) {
|
||||
WebSocketDialog::whereId($this->dialog_id)
|
||||
->where('owner_id', '!=', (int)$primaryUserid)
|
||||
->update(['owner_id' => (int)$primaryUserid]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -363,7 +453,7 @@ class Project extends AbstractModel
|
||||
// 处理所有者权限
|
||||
if (isset($data['owner'])) {
|
||||
$owners = ProjectUser::whereProjectId($data['id'])
|
||||
->whereOwner(1)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
$recipients = [
|
||||
@@ -423,24 +513,25 @@ class Project extends AbstractModel
|
||||
$projectUserids = $this->relationUserids();
|
||||
foreach ($flows as $item) {
|
||||
$id = intval($item['id']);
|
||||
$name = trim(str_replace('|', '·', $item['name']));
|
||||
$turns = Base::arrayRetainInt($item['turns'] ?: [], true);
|
||||
$userids = Base::arrayRetainInt($item['userids'] ?: [], true);
|
||||
$usertype = trim($item['usertype']);
|
||||
$userlimit = intval($item['userlimit']);
|
||||
$columnid = intval($item['columnid']);
|
||||
if ($usertype == 'replace' && empty($userids)) {
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置流转模式时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$name}]设置错误,设置流转模式时必须填写状态负责人");
|
||||
}
|
||||
if ($usertype == 'merge' && empty($userids)) {
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置剔除模式时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$name}]设置错误,设置剔除模式时必须填写状态负责人");
|
||||
}
|
||||
if ($userlimit && empty($userids)) {
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,设置限制负责人时必须填写状态负责人");
|
||||
throw new ApiException("状态[{$name}]设置错误,设置限制负责人时必须填写状态负责人");
|
||||
}
|
||||
foreach ($userids as $userid) {
|
||||
if (!in_array($userid, $projectUserids)) {
|
||||
$nickname = User::userid2nickname($userid);
|
||||
throw new ApiException("状态[{$item['name']}]设置错误,状态负责人[{$nickname}]不在项目成员内");
|
||||
throw new ApiException("状态[{$name}]设置错误,状态负责人[{$nickname}]不在项目成员内");
|
||||
}
|
||||
}
|
||||
$flow = ProjectFlowItem::updateInsert([
|
||||
@@ -448,8 +539,9 @@ class Project extends AbstractModel
|
||||
'project_id' => $this->id,
|
||||
'flow_id' => $projectFlow->id,
|
||||
], [
|
||||
'name' => trim($item['name']),
|
||||
'name' => $name,
|
||||
'status' => trim($item['status']),
|
||||
'color' => trim($item['color']),
|
||||
'sort' => intval($item['sort']),
|
||||
'turns' => $turns,
|
||||
'userids' => $userids,
|
||||
@@ -469,7 +561,7 @@ class Project extends AbstractModel
|
||||
$hasEnd = true;
|
||||
}
|
||||
if (!$isInsert) {
|
||||
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name;
|
||||
$upTaskList[$flow->id] = $flow->status . "|" . $flow->name . "|" . $flow->color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,6 +605,38 @@ class Project extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否有权限创建项目(依据系统设置「项目创建权限」)
|
||||
* @param int $userid
|
||||
* @return bool
|
||||
*/
|
||||
public static function userCanCreate($userid)
|
||||
{
|
||||
// 范围已在 Setting::getSettingAttribute() 归一化(默认 ['all'])
|
||||
$modes = Base::settingFind('system', 'project_add_permission', ['all']);
|
||||
// 「所有人」:放行(与具体用户无关,避免未携带身份时被误判为无权)
|
||||
if (in_array('all', $modes)) {
|
||||
return true;
|
||||
}
|
||||
$user = User::find(intval($userid));
|
||||
if (empty($user)) {
|
||||
return false;
|
||||
}
|
||||
// 系统管理员始终可创建项目(不受开关限制)
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
// 部门负责人/部门管理员
|
||||
if (in_array('departmentOwner', $modes) && UserDepartment::getManagedDepartments($user->userid)->isNotEmpty()) {
|
||||
return true;
|
||||
}
|
||||
// 指定人员
|
||||
if (in_array('appoint', $modes)) {
|
||||
return in_array($user->userid, Base::settingFind('system', 'project_add_userids', []));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
* @param $params
|
||||
@@ -529,6 +653,10 @@ class Project extends AbstractModel
|
||||
$desc = trim(Arr::get($params, 'desc', ''));
|
||||
$flow = trim(Arr::get($params, 'flow', 'close'));
|
||||
$isPersonal = intval(Arr::get($params, 'personal'));
|
||||
// 个人项目为系统自动创建,不受创建权限限制
|
||||
if (!$isPersonal && !self::userCanCreate($userid)) {
|
||||
return Base::retError('当前仅指定人员可以创建项目');
|
||||
}
|
||||
if (mb_strlen($name) < 2) {
|
||||
return Base::retError('项目名称不可以少于2个字');
|
||||
} elseif (mb_strlen($name) > 32) {
|
||||
@@ -582,7 +710,7 @@ class Project extends AbstractModel
|
||||
$column['project_id'] = $project->id;
|
||||
ProjectColumn::createInstance($column)->save();
|
||||
}
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project');
|
||||
$dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid);
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException('创建项目聊天室失败');
|
||||
}
|
||||
@@ -590,7 +718,7 @@ class Project extends AbstractModel
|
||||
$project->save();
|
||||
//
|
||||
if ($flow == 'open') {
|
||||
$project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]'));
|
||||
$project->addFlow(Base::json2array('[{"id":-10,"name":"待处理","status":"start","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-11,"name":"进行中","status":"progress","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-12,"name":"待测试","status":"test","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-13,"name":"已完成","status":"end","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0},{"id":-14,"name":"已取消","status":"end","color":"#999999","turns":[-10,-11,-12,-13,-14],"userids":[],"usertype":"add","userlimit":0,"columnid":0}]'));
|
||||
}
|
||||
});
|
||||
//
|
||||
@@ -604,7 +732,9 @@ class Project extends AbstractModel
|
||||
* 获取项目信息(用于判断会员是否存在项目内)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制
|
||||
* @param null|bool|string $mustOwner true:负责人或项目管理员都可(共享操作);
|
||||
* 'primary':仅负责人(转让/删除/任命项目管理员等独占操作);
|
||||
* false:仅限非负责人;null:不限制
|
||||
* @return self
|
||||
*/
|
||||
public static function userProject($project_id, $archived = true, $mustOwner = null)
|
||||
@@ -622,9 +752,39 @@ class Project extends AbstractModel
|
||||
if ($mustOwner === true && !$project->owner) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === 'primary' && (int)$project->owner !== 1) {
|
||||
throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
if ($mustOwner === false && $project->owner) {
|
||||
throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目(含部门负责人只读视角兜底)
|
||||
* @param int $project_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
|
||||
* @return self
|
||||
*/
|
||||
public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
|
||||
$project = self::allData()->where('projects.id', intval($project_id))->first();
|
||||
if (empty($project)) {
|
||||
throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === true && $project->archived_at != null) {
|
||||
throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
|
||||
}
|
||||
if ($archived === false && $project->archived_at == null) {
|
||||
throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
return self::userProject($project_id, $archived, $mustOwner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Module\Base;
|
||||
* @property int|null $flow_id 流程ID
|
||||
* @property string|null $name 名称
|
||||
* @property string|null $status 状态
|
||||
* @property string|null $color 自定义颜色
|
||||
* @property array $turns 可流转
|
||||
* @property array $userids 状态负责人ID
|
||||
* @property string|null $usertype 流转模式
|
||||
@@ -30,6 +31,7 @@ use App\Module\Base;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColor($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereColumnid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectFlowItem whereFlowId($value)
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace App\Models;
|
||||
* @property string $name 标签名称
|
||||
* @property string|null $desc 标签描述
|
||||
* @property string|null $color 颜色
|
||||
* @property int $sort 排序
|
||||
* @property int $userid 创建人
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
@@ -29,6 +30,7 @@ namespace App\Models;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereProjectId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereSort($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTag whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
@@ -49,6 +51,7 @@ class ProjectTag extends AbstractModel
|
||||
'name',
|
||||
'desc',
|
||||
'color',
|
||||
'sort',
|
||||
'userid'
|
||||
];
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ use App\Tasks\PushTask;
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Observers\ProjectTaskObserver;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
@@ -75,6 +74,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedFollow($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTask whereArchivedUserid($value)
|
||||
@@ -157,7 +157,7 @@ class ProjectTask extends AbstractModel
|
||||
return;
|
||||
}
|
||||
if (!isset($this->appendattrs['sub_num'])) {
|
||||
$builder = self::whereParentId($this->id)->whereNull('archived_at');
|
||||
$builder = self::whereParentId($this->id);
|
||||
$this->appendattrs['sub_num'] = $builder->count();
|
||||
$this->appendattrs['sub_complete'] = $builder->whereNotNull('complete_at')->count();
|
||||
//
|
||||
@@ -354,6 +354,32 @@ class ProjectTask extends AbstractModel
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索任务(Scope)
|
||||
* 支持:任务ID(纯数字)、任务名称、描述
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
if (is_numeric($keyword)) {
|
||||
// 纯数字:匹配任务ID 或 名称/描述
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("project_tasks.id", intval($keyword))
|
||||
->orWhere("project_tasks.name", "like", "%{$keyword}%")
|
||||
->orWhere("project_tasks.desc", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 普通文本:搜索名称/描述
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("project_tasks.name", "like", "%{$keyword}%")
|
||||
->orWhere("project_tasks.desc", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成描述
|
||||
* @param $content
|
||||
@@ -373,6 +399,38 @@ class ProjectTask extends AbstractModel
|
||||
return Base::cutStr(strip_tags($content), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化时间参数,兼容 start_at/end_at 转换为 times
|
||||
* @param array $data 请求数据
|
||||
* @param self|null $task 任务实例(更新时传入)
|
||||
* @return array 处理后的data
|
||||
*/
|
||||
public static function normalizeTimes(array $data, ?self $task = null): array
|
||||
{
|
||||
if (isset($data['times']) || (!isset($data['start_at']) && !isset($data['end_at']))) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$startAt = $data['start_at'] ?? null;
|
||||
$endAt = $data['end_at'] ?? null;
|
||||
|
||||
if ($endAt && !$startAt) {
|
||||
// 只传 end_at:保留已有 start_at,否则取当前时间
|
||||
$startAt = $task?->start_at
|
||||
? Carbon::parse($task->start_at)->toDateTimeString()
|
||||
: date('Y-m-d H:i:s');
|
||||
} elseif ($startAt && !$endAt) {
|
||||
// 只传 start_at:必须已有 end_at
|
||||
if (!$task?->end_at) {
|
||||
throw new ApiException('请设置结束时间');
|
||||
}
|
||||
$endAt = Carbon::parse($task->end_at)->toDateTimeString();
|
||||
}
|
||||
|
||||
$data['times'] = [$startAt, $endAt];
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务
|
||||
* @param $data
|
||||
@@ -397,6 +455,7 @@ class ProjectTask extends AbstractModel
|
||||
$userid = User::userid();
|
||||
$visibility = $data['visibility_appoint'] ?? $data['visibility'];
|
||||
$visibility_userids = $data['visibility_appointor'] ?: [];
|
||||
$taskUserLimit = intval(Base::settingFind('system', 'task_user_limit'));
|
||||
//
|
||||
if (ProjectTask::whereProjectId($project_id)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
@@ -418,6 +477,22 @@ class ProjectTask extends AbstractModel
|
||||
}
|
||||
//
|
||||
$retPre = $parent_id ? '子任务' : '任务';
|
||||
|
||||
// 优先级:主任务在缺省时按系统默认补齐,并尽量补全 name/color
|
||||
if ($parent_id == 0) {
|
||||
$priorityList = Setting::normalizeTaskPriorityList(Base::setting('priority'));
|
||||
if ($p_level > 0) {
|
||||
$matched = reset(array_filter($priorityList, fn($item) => intval($item['priority']) === $p_level)) ?: null;
|
||||
} else {
|
||||
$matched = Setting::getDefaultTaskPriorityItem($priorityList);
|
||||
}
|
||||
if ($matched) {
|
||||
$p_level = $p_level > 0 ? $p_level : intval($matched['priority']);
|
||||
$p_name = $p_name ?: $matched['name'];
|
||||
$p_color = $p_color ?: $matched['color'];
|
||||
}
|
||||
}
|
||||
|
||||
$task = self::createInstance([
|
||||
'parent_id' => $parent_id,
|
||||
'project_id' => $project_id,
|
||||
@@ -456,8 +531,8 @@ class ProjectTask extends AbstractModel
|
||||
if (ProjectTask::authData($uid)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->count() > 500) {
|
||||
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过500个');
|
||||
->count() > $taskUserLimit) {
|
||||
throw new ApiException(User::userid2nickname($uid) . '负责或参与的未完成任务最多不能超过' . $taskUserLimit . '个');
|
||||
}
|
||||
$tmpArray[] = $uid;
|
||||
}
|
||||
@@ -486,7 +561,7 @@ class ProjectTask extends AbstractModel
|
||||
foreach ($projectFlowItem as $item) {
|
||||
if ($item->status == 'start') {
|
||||
$task->flow_item_id = $item->id;
|
||||
$task->flow_item_name = $item->status . "|" . $item->name;
|
||||
$task->flow_item_name = $item->status . "|" . $item->name . "|" . $item->color;
|
||||
$owner = array_merge($owner, $item->userids);
|
||||
break;
|
||||
}
|
||||
@@ -650,7 +725,7 @@ class ProjectTask extends AbstractModel
|
||||
$data['column_id'] = $newFlowItem->columnid;
|
||||
}
|
||||
$this->flow_item_id = $newFlowItem->id;
|
||||
$this->flow_item_name = $newFlowItem->status . "|" . $newFlowItem->name;
|
||||
$this->flow_item_name = $newFlowItem->status . "|" . $newFlowItem->name . "|" . $newFlowItem->color;
|
||||
$this->addLog("修改{任务}状态", [
|
||||
'flow' => $flowData,
|
||||
'change' => [$currentFlowItem?->name, $newFlowItem->name]
|
||||
@@ -675,12 +750,22 @@ class ProjectTask extends AbstractModel
|
||||
if ($this->complete_at) {
|
||||
throw new ApiException('任务已完成');
|
||||
}
|
||||
$this->completeTask(Carbon::now(), isset($newFlowItem) ? $newFlowItem->name : null);
|
||||
// 只有用户单独提交 complete_at 时才自动设置工作流状态
|
||||
if (!Arr::exists($data, 'flow_item_id')) {
|
||||
$flowItemName = $this->checkAndAutoSetFlowItem('end', -4005);
|
||||
} else {
|
||||
$flowItemName = isset($newFlowItem) ? $newFlowItem->name : null;
|
||||
}
|
||||
$this->completeTask(Carbon::now(), $flowItemName);
|
||||
} else {
|
||||
// 标记未完成
|
||||
if (!$this->complete_at) {
|
||||
throw new ApiException('未完成任务');
|
||||
}
|
||||
// 只有用户单独提交 complete_at 时才自动设置工作流状态
|
||||
if (!Arr::exists($data, 'flow_item_id')) {
|
||||
$this->checkAndAutoSetFlowItem('start', -4006);
|
||||
}
|
||||
$this->completeTask(null);
|
||||
}
|
||||
$updateMarking['is_update_project'] = true;
|
||||
@@ -758,7 +843,7 @@ class ProjectTask extends AbstractModel
|
||||
$this->visibility = $data["visibility"];
|
||||
ProjectTask::whereParentId($data['task_id'])->change(['visibility' => $data["visibility"]]);
|
||||
}
|
||||
ProjectTaskVisibilityUser::whereTaskId($data['task_id'])->delete();
|
||||
ProjectTaskVisibilityUser::whereTaskId($data['task_id'])->remove();
|
||||
if (Arr::exists($data, 'visibility_appointor')) {
|
||||
foreach ($data['visibility_appointor'] as $uid) {
|
||||
if ($uid) {
|
||||
@@ -1144,9 +1229,14 @@ class ProjectTask extends AbstractModel
|
||||
*/
|
||||
public function copyTask()
|
||||
{
|
||||
return AbstractModel::transaction(function() {
|
||||
// 复制任务
|
||||
$task = $this->replicate();
|
||||
$source = $this->fresh(['content', 'taskFile', 'taskUser']);
|
||||
if (!$source) {
|
||||
throw new ApiException('任务不存在');
|
||||
}
|
||||
|
||||
return AbstractModel::transaction(function () use ($source) {
|
||||
// 复制任务(使用最新数据,避免复制临时字段)
|
||||
$task = $source->replicate();
|
||||
$task->dialog_id = 0;
|
||||
$task->archived_at = null;
|
||||
$task->archived_userid = 0;
|
||||
@@ -1155,21 +1245,21 @@ class ProjectTask extends AbstractModel
|
||||
$task->created_at = Carbon::now();
|
||||
$task->save();
|
||||
// 复制任务内容
|
||||
if ($this->content) {
|
||||
$tmp = $this->content->replicate();
|
||||
if ($source->content) {
|
||||
$tmp = $source->content->replicate();
|
||||
$tmp->task_id = $task->id;
|
||||
$tmp->created_at = Carbon::now();
|
||||
$tmp->save();
|
||||
}
|
||||
// 复制任务附件
|
||||
foreach ($this->taskFile as $taskFile) {
|
||||
foreach ($source->taskFile as $taskFile) {
|
||||
$tmp = $taskFile->replicate();
|
||||
$tmp->task_id = $task->id;
|
||||
$tmp->created_at = Carbon::now();
|
||||
$tmp->save();
|
||||
}
|
||||
// 复制任务成员
|
||||
foreach ($this->taskUser as $taskUser) {
|
||||
foreach ($source->taskUser as $taskUser) {
|
||||
$tmp = $taskUser->replicate();
|
||||
$tmp->task_id = $task->id;
|
||||
$tmp->task_pid = $task->id;
|
||||
@@ -1181,6 +1271,126 @@ class ProjectTask extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的工作流状态项(start 和 end)
|
||||
* @param int $projectId 项目ID
|
||||
* @return array ['start' => ProjectFlowItem|null, 'end' => ProjectFlowItem|null]
|
||||
*/
|
||||
public static function getProjectFlowItems(int $projectId): array
|
||||
{
|
||||
$startFlowItem = null;
|
||||
$endFlowItem = null;
|
||||
$projectFlow = ProjectFlow::whereProjectId($projectId)->orderByDesc('id')->first();
|
||||
if ($projectFlow) {
|
||||
$flowItems = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get();
|
||||
foreach ($flowItems as $item) {
|
||||
if ($item->status == 'start' && !$startFlowItem) {
|
||||
$startFlowItem = $item;
|
||||
}
|
||||
if ($item->status == 'end' && !$endFlowItem) {
|
||||
$endFlowItem = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['start' => $startFlowItem, 'end' => $endFlowItem];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成工作流状态名称
|
||||
* @param ProjectFlowItem|null $flowItem
|
||||
* @return string
|
||||
*/
|
||||
public static function formatFlowItemName(?ProjectFlowItem $flowItem): string
|
||||
{
|
||||
return $flowItem ? ($flowItem->status . '|' . $flowItem->name . '|' . $flowItem->color) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制子任务到新的父任务
|
||||
* @param ProjectTask $newParentTask 新的父任务
|
||||
* @param array $options 选项
|
||||
* - reset_complete: 是否重置完成状态并映射到 start 工作流(默认 true)
|
||||
* - sync_time: 是否同步时间到父任务的时间(默认 false)
|
||||
* - update_project: 是否更新项目相关字段(project_id、column_id)(默认 false)
|
||||
* @return array 新创建的子任务数组
|
||||
*/
|
||||
public function copySubTasks(ProjectTask $newParentTask, array $options = []): array
|
||||
{
|
||||
$resetComplete = $options['reset_complete'] ?? true;
|
||||
$syncTime = $options['sync_time'] ?? false;
|
||||
$updateProject = $options['update_project'] ?? false;
|
||||
|
||||
$newSubTasks = [];
|
||||
$subTasks = self::whereParentId($this->id)->get();
|
||||
if ($subTasks->isEmpty()) {
|
||||
return $newSubTasks;
|
||||
}
|
||||
|
||||
// 获取 start 工作流状态
|
||||
$flowItems = $resetComplete ? self::getProjectFlowItems($newParentTask->project_id) : ['start' => null];
|
||||
$startFlowItem = $flowItems['start'];
|
||||
|
||||
foreach ($subTasks as $subTask) {
|
||||
$newSubTask = $subTask->copyTask();
|
||||
$newSubTask->parent_id = $newParentTask->id;
|
||||
|
||||
// 同步时间
|
||||
if ($syncTime) {
|
||||
$newSubTask->start_at = $newParentTask->start_at;
|
||||
$newSubTask->end_at = $newParentTask->end_at;
|
||||
}
|
||||
|
||||
// 更新项目相关字段
|
||||
if ($updateProject) {
|
||||
$newSubTask->project_id = $newParentTask->project_id;
|
||||
$newSubTask->column_id = $newParentTask->column_id;
|
||||
}
|
||||
|
||||
// 重置完成状态
|
||||
if ($resetComplete) {
|
||||
$newSubTask->complete_at = null;
|
||||
$newSubTask->flow_item_id = $startFlowItem?->id ?? 0;
|
||||
$newSubTask->flow_item_name = self::formatFlowItemName($startFlowItem);
|
||||
}
|
||||
|
||||
$newSubTask->save();
|
||||
$newSubTasks[] = $newSubTask;
|
||||
}
|
||||
|
||||
return $newSubTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动子任务到新项目/列
|
||||
* @param int $projectId 目标项目ID
|
||||
* @param int $columnId 目标列ID
|
||||
*/
|
||||
public function moveSubTasks(int $projectId, int $columnId): void
|
||||
{
|
||||
$subTasks = self::whereParentId($this->id)->get();
|
||||
if ($subTasks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flowItems = self::getProjectFlowItems($projectId);
|
||||
$startFlowItem = $flowItems['start'];
|
||||
$endFlowItem = $flowItems['end'];
|
||||
|
||||
foreach ($subTasks as $subTask) {
|
||||
$subTask->project_id = $projectId;
|
||||
$subTask->column_id = $columnId;
|
||||
// 根据完成状态映射工作流
|
||||
if ($subTask->complete_at) {
|
||||
$subTask->flow_item_id = $endFlowItem?->id ?? 0;
|
||||
$subTask->flow_item_name = self::formatFlowItemName($endFlowItem);
|
||||
} else {
|
||||
$subTask->flow_item_id = $startFlowItem?->id ?? 0;
|
||||
$subTask->flow_item_name = self::formatFlowItemName($startFlowItem);
|
||||
}
|
||||
$subTask->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步项目成员至聊天室
|
||||
*/
|
||||
@@ -1339,6 +1549,49 @@ class ProjectTask extends AbstractModel
|
||||
return $this->appendattrs['has_owner'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并自动设置工作流状态
|
||||
* @param string $status 目标状态类型 ('start' 或 'end')
|
||||
* @param int $errorCode 多状态时的错误码 (-4005 或 -4006)
|
||||
* @return string|null 自动设置的状态名称,无状态时返回 null
|
||||
*/
|
||||
private function checkAndAutoSetFlowItem(string $status, int $errorCode): ?string
|
||||
{
|
||||
$flowItems = ProjectFlowItem::whereProjectId($this->project_id)
|
||||
->whereStatus($status)
|
||||
->get(['id', 'name', 'status', 'color']);
|
||||
|
||||
if ($flowItems->count() > 1) {
|
||||
$msg = $status === 'end' ? '存在多个结束状态,请选择要使用的状态' : '存在多个开始状态,请选择要使用的状态';
|
||||
throw new ApiException($msg, [
|
||||
'task_id' => $this->id,
|
||||
'flow_items' => $flowItems->toArray(),
|
||||
], $errorCode);
|
||||
}
|
||||
|
||||
if ($flowItems->count() == 1) {
|
||||
$autoFlowItem = $flowItems->first();
|
||||
$oldFlowItemId = $this->flow_item_id;
|
||||
$oldFlowItemName = $this->flow_item_name;
|
||||
$this->flow_item_id = $autoFlowItem->id;
|
||||
$this->flow_item_name = $autoFlowItem->status . "|" . $autoFlowItem->name . "|" . $autoFlowItem->color;
|
||||
|
||||
if ($oldFlowItemId != $this->flow_item_id) {
|
||||
ProjectTaskFlowChange::createInstance([
|
||||
'task_id' => $this->id,
|
||||
'userid' => User::userid(),
|
||||
'before_flow_item_id' => $oldFlowItemId,
|
||||
'before_flow_item_name' => $oldFlowItemName,
|
||||
'after_flow_item_id' => $this->flow_item_id,
|
||||
'after_flow_item_name' => $this->flow_item_name,
|
||||
])->save();
|
||||
}
|
||||
return $autoFlowItem->name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已完成、未完成
|
||||
* @param Carbon|null $complete_at 完成时间
|
||||
@@ -1556,8 +1809,9 @@ class ProjectTask extends AbstractModel
|
||||
* @param string $action
|
||||
* @param array|self $data 发送内容,默认为[id, parent_id, project_id, column_id, dialog_id]
|
||||
* @param array $userid 指定会员,默认为项目所有成员
|
||||
* @param bool $ignoreSelf 是否忽略当前连接
|
||||
*/
|
||||
public function pushMsg($action, $data = null, $userid = null)
|
||||
public function pushMsg($action, $data = null, $userid = null, $ignoreSelf = true)
|
||||
{
|
||||
if (!$this->project) {
|
||||
return;
|
||||
@@ -1569,77 +1823,91 @@ class ProjectTask extends AbstractModel
|
||||
'project_id' => $this->project_id,
|
||||
'column_id' => $this->column_id,
|
||||
'dialog_id' => $this->dialog_id,
|
||||
'visibility' => $this->visibility,
|
||||
];
|
||||
} elseif ($data instanceof self) {
|
||||
$data = $data->toArray();
|
||||
}
|
||||
//
|
||||
|
||||
// 获取接收会员
|
||||
if ($userid === null) {
|
||||
$userids = $this->project->relationUserids();
|
||||
} else {
|
||||
$userids = is_array($userid) ? $userid : [$userid];
|
||||
}
|
||||
//
|
||||
$array = [];
|
||||
if (Arr::exists($data, 'owner') || Arr::exists($data, 'assist')) {
|
||||
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
|
||||
// 负责人
|
||||
$owners = $taskUser->where('owner', 1)->pluck('userid')->toArray();
|
||||
$owners = array_intersect($userids, $owners);
|
||||
if ($owners) {
|
||||
$array[] = [
|
||||
'userid' => array_values($owners),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 1,
|
||||
'assist' => 1,
|
||||
])
|
||||
];
|
||||
}
|
||||
// 协助人
|
||||
$assists = $taskUser->where('owner', 0)->pluck('userid')->toArray();
|
||||
$assists = array_intersect($userids, $assists);
|
||||
if ($assists) {
|
||||
$array[] = [
|
||||
'userid' => array_values($assists),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 1,
|
||||
])
|
||||
];
|
||||
}
|
||||
// 其他人
|
||||
switch ($data['visibility']) {
|
||||
case 1:
|
||||
// 项目人员,除了负责人、协助人项目其他人
|
||||
$userids = array_diff($userids, $owners, $assists);
|
||||
break;
|
||||
case 2:
|
||||
// 任务人员,除了负责人、协助人
|
||||
$userids = [];
|
||||
break;
|
||||
case 3:
|
||||
// 指定成员
|
||||
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
|
||||
$userids = array_diff($specifys, $owners, $assists);
|
||||
break;
|
||||
default:
|
||||
$userids = [];
|
||||
break;
|
||||
}
|
||||
if ($userids) {
|
||||
$array[] = [
|
||||
'userid' => array_values($userids),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 0,
|
||||
])
|
||||
];
|
||||
}
|
||||
$userids = array_values(array_unique(array_map('intval', $userids)));
|
||||
if (empty($userids)) {
|
||||
return;
|
||||
}
|
||||
//
|
||||
|
||||
// 按可见性分组推送
|
||||
$taskUser = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->get();
|
||||
$ownerList = $taskUser->where('owner', 1)->pluck('userid')->toArray();
|
||||
$assistList = $taskUser->where('owner', 0)->pluck('userid')->toArray();
|
||||
|
||||
$ownerUsers = array_values(array_intersect($userids, $ownerList));
|
||||
$assistUsers = array_values(array_diff(array_intersect($userids, $assistList), $ownerUsers));
|
||||
|
||||
$array = [];
|
||||
|
||||
// 负责人
|
||||
if ($ownerUsers) {
|
||||
$array[] = [
|
||||
'userid' => $ownerUsers,
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 1,
|
||||
'assist' => 0,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
// 协助人
|
||||
if ($assistUsers) {
|
||||
$array[] = [
|
||||
'userid' => $assistUsers,
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 1,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
// 其他人
|
||||
$otherUsers = [];
|
||||
switch (intval($data['visibility'])) {
|
||||
case 1:
|
||||
// 项目人员:除了负责人、协助人项目其他人
|
||||
$otherUsers = array_diff($userids, $ownerUsers, $assistUsers);
|
||||
break;
|
||||
case 2:
|
||||
// 任务人员:除了负责人、协助人
|
||||
// $otherUsers = [];
|
||||
break;
|
||||
case 3:
|
||||
// 指定成员
|
||||
$specifys = ProjectTaskVisibilityUser::select(['userid'])->whereTaskId($data['id'])->pluck('userid')->toArray();
|
||||
$otherUsers = array_diff(array_intersect($userids, $specifys), $ownerUsers, $assistUsers);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($otherUsers) {
|
||||
$array[] = [
|
||||
'userid' => array_values($otherUsers),
|
||||
'data' => array_merge($data, [
|
||||
'owner' => 0,
|
||||
'assist' => 0,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($array)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 推送
|
||||
foreach ($array as $item) {
|
||||
$params = [
|
||||
'ignoreFd' => Request::header('fd'),
|
||||
'ignoreFd' => $ignoreSelf ? Request::header('fd') : null,
|
||||
'userid' => $item['userid'],
|
||||
'msg' => [
|
||||
'type' => 'projectTask',
|
||||
@@ -1723,7 +1991,9 @@ class ProjectTask extends AbstractModel
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
//
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人
|
||||
$projectOwnerids = ProjectUser::whereProjectId($this->project_id)
|
||||
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
|
||||
->pluck('userid')->toArray(); // 项目负责人(含项目管理员)
|
||||
//
|
||||
$array = [];
|
||||
if (empty($userids)) {
|
||||
@@ -1898,17 +2168,14 @@ class ProjectTask extends AbstractModel
|
||||
$taskUser->save();
|
||||
}
|
||||
}
|
||||
// 子任务
|
||||
ProjectTask::whereParentId($this->id)->change([
|
||||
'project_id' => $projectId,
|
||||
'column_id' => $columnId,
|
||||
]);
|
||||
// 子任务 - 根据完成状态映射工作流
|
||||
$this->moveSubTasks($projectId, $columnId);
|
||||
//
|
||||
if ($flowItemId) {
|
||||
// 更新任务流程
|
||||
$flowItem = projectFlowItem::whereProjectId($projectId)->whereId($flowItemId)->first();
|
||||
$this->flow_item_id = $flowItemId;
|
||||
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name;
|
||||
$this->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color;
|
||||
if ($flowItem->status == 'end') {
|
||||
$this->completeTask(Carbon::now(), $flowItem->name);
|
||||
} else {
|
||||
@@ -1929,67 +2196,6 @@ class ProjectTask extends AbstractModel
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成AI上下文
|
||||
* @return array
|
||||
*/
|
||||
public function AIContext()
|
||||
{
|
||||
$contexts = [];
|
||||
if ($this->archived_at) {
|
||||
$contexts[] = "任务状态:已归档";
|
||||
$contexts[] = "归档时间:" . $this->archived_at;
|
||||
} elseif ($this->complete_at) {
|
||||
$contexts[] = "任务状态:已完成";
|
||||
$contexts[] = "完成时间:" . $this->complete_at;
|
||||
} elseif ($this->end_at && Carbon::parse($this->end_at)->lt(Carbon::now())) {
|
||||
$contexts[] = "任务状态:已过期";
|
||||
$contexts[] = "任务截止时间:" . $this->end_at;
|
||||
} else {
|
||||
$contexts[] = "任务状态:进行中";
|
||||
if ($this->start_at) {
|
||||
$contexts[] = "任务开始时间:" . $this->start_at;
|
||||
}
|
||||
if ($this->end_at) {
|
||||
$contexts[] = "任务截止时间:" . $this->end_at;
|
||||
}
|
||||
}
|
||||
$contexts[] = "当前系统时间:" . Carbon::now()->toDateTimeString();
|
||||
if ($this->content) {
|
||||
$taskDesc = $this->content?->getContentInfo();
|
||||
if ($taskDesc) {
|
||||
$converter = new HtmlConverter(['strip_tags' => true]);
|
||||
$descContent = Base::cutStr($converter->convert($taskDesc['content']), 2000);
|
||||
$contexts[] = <<<EOF
|
||||
任务描述:
|
||||
```md
|
||||
{$descContent}
|
||||
```
|
||||
EOF;
|
||||
}
|
||||
}
|
||||
$subTask = ProjectTask::select(['id', 'name', 'complete_at', 'end_at'])->whereParentId($this->id)->get();
|
||||
if ($subTask->isNotEmpty()) {
|
||||
$subTaskContent = $subTask->map(function($item) {
|
||||
if ($item->complete_at) {
|
||||
$status = " (已完成)";
|
||||
} elseif ($item->end_at && Carbon::parse($item->end_at)->lt(Carbon::now())) {
|
||||
$status = " (已过期)";
|
||||
} else {
|
||||
$status = " (进行中)";
|
||||
}
|
||||
return " - {$item->name} {$status}";
|
||||
})->join("\n");
|
||||
if ($subTaskContent) {
|
||||
$contexts[] = <<<EOF
|
||||
子任务列表:
|
||||
{$subTaskContent}
|
||||
EOF;
|
||||
}
|
||||
}
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务
|
||||
* @param $task_id
|
||||
@@ -2051,4 +2257,98 @@ class ProjectTask extends AbstractModel
|
||||
//
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务(含部门负责人只读视角兜底)
|
||||
* @param int $task_id
|
||||
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
|
||||
* @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制
|
||||
* @param array $with
|
||||
* @return self
|
||||
*/
|
||||
public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = [])
|
||||
{
|
||||
$user = User::auth();
|
||||
$departmentView = UserDepartment::ownerViewContext($user, true);
|
||||
if ($departmentView['enabled']) {
|
||||
$builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id));
|
||||
if ($trashed === false) {
|
||||
$builder->onlyTrashed();
|
||||
} elseif ($trashed === null) {
|
||||
$builder->withTrashed();
|
||||
}
|
||||
$task = $builder->first();
|
||||
// 仅"全员可见"(visibility=1)的任务走负责人只读视角;指定成员可见的任务交由 userTask 按可见性校验
|
||||
if (!empty($task) && intval($task->visibility) === 1 && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) {
|
||||
if ($archived === true && $task->archived_at != null) {
|
||||
throw new ApiException('任务已归档', ['task_id' => $task_id]);
|
||||
}
|
||||
if ($archived === false && $task->archived_at == null) {
|
||||
throw new ApiException('任务未归档', ['task_id' => $task_id]);
|
||||
}
|
||||
return $task;
|
||||
}
|
||||
}
|
||||
return self::userTask($task_id, $archived, $trashed, $with);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建指定周期内的未完成任务查询(用于周报/日报等)
|
||||
* @param int $userid
|
||||
* @param Carbon $start_time
|
||||
* @param Carbon $end_time
|
||||
* @param bool $includeUpdatedForNoPlan 无计划时间任务是否按周期内更新时间一并纳入
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public static function buildUnfinishedTaskQuery(int $userid, Carbon $start_time, Carbon $end_time, bool $includeUpdatedForNoPlan = true)
|
||||
{
|
||||
return self::query()
|
||||
->join("projects", "projects.id", "=", "project_tasks.project_id")
|
||||
->whereNull("projects.archived_at")
|
||||
->whereNull("project_tasks.complete_at")
|
||||
->whereHas("taskUser", function ($query) use ($userid) {
|
||||
$query->where("userid", $userid);
|
||||
})
|
||||
->where(function ($query) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
|
||||
// 1) 有计划时间:计划时间与给定周期 [start_time, end_time] 有交集
|
||||
$query->where(function ($q1) use ($start_time, $end_time) {
|
||||
$q1->whereNotNull('project_tasks.start_at')
|
||||
->whereNotNull('project_tasks.end_at')
|
||||
->where(function ($q2) use ($start_time, $end_time) {
|
||||
$q2->whereBetween('project_tasks.start_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
|
||||
->orWhereBetween('project_tasks.end_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()])
|
||||
->orWhere(function ($q3) use ($start_time, $end_time) {
|
||||
$q3->where('project_tasks.start_at', '<=', $start_time->toDateTimeString())
|
||||
->where('project_tasks.end_at', '>=', $end_time->toDateTimeString());
|
||||
});
|
||||
});
|
||||
});
|
||||
// 2) 无计划时间
|
||||
$query->orWhere(function ($q1) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
|
||||
$q1->whereNull('project_tasks.start_at')
|
||||
->whereNull('project_tasks.end_at')
|
||||
->where(function ($q2) use ($start_time, $end_time, $includeUpdatedForNoPlan) {
|
||||
$q2->whereBetween('project_tasks.created_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
|
||||
if ($includeUpdatedForNoPlan) {
|
||||
$q2->orWhereBetween('project_tasks.updated_at', [$start_time->toDateTimeString(), $end_time->toDateTimeString()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
->select("project_tasks.*")
|
||||
->orderByDesc("project_tasks.id");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断工作流名称是否为取消态(多语言)
|
||||
* @param string|null $flowItemName
|
||||
* @return bool
|
||||
*/
|
||||
public static function isCanceledFlowName(?string $flowItemName): bool
|
||||
{
|
||||
if (empty($flowItemName)) {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/已取消|Cancelled|취소됨|キャンセル済み|Abgebrochen|Annulé|Dibatalkan|Отменено/', $flowItemName) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
154
app/Models/ProjectTaskAiEvent.php
Normal file
154
app/Models/ProjectTaskAiEvent.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ProjectTaskAiEvent
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $task_id 任务ID
|
||||
* @property string $event_type 事件类型
|
||||
* @property string $status 状态
|
||||
* @property int $retry_count 重试次数
|
||||
* @property array|null $result 执行结果
|
||||
* @property string|null $error 错误信息
|
||||
* @property int $msg_id 消息ID
|
||||
* @property \Illuminate\Support\Carbon|null $executed_at
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
*/
|
||||
class ProjectTaskAiEvent extends AbstractModel
|
||||
{
|
||||
const EVENT_DESCRIPTION = 'description';
|
||||
const EVENT_SUBTASKS = 'subtasks';
|
||||
const EVENT_ASSIGNEE = 'assignee';
|
||||
const EVENT_SIMILAR = 'similar';
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_FAILED = 'failed';
|
||||
const STATUS_SKIPPED = 'skipped';
|
||||
const STATUS_APPLIED = 'applied';
|
||||
const STATUS_DISMISSED = 'dismissed';
|
||||
|
||||
const MAX_RETRY = 3;
|
||||
|
||||
protected $table = 'project_task_ai_events';
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'event_type',
|
||||
'status',
|
||||
'retry_count',
|
||||
'result',
|
||||
'error',
|
||||
'msg_id',
|
||||
'executed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'result' => 'array',
|
||||
'executed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联任务
|
||||
*/
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件类型
|
||||
*/
|
||||
public static function getEventTypes(): array
|
||||
{
|
||||
return [
|
||||
self::EVENT_DESCRIPTION,
|
||||
self::EVENT_SUBTASKS,
|
||||
self::EVENT_ASSIGNEE,
|
||||
self::EVENT_SIMILAR,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为处理中
|
||||
*/
|
||||
public function markProcessing(): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_PROCESSING,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为完成
|
||||
*/
|
||||
public function markCompleted(array $result, int $msgId = 0): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'result' => $result,
|
||||
'msg_id' => $msgId,
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为失败
|
||||
*/
|
||||
public function markFailed(string $error): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'retry_count' => $this->retry_count + 1,
|
||||
'error' => $error,
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为跳过
|
||||
*/
|
||||
public function markSkipped(string $reason = ''): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_SKIPPED,
|
||||
'error' => $reason,
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可以重试
|
||||
*/
|
||||
public function canRetry(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED
|
||||
&& $this->retry_count < self::MAX_RETRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为已采纳
|
||||
*/
|
||||
public function markApplied(): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_APPLIED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为已忽略
|
||||
*/
|
||||
public function markDismissed(): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_DISMISSED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
223
app/Models/ProjectTaskRelation.php
Normal file
223
app/Models/ProjectTaskRelation.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Module\Base;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ProjectTaskRelation
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $task_id 任务ID
|
||||
* @property int $related_task_id 关联任务ID
|
||||
* @property string $direction 关系方向: mention/mentioned_by
|
||||
* @property int|null $dialog_id 来源会话ID
|
||||
* @property int|null $msg_id 来源消息ID
|
||||
* @property int|null $userid 提及人
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\ProjectTask|null $relatedTask
|
||||
* @property-read \App\Models\ProjectTask|null $task
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDialogId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereDirection($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereMsgId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereRelatedTaskId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereTaskId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectTaskRelation whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ProjectTaskRelation extends AbstractModel
|
||||
{
|
||||
public const DIRECTION_MENTION = 'mention';
|
||||
public const DIRECTION_MENTIONED_BY = 'mentioned_by';
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'related_task_id',
|
||||
'direction',
|
||||
'dialog_id',
|
||||
'msg_id',
|
||||
'userid',
|
||||
];
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'task_id');
|
||||
}
|
||||
|
||||
public function relatedTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'related_task_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建双向任务关联
|
||||
*
|
||||
* @param int $sourceTaskId 源任务ID
|
||||
* @param int $targetTaskId 目标任务ID
|
||||
* @param int|null $dialogId 来源对话ID
|
||||
* @param int|null $msgId 来源消息ID
|
||||
* @param int|null $userid 操作人
|
||||
* @param bool $push 是否推送更新
|
||||
* @return bool 是否创建成功
|
||||
*/
|
||||
public static function createRelation(
|
||||
int $sourceTaskId,
|
||||
int $targetTaskId,
|
||||
?int $dialogId = null,
|
||||
?int $msgId = null,
|
||||
?int $userid = null,
|
||||
bool $push = true
|
||||
): bool {
|
||||
if ($sourceTaskId === $targetTaskId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sourceTask = ProjectTask::with('project')->find($sourceTaskId);
|
||||
$targetTask = ProjectTask::with('project')->find($targetTaskId);
|
||||
|
||||
if (!$sourceTask || !$targetTask) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($sourceTask->deleted_at || $targetTask->deleted_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建正向关联:源任务提及目标任务
|
||||
$mentionRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $sourceTaskId,
|
||||
'related_task_id' => $targetTaskId,
|
||||
'direction' => self::DIRECTION_MENTION,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $dialogId,
|
||||
'msg_id' => $msgId,
|
||||
'userid' => $userid,
|
||||
]
|
||||
);
|
||||
|
||||
// 创建反向关联:目标任务被源任务提及
|
||||
$reverseRelation = static::updateOrCreate(
|
||||
[
|
||||
'task_id' => $targetTaskId,
|
||||
'related_task_id' => $sourceTaskId,
|
||||
'direction' => self::DIRECTION_MENTIONED_BY,
|
||||
],
|
||||
[
|
||||
'dialog_id' => $dialogId,
|
||||
'msg_id' => $msgId,
|
||||
'userid' => $userid,
|
||||
]
|
||||
);
|
||||
|
||||
// 推送关联更新
|
||||
if ($push) {
|
||||
$needPush = $mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()
|
||||
|| $reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged();
|
||||
|
||||
if ($needPush) {
|
||||
if ($sourceTask->project) {
|
||||
$sourceTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
if ($targetTask->project) {
|
||||
$targetTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除双向任务关联
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $relatedTaskId 关联任务ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public static function deleteRelation(int $taskId, int $relatedTaskId): bool
|
||||
{
|
||||
// 删除正向关联
|
||||
$deleted1 = static::whereTaskId($taskId)
|
||||
->whereRelatedTaskId($relatedTaskId)
|
||||
->delete();
|
||||
|
||||
// 删除反向关联
|
||||
$deleted2 = static::whereTaskId($relatedTaskId)
|
||||
->whereRelatedTaskId($taskId)
|
||||
->delete();
|
||||
|
||||
if ($deleted1 || $deleted2) {
|
||||
// 推送关联更新
|
||||
$sourceTask = ProjectTask::with('project')->find($taskId);
|
||||
$targetTask = ProjectTask::with('project')->find($relatedTaskId);
|
||||
if ($sourceTask?->project) {
|
||||
$sourceTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
if ($targetTask?->project) {
|
||||
$targetTask->pushMsg('relation', null, null, false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
|
||||
{
|
||||
if ($msg->type !== 'text') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $msg->msg;
|
||||
if (!is_array($payload)) {
|
||||
$payload = Base::json2array($msg->getRawOriginal('msg'));
|
||||
}
|
||||
|
||||
$text = $payload['text'] ?? '';
|
||||
if (!$text || !preg_match_all('/<span class="mention task" data-id="(\d+)">#?(.*?)<\/span>/i', $text, $matches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetIds = array_values(array_unique(array_filter(array_map('intval', $matches[1] ?? []))));
|
||||
if (empty($targetIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceTaskIds = ProjectTask::whereDialogId($msg->dialog_id)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (empty($sourceTaskIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($sourceTaskIds as $sourceTaskId) {
|
||||
foreach ($targetIds as $targetId) {
|
||||
self::createRelation(
|
||||
$sourceTaskId,
|
||||
$targetId,
|
||||
$msg->dialog_id,
|
||||
$msg->id,
|
||||
$msg->userid
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ namespace App\Models;
|
||||
* @property int $sort 排序
|
||||
* @property int $is_default 是否默认模板
|
||||
* @property int $userid 创建人
|
||||
* @property int $use_count 累计使用次数
|
||||
* @property \Illuminate\Support\Carbon|null $last_used_at 最近一次使用时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Project $project
|
||||
@@ -52,7 +54,18 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
'content',
|
||||
'sort',
|
||||
'is_default',
|
||||
'userid'
|
||||
'userid',
|
||||
'use_count',
|
||||
'last_used_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -74,4 +87,17 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子递增使用次数并刷新最近使用时间。
|
||||
*/
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->newQuery()
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'use_count' => \DB::raw('use_count + 1'),
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Module\Base;
|
||||
* @property int|null $userid 成员ID
|
||||
* @property int|null $owner 是否负责人
|
||||
* @property \Illuminate\Support\Carbon|null $top_at 置顶时间
|
||||
* @property int|null $sort 排序(ASC)
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Project|null $project
|
||||
@@ -28,6 +29,7 @@ use App\Module\Base;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereOwner($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereProjectId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereSort($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereTopAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ProjectUser whereUserid($value)
|
||||
@@ -35,6 +37,36 @@ use App\Module\Base;
|
||||
*/
|
||||
class ProjectUser extends AbstractModel
|
||||
{
|
||||
/** @var int 普通成员编码 */
|
||||
const OWNER_MEMBER = 0;
|
||||
/** @var int 项目负责人编码 */
|
||||
const OWNER_PRIMARY = 1;
|
||||
/** @var int 项目管理员编码 */
|
||||
const OWNER_DEPUTY = 2;
|
||||
|
||||
/**
|
||||
* 是否项目负责人(owner=1)
|
||||
*/
|
||||
public function isPrimaryOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_PRIMARY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否项目管理员(owner=2)
|
||||
*/
|
||||
public function isDeputyOwner(): bool
|
||||
{
|
||||
return (int)$this->owner === self::OWNER_DEPUTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含项目管理员)
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
return $this->isPrimaryOwner() || $this->isDeputyOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
@@ -59,12 +91,19 @@ class ProjectUser extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
$row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first();
|
||||
if ($row) {
|
||||
// 已存在则删除原数据,判断改变已存在的数据
|
||||
$row->owner = max($row->owner, $item->owner);
|
||||
// 已存在:仅当离职用户是项目负责人(owner=1)时把接收人升为项目负责人;
|
||||
// 离职用户是项目管理员(owner=2)时不传项目管理员身份给接收人(spec:项目管理员不替补)
|
||||
if ((int)$item->owner === self::OWNER_PRIMARY) {
|
||||
$row->owner = self::OWNER_PRIMARY;
|
||||
}
|
||||
// owner=2/0:保留接收人原有 owner 值不变
|
||||
$row->save();
|
||||
$item->delete();
|
||||
} else {
|
||||
// 不存在则改变原数据
|
||||
// 不存在:转移时如果离职用户是项目管理员,降级为普通成员(不带项目管理员身份过户给接收人)
|
||||
if ((int)$item->owner === self::OWNER_DEPUTY) {
|
||||
$item->owner = self::OWNER_MEMBER;
|
||||
}
|
||||
$item->userid = $newUserid;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use JetBrains\PhpStorm\Pure;
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,9 @@ use JetBrains\PhpStorm\Pure;
|
||||
* @property string $sign 汇报唯一标识
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportReceive> $Receives
|
||||
* @property-read int|null $receives_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReportAnalysis> $aiAnalyses
|
||||
* @property-read int|null $ai_analyses_count
|
||||
* @property-read \App\Models\ReportAnalysis|null $aiAnalysis
|
||||
* @property-read mixed $receives
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $receivesUser
|
||||
* @property-read int|null $receives_user_count
|
||||
@@ -55,6 +59,15 @@ class Report extends AbstractModel
|
||||
|
||||
const WEEKLY = "weekly";
|
||||
const DAILY = "daily";
|
||||
public const LIST_FIELDS = [
|
||||
'id',
|
||||
'title',
|
||||
'type',
|
||||
'userid',
|
||||
'sign',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
"title",
|
||||
@@ -78,6 +91,16 @@ class Report extends AbstractModel
|
||||
->withPivot("receive_at", "read");
|
||||
}
|
||||
|
||||
public function aiAnalyses(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReportAnalysis::class, 'rid');
|
||||
}
|
||||
|
||||
public function aiAnalysis(): HasOne
|
||||
{
|
||||
return $this->hasOne(ReportAnalysis::class, 'rid');
|
||||
}
|
||||
|
||||
public function sendUser()
|
||||
{
|
||||
return $this->hasOne(User::class, "userid", "userid");
|
||||
|
||||
58
app/Models/ReportAnalysis.php
Normal file
58
app/Models/ReportAnalysis.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ReportAnalysis
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $rid 报告ID
|
||||
* @property int $userid 生成分析的会员ID
|
||||
* @property string $model 使用的模型名称
|
||||
* @property string $analysis_text AI 分析的原始文本(Markdown)
|
||||
* @property array|null $meta 额外的上下文信息
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Report|null $report
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereAnalysisText($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereMeta($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereModel($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereRid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportAnalysis whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ReportAnalysis extends AbstractModel
|
||||
{
|
||||
protected $table = 'report_ai_analyses';
|
||||
|
||||
protected $fillable = [
|
||||
'rid',
|
||||
'userid',
|
||||
'model',
|
||||
'analysis_text',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Report::class, 'rid');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Timer;
|
||||
use App\Module\AI;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
@@ -48,23 +49,35 @@ class Setting extends AbstractModel
|
||||
}
|
||||
$value = Base::json2array($value);
|
||||
switch ($this->name) {
|
||||
// 系统设置
|
||||
case 'system':
|
||||
$value['system_alias'] = $value['system_alias'] ?: env('APP_NAME');
|
||||
$value['image_compress'] = $value['image_compress'] ?: 'open';
|
||||
$value['image_quality'] = min(100, max(0, intval($value['image_quality']) ?: 90));
|
||||
$value['image_save_local'] = $value['image_save_local'] ?: 'open';
|
||||
$value['task_user_limit'] = min(2000, max(1, intval($value['task_user_limit']) ?: 500));
|
||||
if (!is_array($value['task_default_time']) || count($value['task_default_time']) != 2 || !Timer::isTime($value['task_default_time'][0]) || !Timer::isTime($value['task_default_time'][1])) {
|
||||
$value['task_default_time'] = ['09:00', '18:00'];
|
||||
}
|
||||
// 项目创建权限:范围(all/departmentOwner/appoint,默认 all)+ 指定人员
|
||||
$value['project_add_permission'] = array_values(array_intersect(
|
||||
is_array($value['project_add_permission'] ?? null) ? $value['project_add_permission'] : [],
|
||||
['all', 'departmentOwner', 'appoint']
|
||||
)) ?: ['all'];
|
||||
$value['project_add_userids'] = is_array($value['project_add_userids'] ?? null)
|
||||
? array_values(array_unique(array_filter(array_map('intval', $value['project_add_userids']))))
|
||||
: [];
|
||||
break;
|
||||
|
||||
// 文件设置
|
||||
case 'fileSetting':
|
||||
$value['permission_pack_type'] = $value['permission_pack_type'] ?: 'all';
|
||||
$value['permission_pack_userids'] = is_array($value['permission_pack_userids']) ? $value['permission_pack_userids'] : [];
|
||||
break;
|
||||
|
||||
// AI 机器人设置
|
||||
case 'aibotSetting':
|
||||
if ($value['claude_token'] && empty($value['claude_key'])) {
|
||||
if (!empty($value['claude_token']) && empty($value['claude_key'])) {
|
||||
$value['claude_key'] = $value['claude_token'];
|
||||
}
|
||||
$array = [];
|
||||
@@ -73,20 +86,17 @@ class Setting extends AbstractModel
|
||||
foreach ($aiList as $aiName) {
|
||||
foreach ($fieldList as $fieldName) {
|
||||
$key = $aiName . '_' . $fieldName;
|
||||
$content = $value[$key] ? trim($value[$key]) : '';
|
||||
$content = !empty($value[$key]) ? trim($value[$key]) : '';
|
||||
switch ($fieldName) {
|
||||
case 'models':
|
||||
if ($content) {
|
||||
$content = explode("\n", $content);
|
||||
$content = array_filter($content);
|
||||
}
|
||||
if (empty($content)) {
|
||||
$content = self::AIDefaultModels($aiName);
|
||||
}
|
||||
$content = implode("\n", $content);
|
||||
$content = is_array($content) ? implode("\n", $content) : '';
|
||||
break;
|
||||
case 'model':
|
||||
$models = Setting::AIModels2Array($array[$key . 's'], true);
|
||||
$models = Setting::AIBotModels2Array($array[$key . 's'], true);
|
||||
$content = in_array($content, $models) ? $content : ($models[0] ?? '');
|
||||
break;
|
||||
case 'temperature':
|
||||
@@ -105,112 +115,109 @@ class Setting extends AbstractModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启AI
|
||||
* @param $ai
|
||||
* @return bool
|
||||
* 规范任务优先级设置(确保字段完整且仅有一个默认项)
|
||||
* @param mixed $list
|
||||
* @return array<int, array{name:string,color:string,days:int,priority:int,is_default:int}>
|
||||
*/
|
||||
public static function AIOpen($ai = 'openai')
|
||||
public static function normalizeTaskPriorityList($list)
|
||||
{
|
||||
$array = Base::setting('aibotSetting');
|
||||
return !!$array[$ai . '_key'];
|
||||
if (!is_array($list)) {
|
||||
return [];
|
||||
}
|
||||
$normalized = [];
|
||||
$defaultIndex = null;
|
||||
foreach ($list as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($item['name'] ?? ''));
|
||||
$color = trim((string)($item['color'] ?? ''));
|
||||
$priority = intval($item['priority'] ?? 0);
|
||||
if ($name === '' || $color === '' || $priority <= 0) {
|
||||
continue;
|
||||
}
|
||||
$days = intval($item['days'] ?? 0);
|
||||
$isDefault = !empty($item['is_default']) || !empty($item['default']);
|
||||
if ($defaultIndex === null && $isDefault) {
|
||||
$defaultIndex = count($normalized);
|
||||
}
|
||||
$normalized[] = [
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'days' => $days,
|
||||
'priority' => $priority,
|
||||
'is_default' => $isDefault ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if (!empty($normalized)) {
|
||||
$defaultIndex = $defaultIndex ?? 0;
|
||||
foreach ($normalized as $i => $row) {
|
||||
$normalized[$i]['is_default'] = $i === $defaultIndex ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI默认模型
|
||||
* @param string $ai
|
||||
* @return array
|
||||
* 获取默认任务优先级(来自 settings.priority)
|
||||
* @param array|null $list
|
||||
* @return array|null
|
||||
*/
|
||||
public static function AIDefaultModels($ai = 'openai')
|
||||
public static function getDefaultTaskPriorityItem($list = null)
|
||||
{
|
||||
return match ($ai) {
|
||||
'openai' => [
|
||||
'gpt-4.1 | GPT-4.1',
|
||||
'gpt-4o | GPT-4o',
|
||||
'gpt-4 | GPT-4',
|
||||
'gpt-4o-mini | GPT-4o Mini',
|
||||
'gpt-4-turbo | GPT-4 Turbo',
|
||||
'o3 (thinking) | GPT-o3',
|
||||
'o1 | GPT-o1',
|
||||
'o4-mini | GPT-o4 Mini',
|
||||
'o3-mini | GPT-o3 Mini',
|
||||
'o1-mini | GPT-o1 Mini',
|
||||
'gpt-3.5-turbo | GPT-3.5 Turbo',
|
||||
'gpt-3.5-turbo-16k | GPT-3.5 Turbo 16K',
|
||||
'gpt-3.5-turbo-0125 | GPT-3.5 Turbo 0125',
|
||||
'gpt-3.5-turbo-1106 | GPT-3.5 Turbo 1106'
|
||||
],
|
||||
'claude' => [
|
||||
'claude-opus-4-0 (thinking) | Claude Opus 4',
|
||||
'claude-sonnet-4-0 (thinking) | Claude Sonnet 4',
|
||||
'claude-3-7-sonnet-latest (thinking) | Claude Sonnet 3.7',
|
||||
'claude-3-5-sonnet-latest | Claude Sonnet 3.5',
|
||||
'claude-3-5-haiku-latest | Claude Haiku 3.5',
|
||||
'claude-3-opus-latest | Claude Opus 3'
|
||||
],
|
||||
'deepseek' => [
|
||||
'deepseek-chat | DeepSeek V3',
|
||||
'deepseek-reasoner | DeepSeek R1'
|
||||
],
|
||||
'gemini' => [
|
||||
'gemini-2.5-pro-preview-05-06 (thinking) | Gemini 2.5 Pro Preview',
|
||||
'gemini-2.0-flash | Gemini 2.0 Flash',
|
||||
'gemini-2.0-flash-lite | Gemini 2.0 Flash-Lite',
|
||||
'gemini-1.5-flash | Gemini 1.5 Flash',
|
||||
'gemini-1.5-flash-8b | Gemini 1.5 Flash 8B',
|
||||
'gemini-1.5-pro | Gemini 1.5 Pro',
|
||||
'gemini-1.0-pro | Gemini 1.0 Pro'
|
||||
],
|
||||
'grok' => [
|
||||
'grok-3-latest | Grok 3',
|
||||
'grok-3-fast-latest | Grok 3 Fast',
|
||||
'grok-3-mini-latest | Grok 3 Mini',
|
||||
'grok-3-mini-fast-latest | Grok 3 Mini Fast',
|
||||
'grok-2-vision-latest | Grok 2 Vision',
|
||||
'grok-2-latest | Grok 2',
|
||||
],
|
||||
'zhipu' => [
|
||||
'glm-4 | GLM-4',
|
||||
'glm-4-plus | GLM-4 Plus',
|
||||
'glm-4-air | GLM-4 Air',
|
||||
'glm-4-airx | GLM-4 AirX',
|
||||
'glm-4-long | GLM-4 Long',
|
||||
'glm-4-flash | GLM-4 Flash',
|
||||
'glm-4v | GLM-4V',
|
||||
'glm-4v-plus | GLM-4V Plus',
|
||||
'glm-3-turbo | GLM-3 Turbo'
|
||||
],
|
||||
'qianwen' => [
|
||||
'qwen-max | QWEN Max',
|
||||
'qwen-max-latest | QWEN Max Latest',
|
||||
'qwen-turbo | QWEN Turbo',
|
||||
'qwen-turbo-latest | QWEN Turbo Latest',
|
||||
'qwen-plus | QWEN Plus',
|
||||
'qwen-plus-latest | QWEN Plus Latest',
|
||||
'qwen-long | QWEN Long'
|
||||
],
|
||||
'wenxin' => [
|
||||
'ernie-4.0-8k | Ernie 4.0 8K',
|
||||
'ernie-4.0-8k-latest | Ernie 4.0 8K Latest',
|
||||
'ernie-4.0-turbo-128k | Ernie 4.0 Turbo 128K',
|
||||
'ernie-4.0-turbo-8k | Ernie 4.0 Turbo 8K',
|
||||
'ernie-3.5-128k | Ernie 3.5 128K',
|
||||
'ernie-3.5-8k | Ernie 3.5 8K',
|
||||
'ernie-speed-128k | Ernie Speed 128K',
|
||||
'ernie-speed-8k | Ernie Speed 8K',
|
||||
'ernie-lite-8k | Ernie Lite 8K',
|
||||
'ernie-tiny-8k | Ernie Tiny 8K'
|
||||
],
|
||||
default => [],
|
||||
$list = $list ?? Base::setting('priority');
|
||||
$list = self::normalizeTaskPriorityList($list);
|
||||
if (empty($list)) {
|
||||
return null;
|
||||
}
|
||||
foreach ($list as $item) {
|
||||
if (!empty($item['is_default'])) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
return $list[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启 AI 助手
|
||||
* @return bool
|
||||
*/
|
||||
public static function AIOpen()
|
||||
{
|
||||
$setting = Base::setting('aibotSetting');
|
||||
if (!is_array($setting) || empty($setting)) {
|
||||
return false;
|
||||
}
|
||||
foreach (AI::TEXT_MODEL_PRIORITY as $vendor) {
|
||||
if (self::isAIBotVendorEnabled($setting, $vendor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 AI 机器人厂商是否启用
|
||||
* @param array $setting
|
||||
* @param string $vendor
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isAIBotVendorEnabled(array $setting, string $vendor): bool
|
||||
{
|
||||
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
|
||||
return match ($vendor) {
|
||||
'ollama' => $key !== '' || !empty($setting['ollama_base_url']),
|
||||
default => $key !== '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AI模型转数组
|
||||
* AI 机器人模型转数组
|
||||
* @param $models
|
||||
* @param bool $retValue
|
||||
* @return array
|
||||
*/
|
||||
public static function AIModels2Array($models, $retValue = false)
|
||||
public static function AIBotModels2Array($models, $retValue = false)
|
||||
{
|
||||
$list = is_array($models) ? $models : explode("\n", $models);
|
||||
$array = [];
|
||||
@@ -229,6 +236,213 @@ class Setting extends AbstractModel
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用配置
|
||||
* @param array $list
|
||||
* @return array
|
||||
*/
|
||||
public static function normalizeCustomMicroApps($list)
|
||||
{
|
||||
if (!is_array($list)) {
|
||||
return [];
|
||||
}
|
||||
$apps = [];
|
||||
foreach ($list as $item) {
|
||||
$app = self::normalizeCustomMicroAppItem($item);
|
||||
if ($app) {
|
||||
$apps[] = $app;
|
||||
}
|
||||
}
|
||||
return $apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户身份过滤可见的自定义微应用
|
||||
* @param array $apps
|
||||
* @param \App\Models\User|null $user
|
||||
* @return array
|
||||
*/
|
||||
public static function filterCustomMicroAppsForUser(array $apps, $user)
|
||||
{
|
||||
if (empty($apps)) {
|
||||
return [];
|
||||
}
|
||||
$isAdmin = $user ? $user->isAdmin() : false;
|
||||
$userId = $user ? intval($user->userid) : 0;
|
||||
$filtered = [];
|
||||
foreach ($apps as $app) {
|
||||
$visible = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']);
|
||||
if (!self::isCustomMicroVisibleTo($visible, $isAdmin, $userId)) {
|
||||
continue;
|
||||
}
|
||||
if (empty($app['menu_items']) || !is_array($app['menu_items'])) {
|
||||
continue;
|
||||
}
|
||||
$menus = array_values(array_filter($app['menu_items'], function ($menu) use ($isAdmin, $userId) {
|
||||
if (!isset($menu['visible_to'])) {
|
||||
return true;
|
||||
}
|
||||
$visible = self::normalizeCustomMicroVisible($menu['visible_to']);
|
||||
return self::isCustomMicroVisibleTo($visible, $isAdmin, $userId);
|
||||
}));
|
||||
if (empty($menus)) {
|
||||
continue;
|
||||
}
|
||||
$app['menu_items'] = $menus;
|
||||
$filtered[] = $app;
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将存储结构转换成 appstore 接口同款格式
|
||||
* @param array $apps
|
||||
* @return array
|
||||
*/
|
||||
public static function formatCustomMicroAppsForResponse(array $apps)
|
||||
{
|
||||
return array_values(array_map(function ($app) {
|
||||
unset($app['visible_to']);
|
||||
if (!empty($app['menu_items']) && is_array($app['menu_items'])) {
|
||||
$app['menu_items'] = array_values(array_map(function ($menu) {
|
||||
$menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true;
|
||||
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
|
||||
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
|
||||
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
|
||||
if (isset($menu['visible_to'])) {
|
||||
unset($menu['visible_to']);
|
||||
}
|
||||
return $menu;
|
||||
}, $app['menu_items']));
|
||||
}
|
||||
return $app;
|
||||
}, $apps));
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用
|
||||
* @param array $item
|
||||
* @return array|null
|
||||
*/
|
||||
protected static function normalizeCustomMicroAppItem($item)
|
||||
{
|
||||
if (!is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
$id = trim($item['id'] ?? '');
|
||||
if ($id === '') {
|
||||
return null;
|
||||
}
|
||||
$name = Base::newTrim($item['name'] ?? '');
|
||||
$version = Base::newTrim($item['version'] ?? '') ?: 'custom';
|
||||
$menuItems = [];
|
||||
if (isset($item['menu_items']) && is_array($item['menu_items'])) {
|
||||
$menuItems = $item['menu_items'];
|
||||
} elseif (isset($item['menu']) && is_array($item['menu'])) {
|
||||
$menuItems = [$item['menu']];
|
||||
}
|
||||
if (empty($menuItems)) {
|
||||
return null;
|
||||
}
|
||||
$normalizedMenus = [];
|
||||
foreach ($menuItems as $menu) {
|
||||
$formattedMenu = self::normalizeCustomMicroMenuItem($menu, $name ?: $id);
|
||||
if ($formattedMenu) {
|
||||
$normalizedMenus[] = $formattedMenu;
|
||||
}
|
||||
}
|
||||
if (empty($normalizedMenus)) {
|
||||
return null;
|
||||
}
|
||||
return Base::newTrim([
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'version' => $version,
|
||||
'menu_items' => $normalizedMenus,
|
||||
'visible_to' => self::normalizeCustomMicroVisible($item['visible_to'] ?? 'admin'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用菜单项
|
||||
* @param array $menu
|
||||
* @param string $fallbackLabel
|
||||
* @return array|null
|
||||
*/
|
||||
protected static function normalizeCustomMicroMenuItem($menu, $fallbackLabel = '')
|
||||
{
|
||||
if (!is_array($menu)) {
|
||||
return null;
|
||||
}
|
||||
$url = trim($menu['url'] ?? '');
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
$location = trim($menu['location'] ?? 'application');
|
||||
$label = trim($menu['label'] ?? $fallbackLabel);
|
||||
$type = strtolower(trim($menu['type'] ?? 'iframe'));
|
||||
$payload = [
|
||||
'location' => $location,
|
||||
'label' => $label,
|
||||
'icon' => Base::newTrim($menu['icon'] ?? ''),
|
||||
'url' => $url,
|
||||
'type' => $type,
|
||||
'keep_alive' => isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true,
|
||||
'disable_scope_css' => (bool)($menu['disable_scope_css'] ?? false),
|
||||
'auto_dark_theme' => isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true,
|
||||
'transparent' => (bool)($menu['transparent'] ?? false),
|
||||
];
|
||||
if (!empty($menu['background'])) {
|
||||
$payload['background'] = Base::newTrim($menu['background']);
|
||||
}
|
||||
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
|
||||
$payload['capsule'] = Base::newTrim($menu['capsule']);
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范自定义微应用可见范围
|
||||
* @param mixed $value
|
||||
* @return array
|
||||
*/
|
||||
protected static function normalizeCustomMicroVisible($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$list = array_filter(array_map('trim', $value));
|
||||
} else {
|
||||
$list = array_filter(array_map('trim', explode(',', (string)$value)));
|
||||
}
|
||||
if (empty($list)) {
|
||||
return ['admin'];
|
||||
}
|
||||
if (in_array('all', $list)) {
|
||||
return ['all'];
|
||||
}
|
||||
return array_values($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断自定义微应用是否可见
|
||||
* @param array $visible
|
||||
* @param bool $isAdmin
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
||||
{
|
||||
if (in_array('all', $visible)) {
|
||||
return true;
|
||||
}
|
||||
if ($isAdmin && in_array('admin', $visible)) {
|
||||
return true;
|
||||
}
|
||||
if ($userId > 0 && in_array((string)$userId, $visible, true)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱地址(过滤忽略地址)
|
||||
* @param $array
|
||||
@@ -294,7 +508,7 @@ class Setting extends AbstractModel
|
||||
}
|
||||
$limitTime = Carbon::parse($dialogMsg->created_at)->addMinutes($limitNum);
|
||||
if ($limitTime->lt(Carbon::now())) {
|
||||
throw new ApiException('已超过' . Doo::translate(Base::forumMinuteDay($limitNum)) . ',' . $error);
|
||||
throw new ApiException('已超过' . Base::forumMinuteDay($limitNum) . ',' . $error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ class UmengAlias extends AbstractModel
|
||||
return;
|
||||
}
|
||||
|
||||
$instance = null;
|
||||
$responsePayload = null;
|
||||
|
||||
try {
|
||||
switch ($first['platform']) {
|
||||
case 'ios':
|
||||
@@ -81,8 +84,11 @@ class UmengAlias extends AbstractModel
|
||||
default:
|
||||
return;
|
||||
}
|
||||
$instance->send($first['data']);
|
||||
$responsePayload = $instance->send($first['data']);
|
||||
} catch (\Exception $e) {
|
||||
$responsePayload = [
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
$first['retry'] = intval($first['retry'] ?? 0) + 1;
|
||||
if ($first['retry'] > 3) {
|
||||
info("[PushMsg] fail: " . $e->getMessage());
|
||||
@@ -91,6 +97,12 @@ class UmengAlias extends AbstractModel
|
||||
self::$waitSend[] = $first;
|
||||
}
|
||||
} finally {
|
||||
if ($instance !== null) {
|
||||
UmengLog::create([
|
||||
'request' => Base::array2json($first['data']),
|
||||
'response' => Base::array2json($responsePayload),
|
||||
]);
|
||||
}
|
||||
self::sendTask();
|
||||
}
|
||||
}
|
||||
@@ -153,7 +165,7 @@ class UmengAlias extends AbstractModel
|
||||
$description = $array['description'] ?: 'no description'; // 描述
|
||||
$extra = is_array($array['extra']) ? $array['extra'] : []; // 额外参数
|
||||
$seconds = intval($array['seconds']) ?: 86400; // 有效时间(单位:秒)
|
||||
$badge = intval($array['badge']) ?: 0; // 角标数(iOS)
|
||||
$badge = intval($array['badge']) ?: 0; // 角标数
|
||||
//
|
||||
switch ($platform) {
|
||||
case 'ios':
|
||||
@@ -203,6 +215,7 @@ class UmengAlias extends AbstractModel
|
||||
'title' => $title,
|
||||
'after_open' => 'go_app',
|
||||
'play_sound' => true,
|
||||
'set_badge' => min(99, $badge),
|
||||
],
|
||||
], $extra),
|
||||
'type' => 'customizedcast',
|
||||
@@ -213,13 +226,19 @@ class UmengAlias extends AbstractModel
|
||||
'policy' => [
|
||||
'expire_time' => Carbon::now()->addSeconds($seconds)->toDateTimeString(),
|
||||
],
|
||||
'category' => 1,
|
||||
'channel_properties' => [
|
||||
'main_activity' => 'com.dootask.task.WelcomeActivity',
|
||||
'oppo_channel_id' => 'dootask',
|
||||
'vivo_category' => 'IM',
|
||||
'huawei_channel_importance' => 'NORMAL',
|
||||
'huawei_channel_category' => 'IM',
|
||||
'channel_fcm' => 0,
|
||||
],
|
||||
'local_properties' => [
|
||||
'importance' => 'IMPORTANCE_DEFAULT',
|
||||
'category' => 'CATEGORY_MESSAGE',
|
||||
]
|
||||
]
|
||||
]);
|
||||
break;
|
||||
|
||||
32
app/Models/UmengLog.php
Normal file
32
app/Models/UmengLog.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* App\Models\UmengLog
|
||||
*
|
||||
* @property int $id
|
||||
* @property string|null $request 请求参数
|
||||
* @property string|null $response 推送返回
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereRequest($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereResponse($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UmengLog whereUpdatedAt($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UmengLog extends AbstractModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -2,29 +2,33 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Table\OnlineData;
|
||||
use App\Observers\AbstractObserver;
|
||||
use App\Services\RequestContext;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* App\Models\User
|
||||
*
|
||||
* @property int $userid
|
||||
* @property array $identity
|
||||
* @property array $department
|
||||
* @property array $identity 身份
|
||||
* @property array $department 所属部门
|
||||
* @property string|null $az A-Z
|
||||
* @property string|null $pinyin 拼音(主要用于搜索)
|
||||
* @property string|null $email
|
||||
* @property string|null $email 邮箱
|
||||
* @property string|null $tel 联系电话
|
||||
* @property string $nickname
|
||||
* @property string|null $profession
|
||||
* @property string $userimg
|
||||
* @property string $nickname 昵称
|
||||
* @property string|null $profession 职位/职称
|
||||
* @property string|null $birthday 生日
|
||||
* @property string|null $address 地址
|
||||
* @property string|null $introduction 个人简介
|
||||
* @property string $userimg 头像
|
||||
* @property string|null $encrypt
|
||||
* @property string|null $password 登录密码
|
||||
* @property int|null $changepass 登录需要修改密码
|
||||
@@ -35,7 +39,7 @@ use Request;
|
||||
* @property \Illuminate\Support\Carbon|null $line_at 最后在线时间(接口)
|
||||
* @property int|null $task_dialog_id 最后打开的任务会话ID
|
||||
* @property string|null $created_ip 注册IP
|
||||
* @property \Illuminate\Support\Carbon|null $disable_at
|
||||
* @property \Illuminate\Support\Carbon|null $disable_at 禁用时间(离职时间)
|
||||
* @property int|null $email_verity 邮箱是否已验证
|
||||
* @property int|null $bot 是否机器人
|
||||
* @property string|null $lang 语言首选项
|
||||
@@ -51,7 +55,10 @@ use Request;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereAddress($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereAz($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereBirthday($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereBot($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereChangepass($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
|
||||
@@ -62,6 +69,7 @@ use Request;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerity($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereEncrypt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereIdentity($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereIntroduction($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLang($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereLastIp($value)
|
||||
@@ -81,6 +89,8 @@ use Request;
|
||||
*/
|
||||
class User extends AbstractModel
|
||||
{
|
||||
const IMPORT_MAX = 500;
|
||||
|
||||
protected $primaryKey = 'userid';
|
||||
|
||||
protected $hidden = [
|
||||
@@ -174,10 +184,9 @@ class User extends AbstractModel
|
||||
return UserDepartment::where('owner_userid', $this->userid)->exists();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取机器人所有者
|
||||
* @return int|mixed
|
||||
* @return int
|
||||
*/
|
||||
public function getBotOwner()
|
||||
{
|
||||
@@ -313,7 +322,7 @@ class User extends AbstractModel
|
||||
*/
|
||||
public function deleteUser($reason)
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($reason) {
|
||||
$ret = AbstractModel::transaction(function () use ($reason) {
|
||||
// 删除原因
|
||||
$userDelete = UserDelete::createInstance([
|
||||
'operator' => User::userid(),
|
||||
@@ -334,6 +343,7 @@ class User extends AbstractModel
|
||||
//
|
||||
return $this->delete();
|
||||
});
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,7 +417,295 @@ class User extends AbstractModel
|
||||
$dialog?->joinGroup($user->userid, 0);
|
||||
}
|
||||
}
|
||||
return $user->find($user->userid);
|
||||
$createdUser = $user->find($user->userid);
|
||||
if (!$createdUser->bot) {
|
||||
// Manticore 索引同步
|
||||
AbstractObserver::taskDeliver(new ManticoreSyncTask('user_sync', $createdUser->toArray()));
|
||||
// 触发 user_onboard hook
|
||||
Apps::dispatchUserHook($createdUser, 'user_onboard', 'onboard');
|
||||
}
|
||||
return $createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
|
||||
* @param string $email
|
||||
* @param string $password
|
||||
* @param string $nickname
|
||||
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / department(int[]) / profession(string)
|
||||
* @return self
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
|
||||
{
|
||||
$nickname = trim($nickname);
|
||||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||
throw new ApiException('昵称需为2-20个字');
|
||||
}
|
||||
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
|
||||
$emailVerity = ($options['emailVerity'] ?? false) ? 1 : 0;
|
||||
$profession = trim((string)($options['profession'] ?? ''));
|
||||
// 校验前置(reg 之前快速失败,且可在无 Swoole 环境单测)
|
||||
self::assertValidProfession($profession);
|
||||
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
|
||||
// 复用 reg:邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
|
||||
$user = self::reg($email, $password, ['nickname' => $nickname]);
|
||||
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
|
||||
if (in_array('temp', $user->identity)) {
|
||||
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
|
||||
}
|
||||
$user->changepass = $changePass; // 复用现有首登强制改密机制
|
||||
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
|
||||
if ($profession !== '') {
|
||||
$user->profession = $profession;
|
||||
}
|
||||
if ($departmentIds) {
|
||||
$user->department = Base::arrayImplode($departmentIds);
|
||||
}
|
||||
$user->save();
|
||||
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
|
||||
if ($departmentIds) {
|
||||
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
|
||||
foreach ($departments as $department) {
|
||||
try {
|
||||
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
|
||||
$dialog->joinGroup([$user->userid], 0, true);
|
||||
$dialog->pushMsg("groupJoin", null, [$user->userid]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
|
||||
\Log::warning('createByAdmin: 部门入群失败', [
|
||||
'userid' => $user->userid,
|
||||
'department_id' => $department->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将上传表格(Excel::toArray 的二维数组)归一化为导入行
|
||||
* @param array $sheet
|
||||
* @return array [{line, email, nickname, password}]
|
||||
*/
|
||||
public static function parseImportRows(array $sheet): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($sheet as $index => $cells) {
|
||||
if ($index === 0) {
|
||||
continue; // 表头
|
||||
}
|
||||
$email = trim((string)($cells[0] ?? ''));
|
||||
$nickname = trim((string)($cells[1] ?? ''));
|
||||
$password = trim((string)($cells[2] ?? ''));
|
||||
$profession = trim((string)($cells[3] ?? ''));
|
||||
if ($email === '' && $nickname === '' && $password === '') {
|
||||
continue; // 空行(仅职位有值也视为空行跳过)
|
||||
}
|
||||
$rows[] = [
|
||||
'line' => $index + 1, // 电子表格行号(从 1 开始)
|
||||
'email' => $email,
|
||||
'nickname' => $nickname,
|
||||
'password' => $password,
|
||||
'profession' => $profession,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验单条导入行
|
||||
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
|
||||
* @return string|null 错误文案;null 表示通过
|
||||
*/
|
||||
public static function validateImportRow(array $row): ?string
|
||||
{
|
||||
$email = trim((string)($row['email'] ?? ''));
|
||||
$nickname = trim((string)($row['nickname'] ?? ''));
|
||||
$password = trim((string)($row['password'] ?? ''));
|
||||
if ($email === '' || $nickname === '' || $password === '') {
|
||||
return '邮箱、昵称、初始密码均为必填';
|
||||
}
|
||||
if (!Base::isEmail($email)) {
|
||||
return '邮箱格式不正确';
|
||||
}
|
||||
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
|
||||
return '昵称需为2-20个字';
|
||||
}
|
||||
try {
|
||||
self::passwordPolicy($password);
|
||||
} catch (ApiException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
// 职位/职称选填,填写则校验 2-20 字
|
||||
try {
|
||||
self::assertValidProfession((string)($row['profession'] ?? ''));
|
||||
} catch (ApiException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
|
||||
* @param string $profession
|
||||
* @return void
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function assertValidProfession(string $profession): void
|
||||
{
|
||||
$profession = trim($profession);
|
||||
if ($profession === '') {
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($profession) < 2) {
|
||||
throw new ApiException('职位/职称不可以少于2个字');
|
||||
}
|
||||
if (mb_strlen($profession) > 20) {
|
||||
throw new ApiException('职位/职称最多只能设置20个字');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
|
||||
* @param mixed $ids
|
||||
* @return int[]
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function assertValidDepartments($ids): array
|
||||
{
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||
if (count($ids) > 10) {
|
||||
throw new ApiException('最多只可加入10个部门');
|
||||
}
|
||||
if ($ids) {
|
||||
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
|
||||
if (count($existing) < count($ids)) {
|
||||
throw new ApiException('修改部门不存在');
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入用户(部门/职位逐行:department 来自前端逐行设置,profession 来自 Excel 行)
|
||||
* @param array $rows 每行含 email/nickname/password/profession,可选 department(int[])
|
||||
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
|
||||
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
|
||||
* @throws ApiException 行数超限
|
||||
*/
|
||||
public static function importUsers(array $rows, bool $changePass = true): array
|
||||
{
|
||||
if (count($rows) > self::IMPORT_MAX) {
|
||||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||
}
|
||||
$success = 0;
|
||||
$failed = [];
|
||||
$seen = [];
|
||||
foreach ($rows as $row) {
|
||||
$error = self::validateImportRow($row);
|
||||
if ($error === null) {
|
||||
$emailLower = strtolower(trim((string)$row['email']));
|
||||
if (isset($seen[$emailLower])) {
|
||||
$error = '文件内邮箱重复';
|
||||
} else {
|
||||
$seen[$emailLower] = true;
|
||||
}
|
||||
}
|
||||
if ($error === null) {
|
||||
try {
|
||||
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
|
||||
'changePass' => $changePass,
|
||||
'emailVerity' => !empty($row['email_verity']),
|
||||
'department' => $row['department'] ?? [],
|
||||
'profession' => $row['profession'] ?? '',
|
||||
]);
|
||||
$success++;
|
||||
continue;
|
||||
} catch (ApiException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$failed[] = [
|
||||
'line' => $row['line'] ?? 0,
|
||||
'email' => $row['email'] ?? '',
|
||||
'reason' => $error,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入预览(只解析+校验,不创建任何账号)
|
||||
* 逐行判定 ok/error:必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
|
||||
* @param array $rows parseImportRows 的输出
|
||||
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
|
||||
*/
|
||||
public static function importPreview(array $rows): array
|
||||
{
|
||||
if (count($rows) > self::IMPORT_MAX) {
|
||||
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
|
||||
}
|
||||
// 预查系统中已存在的邮箱(小写比较)
|
||||
$emails = [];
|
||||
foreach ($rows as $row) {
|
||||
$e = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($e !== '') {
|
||||
$emails[$e] = true;
|
||||
}
|
||||
}
|
||||
$existing = [];
|
||||
if ($emails) {
|
||||
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
|
||||
$existing[strtolower($em)] = true;
|
||||
}
|
||||
}
|
||||
$seen = [];
|
||||
$valid = 0;
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$reason = self::validateImportRow($row);
|
||||
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($reason === null) {
|
||||
if (isset($seen[$emailLower])) {
|
||||
$reason = '文件内邮箱重复';
|
||||
} else {
|
||||
$seen[$emailLower] = true;
|
||||
if (isset($existing[$emailLower])) {
|
||||
$reason = '邮箱地址已存在';
|
||||
}
|
||||
}
|
||||
}
|
||||
$ok = $reason === null;
|
||||
if ($ok) {
|
||||
$valid++;
|
||||
}
|
||||
$list[] = [
|
||||
'line' => $row['line'] ?? 0,
|
||||
'email' => $row['email'] ?? '',
|
||||
'nickname' => $row['nickname'] ?? '',
|
||||
'password' => $row['password'] ?? '',
|
||||
'profession' => $row['profession'] ?? '',
|
||||
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
|
||||
'status' => $ok ? 'ok' : 'error',
|
||||
'reason' => $reason ?? '',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'valid' => $valid,
|
||||
'invalid' => count($rows) - $valid,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,19 +734,6 @@ class User extends AbstractModel
|
||||
return $user->nickname;
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时日志记录
|
||||
* @param $message
|
||||
* @return void
|
||||
*/
|
||||
private static function tmpLog($message)
|
||||
{
|
||||
if (Request::input('log') !== 'yes') {
|
||||
return;
|
||||
}
|
||||
info("[User] [" . date("Y-m-d H:i:s") . "] " . $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户身份认证(获取用户信息)
|
||||
* @param null $identity 判断身份
|
||||
@@ -458,20 +743,11 @@ class User extends AbstractModel
|
||||
{
|
||||
$user = self::authInfo();
|
||||
if (!$user) {
|
||||
self::tmpLog('auth failed');
|
||||
$token = Base::token();
|
||||
if ($token) {
|
||||
UserDevice::forget($token);
|
||||
self::tmpLog('auth token found: ' . Base::array2json([
|
||||
'header' => Request::header(),
|
||||
'input' => Request::input(),
|
||||
]));
|
||||
throw new ApiException('身份已失效,请重新登录', [], -1);
|
||||
} else {
|
||||
self::tmpLog('auth no token found: ' . Base::array2json([
|
||||
'header' => Request::header(),
|
||||
'input' => Request::input(),
|
||||
]));
|
||||
throw new ApiException('请登录后继续...', [], -1);
|
||||
}
|
||||
}
|
||||
@@ -490,31 +766,25 @@ class User extends AbstractModel
|
||||
*/
|
||||
private static function authInfo()
|
||||
{
|
||||
self::tmpLog('auth start');
|
||||
if (RequestContext::has('auth')) {
|
||||
// 缓存
|
||||
self::tmpLog('auth from cache');
|
||||
return RequestContext::get('auth');
|
||||
}
|
||||
if (Doo::userId() <= 0) {
|
||||
// 没有登录
|
||||
self::tmpLog('auth no login');
|
||||
return RequestContext::save('auth', false);
|
||||
}
|
||||
if (Doo::userExpired()) {
|
||||
// 登录过期
|
||||
self::tmpLog('auth expired');
|
||||
return RequestContext::save('auth', false);
|
||||
}
|
||||
if (!UserDevice::check()) {
|
||||
// token 不存在
|
||||
self::tmpLog('auth token not found');
|
||||
return RequestContext::save('auth', false);
|
||||
}
|
||||
$user = self::whereUserid(Doo::userId())->whereEmail(Doo::userEmail())->whereEncrypt(Doo::userEncrypt())->first();
|
||||
if (!$user) {
|
||||
// 登录信息不匹配
|
||||
self::tmpLog('auth user not found');
|
||||
return RequestContext::save('auth', false);
|
||||
}
|
||||
|
||||
@@ -536,7 +806,6 @@ class User extends AbstractModel
|
||||
$user->updateInstance($upArray);
|
||||
$user->save();
|
||||
}
|
||||
self::tmpLog('auth success: ' . $user->userid . ' - ' . $user->email);
|
||||
return RequestContext::save('auth', $user);
|
||||
}
|
||||
|
||||
@@ -567,6 +836,22 @@ class User extends AbstractModel
|
||||
return $userinfo->token = $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成无设备的 token(主要用于接口调用,此 token 不检查设备是否存在)
|
||||
* @param self $userinfo
|
||||
* @param $ttl
|
||||
* @return mixed
|
||||
*/
|
||||
public static function generateTokenNoDevice($userinfo, $ttl)
|
||||
{
|
||||
$key = 'user_token_no_device_' . $userinfo->userid;
|
||||
return Cache::remember($key, $ttl, function () use ($userinfo, $ttl) {
|
||||
$token = Doo::tokenEncode($userinfo->userid, $userinfo->email, $userinfo->encrypt);
|
||||
Cache::put(UserDevice::ck(md5($token)), $userinfo->userid, $ttl);
|
||||
return $token;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* userid 获取 基础信息
|
||||
* @param int $userid 会员ID
|
||||
@@ -766,39 +1051,49 @@ class User extends AbstractModel
|
||||
/**
|
||||
* 是否机器人
|
||||
* @param $userid
|
||||
* @return bool|mixed
|
||||
* @return bool
|
||||
*/
|
||||
public static function isBot($userid)
|
||||
{
|
||||
if (empty($userid)) {
|
||||
return false;
|
||||
}
|
||||
$userid = intval($userid);
|
||||
if (RequestContext::has("isBot_" . $userid)) {
|
||||
return RequestContext::get("isBot_" . $userid);
|
||||
}
|
||||
return (bool)User::find($userid)?->bot;
|
||||
// 这个不会有变化,所以可以使用永久缓存
|
||||
return (bool)Cache::rememberForever('is-bot-user-' . $userid, function () use ($userid) {
|
||||
return (bool)User::find($userid)?->bot;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
* @param $key
|
||||
* @param $take
|
||||
* @return User[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection
|
||||
* 按关键词搜索用户(Scope)
|
||||
* 支持:邮箱(含@)、用户ID(纯数字)、昵称/拼音/职业
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public static function searchUser($key, $take = 20)
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
return User::select(User::$basicField)
|
||||
->where(function ($query) use ($key) {
|
||||
if (str_contains($key, "@")) {
|
||||
$query->where("email", "like", "%{$key}%");
|
||||
} else {
|
||||
$query->where("nickname", "like", "%{$key}%")
|
||||
->orWhere("pinyin", "like", "%{$key}%")
|
||||
->orWhere("profession", "like", "%{$key}%");
|
||||
}
|
||||
})->orderBy('userid')
|
||||
->take($take)
|
||||
->get();
|
||||
if (str_contains($keyword, "@")) {
|
||||
// 包含 @ 按邮箱搜索
|
||||
return $query->where("email", "like", "%{$keyword}%");
|
||||
}
|
||||
|
||||
if (is_numeric($keyword)) {
|
||||
// 纯数字:匹配用户ID 或 昵称/拼音/职业
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("userid", intval($keyword))
|
||||
->orWhere("nickname", "like", "%{$keyword}%")
|
||||
->orWhere("pinyin", "like", "%{$keyword}%")
|
||||
->orWhere("profession", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 普通文本:搜索昵称/拼音/职业
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where("nickname", "like", "%{$keyword}%")
|
||||
->orWhere("pinyin", "like", "%{$keyword}%")
|
||||
->orWhere("profession", "like", "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
102
app/Models/UserAppSort.php
Normal file
102
app/Models/UserAppSort.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* App\Models\UserAppSort
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $userid 用户ID
|
||||
* @property array|null $sorts 排序配置
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereSorts($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAppSort whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserAppSort extends AbstractModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'sorts',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sorts' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取用户排序配置
|
||||
* @param int $userid
|
||||
* @return array
|
||||
*/
|
||||
public static function getSorts(int $userid): array
|
||||
{
|
||||
$record = static::whereUserid($userid)->first();
|
||||
if (!$record) {
|
||||
return self::normalizeSorts([]);
|
||||
}
|
||||
return self::normalizeSorts($record->sorts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存排序配置
|
||||
* @param int $userid
|
||||
* @param array $sorts
|
||||
* @return static
|
||||
*/
|
||||
public static function saveSorts(int $userid, array $sorts): self
|
||||
{
|
||||
return static::updateOrCreate(
|
||||
['userid' => $userid],
|
||||
['sorts' => self::normalizeSorts($sorts)]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化排序数据
|
||||
* @param mixed $sorts
|
||||
* @return array
|
||||
*/
|
||||
public static function normalizeSorts($sorts): array
|
||||
{
|
||||
$result = [
|
||||
'base' => [],
|
||||
'admin' => [],
|
||||
];
|
||||
if (!is_array($sorts)) {
|
||||
return $result;
|
||||
}
|
||||
foreach (['base', 'admin'] as $group) {
|
||||
$list = $sorts[$group] ?? [];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
$normalized = [];
|
||||
foreach ($list as $value) {
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = $value;
|
||||
}
|
||||
$result[$group] = array_values(array_unique($normalized));
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ namespace App\Models;
|
||||
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Extranet;
|
||||
use App\Module\Ihttp;
|
||||
use App\Module\Timer;
|
||||
use App\Tasks\JokeSoupTask;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* App\Models\UserBot
|
||||
@@ -20,6 +21,7 @@ use Carbon\Carbon;
|
||||
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
|
||||
* @property string|null $webhook_url 消息webhook地址
|
||||
* @property int|null $webhook_num 消息webhook请求次数
|
||||
* @property array $webhook_events Webhook事件配置
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
@@ -38,12 +40,93 @@ use Carbon\Carbon;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUserid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookEvents($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookNum($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookUrl($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserBot extends AbstractModel
|
||||
{
|
||||
public const WEBHOOK_EVENT_MESSAGE = 'message';
|
||||
public const WEBHOOK_EVENT_DIALOG_OPEN = 'dialog_open';
|
||||
public const WEBHOOK_EVENT_MEMBER_JOIN = 'member_join';
|
||||
public const WEBHOOK_EVENT_MEMBER_LEAVE = 'member_leave';
|
||||
|
||||
protected $casts = [
|
||||
'webhook_events' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取 webhook 事件配置
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return array
|
||||
*/
|
||||
public function getWebhookEventsAttribute(mixed $value): array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return self::normalizeWebhookEvents(null, true);
|
||||
}
|
||||
return self::normalizeWebhookEvents($value, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 webhook 事件配置
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function setWebhookEventsAttribute(mixed $value): void
|
||||
{
|
||||
$useFallback = $value === null;
|
||||
$this->attributes['webhook_events'] = Base::array2json(self::normalizeWebhookEvents($value, $useFallback));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要触发指定 webhook 事件
|
||||
*
|
||||
* @param string $event
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldDispatchWebhook(string $event): bool
|
||||
{
|
||||
if (!$this->webhook_url) {
|
||||
return false;
|
||||
}
|
||||
if (!preg_match('/^https?:\/\//', $this->webhook_url)) {
|
||||
return false;
|
||||
}
|
||||
return in_array($event, $this->webhook_events ?? [], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 webhook
|
||||
*
|
||||
* @param string $event
|
||||
* @param array $data
|
||||
* @param int $timeout
|
||||
* @return array|null
|
||||
*/
|
||||
public function dispatchWebhook(string $event, array $data, int $timeout = 30): ?array
|
||||
{
|
||||
if (!$this->shouldDispatchWebhook($event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data['event'] = $event;
|
||||
$result = Ihttp::ihttp_post($this->webhook_url, $data, $timeout);
|
||||
$this->increment('webhook_num');
|
||||
return $result;
|
||||
} catch (Throwable $th) {
|
||||
info(Base::array2json([
|
||||
'webhook_url' => $this->webhook_url,
|
||||
'data' => $data,
|
||||
'error' => $th->getMessage(),
|
||||
]));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否系统机器人
|
||||
@@ -68,6 +151,7 @@ class UserBot extends AbstractModel
|
||||
$name = match ($name) {
|
||||
'system-msg' => '系统消息',
|
||||
'task-alert' => '任务提醒',
|
||||
'todo-alert' => '待办提醒',
|
||||
'check-in' => '签到打卡',
|
||||
'anon-msg' => '匿名消息',
|
||||
'approval-alert' => '审批',
|
||||
@@ -97,39 +181,33 @@ class UserBot extends AbstractModel
|
||||
{
|
||||
switch ($email) {
|
||||
case 'check-in@bot.system':
|
||||
$menu = [
|
||||
/*[
|
||||
'key' => 'it',
|
||||
'label' => Doo::translate('IT资讯')
|
||||
], [
|
||||
'key' => '36ke',
|
||||
'label' => Doo::translate('36氪')
|
||||
], [
|
||||
'key' => '60s',
|
||||
'label' => Doo::translate('60s读世界')
|
||||
], [
|
||||
'key' => 'joke',
|
||||
'label' => Doo::translate('开心笑话')
|
||||
], [
|
||||
'key' => 'soup',
|
||||
'label' => Doo::translate('心灵鸡汤')
|
||||
]*/
|
||||
];
|
||||
$menu = [];
|
||||
$setting = Base::setting('checkinSetting');
|
||||
if ($setting['open'] !== 'open') {
|
||||
return $menu;
|
||||
}
|
||||
if (in_array('locat', $setting['modes']) && Base::isEEUIApp()) {
|
||||
$menu[] = [
|
||||
'key' => 'locat-checkin',
|
||||
'label' => Doo::translate('定位签到'),
|
||||
'config' => [
|
||||
'key' => $setting['locat_bd_lbs_key'],
|
||||
'lng' => $setting['locat_bd_lbs_point']['lng'],
|
||||
'lat' => $setting['locat_bd_lbs_point']['lat'],
|
||||
'radius' => $setting['locat_bd_lbs_point']['radius'],
|
||||
]
|
||||
$mapTypes = [
|
||||
'baidu' => ['key' => 'locat_bd_lbs_key', 'point' => 'locat_bd_lbs_point', 'msg' => '请填写百度地图AK'],
|
||||
'amap' => ['key' => 'locat_amap_key', 'point' => 'locat_amap_point', 'msg' => '请填写高德地图Key'],
|
||||
'tencent' => ['key' => 'locat_tencent_key', 'point' => 'locat_tencent_point', 'msg' => '请填写腾讯地图Key'],
|
||||
];
|
||||
$type = $setting['locat_map_type'];
|
||||
if (isset($mapTypes[$type])) {
|
||||
$conf = $mapTypes[$type];
|
||||
$point = $setting[$conf['point']];
|
||||
$menu[] = [
|
||||
'key' => 'locat-checkin',
|
||||
'label' => Doo::translate('定位签到'),
|
||||
'config' => [
|
||||
'type' => $type,
|
||||
'key' => $setting[$conf['key']],
|
||||
'lng' => $point['lng'],
|
||||
'lat' => $point['lat'],
|
||||
'radius' => intval($point['radius']),
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
if (in_array('manual', $setting['modes'])) {
|
||||
$menu[] = [
|
||||
@@ -180,28 +258,8 @@ class UserBot extends AbstractModel
|
||||
];
|
||||
|
||||
default:
|
||||
if (preg_match('/^ai-(.*?)@bot\.system$/', $email, $match)) {
|
||||
if (!Base::judgeClientVersion('0.42.62')) {
|
||||
return [
|
||||
'key' => '%3A.clear',
|
||||
'label' => Doo::translate('清空上下文')
|
||||
];
|
||||
}
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
$aibotModel = $aibotSetting[$match[1] . '_model'];
|
||||
$aibotModels = Setting::AIModels2Array($aibotSetting[$match[1] . '_models']);
|
||||
if (empty($aibotModels)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
[
|
||||
'key' => '~ai-model-select',
|
||||
'label' => Doo::translate('选择模型'),
|
||||
'config' => [
|
||||
'model' => $aibotModel,
|
||||
'models' => $aibotModels
|
||||
]
|
||||
],
|
||||
if (preg_match('/^(ai-|user-session-)(.*?)@bot\.system$/', $email, $match)) {
|
||||
$menus = [
|
||||
[
|
||||
'key' => '~ai-session-create',
|
||||
'label' => Doo::translate('开启新会话'),
|
||||
@@ -211,6 +269,27 @@ class UserBot extends AbstractModel
|
||||
'label' => Doo::translate('历史会话'),
|
||||
]
|
||||
];
|
||||
if ($match[1] === "ai-") {
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
$aibotModel = $aibotSetting[$match[2] . '_model'];
|
||||
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
|
||||
if ($aibotModels) {
|
||||
$menus = array_merge(
|
||||
[
|
||||
[
|
||||
'key' => '~ai-model-select',
|
||||
'label' => Doo::translate('选择模型'),
|
||||
'config' => [
|
||||
'model' => $aibotModel,
|
||||
'models' => $aibotModels
|
||||
]
|
||||
]
|
||||
],
|
||||
$menus
|
||||
);
|
||||
}
|
||||
}
|
||||
return $menus;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -239,7 +318,6 @@ class UserBot extends AbstractModel
|
||||
return '暂未开放手动签到。';
|
||||
}
|
||||
UserBot::checkinBotCheckin('manual-' . $userid, Timer::time(), true);
|
||||
return null;
|
||||
} elseif ($command === 'locat-checkin') {
|
||||
$setting = Base::setting('checkinSetting');
|
||||
if ($setting['open'] !== 'open') {
|
||||
@@ -251,16 +329,14 @@ class UserBot extends AbstractModel
|
||||
if (empty($extra)) {
|
||||
return '当前客户端版本低(所需版本≥v0.39.75)。';
|
||||
}
|
||||
if ($extra['type'] === 'bd') {
|
||||
if (in_array($extra['type'], ['baidu', 'amap', 'tencent'])) {
|
||||
// todo 判断距离
|
||||
} else {
|
||||
return '错误的定位签到。';
|
||||
}
|
||||
UserBot::checkinBotCheckin('locat-' . $userid, Timer::time(), true);
|
||||
return null;
|
||||
} else {
|
||||
return Extranet::checkinBotQuickMsg($command);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,16 +354,47 @@ class UserBot extends AbstractModel
|
||||
$advance = (intval($setting['advance']) ?: 120) * 60;
|
||||
$delay = (intval($setting['delay']) ?: 120) * 60;
|
||||
//
|
||||
$currentTime = Timer::time();
|
||||
$nowDate = date("Y-m-d");
|
||||
$nowTime = date("H:i:s");
|
||||
$yesterdayDate = date("Y-m-d", strtotime("-1 day"));
|
||||
//
|
||||
// 今天的签到窗口
|
||||
$timeStart = strtotime("{$nowDate} {$times[0]}");
|
||||
$timeEnd = strtotime("{$nowDate} {$times[1]}");
|
||||
$timeAdvance = max($timeStart - $advance, strtotime($nowDate));
|
||||
$timeDelay = min($timeEnd + $delay, strtotime("{$nowDate} 23:59:59"));
|
||||
// 移除 23:59:59 限制,允许跨天
|
||||
$todayTimeDelay = $timeEnd + $delay;
|
||||
//
|
||||
// 昨天的延后窗口(用于判断凌晨打卡归属)
|
||||
$yesterdayTimeEnd = strtotime("{$yesterdayDate} {$times[1]}");
|
||||
$yesterdayTimeDelay = $yesterdayTimeEnd + $delay;
|
||||
//
|
||||
// 判断签到归属哪天
|
||||
$targetDate = null;
|
||||
$checkType = null; // 'up' 或 'down'
|
||||
//
|
||||
// 情况1:在今天的有效窗口内
|
||||
if ($currentTime >= $timeAdvance && $currentTime <= $todayTimeDelay) {
|
||||
$targetDate = $nowDate;
|
||||
if ($currentTime < $timeEnd) {
|
||||
$checkType = 'up';
|
||||
} else {
|
||||
$checkType = 'down';
|
||||
}
|
||||
}
|
||||
// 情况2:凌晨时段,检查是否在昨天的延后窗口内
|
||||
elseif ($currentTime < $timeAdvance && $currentTime <= $yesterdayTimeDelay) {
|
||||
$targetDate = $yesterdayDate;
|
||||
$checkType = 'down';
|
||||
}
|
||||
//
|
||||
// 构建错误消息
|
||||
$errorTime = false;
|
||||
if (Timer::time() < $timeAdvance || $timeDelay < Timer::time()) {
|
||||
$errorTime = "不在有效时间内,有效时间为:" . date("H:i", $timeAdvance) . "-" . date("H:i", $timeDelay);
|
||||
if (!$targetDate) {
|
||||
$displayDelay = date("H:i", $todayTimeDelay % 86400);
|
||||
$nextDay = ($todayTimeDelay > strtotime("{$nowDate} 23:59:59")) ? "(+1)" : "";
|
||||
$errorTime = "不在有效时间内,有效时间为:" . date("H:i", $timeAdvance) . "-{$displayDelay}{$nextDay}";
|
||||
}
|
||||
//
|
||||
$macs = explode(",", $mac);
|
||||
@@ -301,7 +408,7 @@ class UserBot extends AbstractModel
|
||||
$array[] = [
|
||||
'userid' => $UserCheckinMac->userid,
|
||||
'mac' => $UserCheckinMac->mac,
|
||||
'date' => $nowDate,
|
||||
'date' => $targetDate ?: $nowDate,
|
||||
];
|
||||
$checkins[] = [
|
||||
'userid' => $UserCheckinMac->userid,
|
||||
@@ -322,7 +429,7 @@ class UserBot extends AbstractModel
|
||||
$array[] = [
|
||||
'userid' => $UserInfo->userid,
|
||||
'mac' => '00:00:00:00:00:00',
|
||||
'date' => $nowDate,
|
||||
'date' => $targetDate ?: $nowDate,
|
||||
];
|
||||
$checkins[] = [
|
||||
'userid' => $UserInfo->userid,
|
||||
@@ -357,7 +464,8 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$sendMsg = function($type, $checkin) use ($errorTime, $alreadyTip, $getJokeSoup, $botUser, $nowDate) {
|
||||
$sendMsg = function($type, $checkin) use ($errorTime, $alreadyTip, $getJokeSoup, $botUser, $targetDate, $nowDate) {
|
||||
$displayDate = $targetDate ?: $nowDate;
|
||||
$dialog = WebSocketDialog::checkUserDialog($botUser, $checkin['userid']);
|
||||
if (!$dialog) {
|
||||
return;
|
||||
@@ -374,12 +482,13 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 判断已打卡
|
||||
$cacheKey = "Checkin::sendMsg-{$nowDate}-{$type}:" . $checkin['userid'];
|
||||
// 判断已打卡(使用目标日期作为缓存键)
|
||||
$cacheKey = "Checkin::sendMsg-{$displayDate}-{$type}:" . $checkin['userid'];
|
||||
$typeContent = $type == "up" ? "上班" : "下班";
|
||||
if (Cache::get($cacheKey) === "yes") {
|
||||
if ($alreadyTip) {
|
||||
$text = "今日已{$typeContent}打卡,无需重复打卡。";
|
||||
$dateHint = ($displayDate != $nowDate) ? "({$displayDate}) " : "今日";
|
||||
$text = "{$dateHint}已{$typeContent}打卡,无需重复打卡。";
|
||||
$text .= $checkin['remark'] ? " ({$checkin['remark']})": "";
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
@@ -393,7 +502,8 @@ class UserBot extends AbstractModel
|
||||
$hi = date("H:i");
|
||||
$remark = $checkin['remark'] ? " ({$checkin['remark']})": "";
|
||||
$subcontent = $getJokeSoup($type, $checkin['userid']);
|
||||
$title = "{$typeContent}打卡成功,打卡时间: {$hi}{$remark}";
|
||||
$dateInfo = ($displayDate != $nowDate) ? " ({$displayDate})" : "";
|
||||
$title = "{$typeContent}打卡成功,打卡时间: {$hi}{$remark}{$dateInfo}";
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => 'content',
|
||||
'title' => $title,
|
||||
@@ -408,14 +518,13 @@ class UserBot extends AbstractModel
|
||||
],
|
||||
], $botUser->userid, false, false, $type != "up");
|
||||
};
|
||||
if ($timeAdvance <= Timer::time() && Timer::time() < $timeEnd) {
|
||||
// 上班打卡通知(从最早打卡时间 到 下班打卡时间)
|
||||
// 根据打卡类型发送通知
|
||||
if ($checkType === 'up') {
|
||||
foreach ($checkins as $checkin) {
|
||||
$sendMsg('up', $checkin);
|
||||
}
|
||||
}
|
||||
if ($timeEnd <= Timer::time() && Timer::time() <= $timeDelay) {
|
||||
// 下班打卡通知(下班打卡时间 到 最晚打卡时间)
|
||||
if ($checkType === 'down') {
|
||||
foreach ($checkins as $checkin) {
|
||||
$sendMsg('down', $checkin);
|
||||
}
|
||||
@@ -445,11 +554,12 @@ class UserBot extends AbstractModel
|
||||
|
||||
/**
|
||||
* 创建我的机器人
|
||||
* @param $userid
|
||||
* @param $botName
|
||||
* @param int $userid 创建人userid
|
||||
* @param string $botName 机器人名称
|
||||
* @param bool $sessionSupported 是否支持会话
|
||||
* @return array
|
||||
*/
|
||||
public static function newbot($userid, $botName)
|
||||
public static function newBot($userid, $botName, $sessionSupported = false)
|
||||
{
|
||||
if (User::select(['users.*'])
|
||||
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
|
||||
@@ -461,7 +571,8 @@ class UserBot extends AbstractModel
|
||||
if (strlen($botName) < 2 || strlen($botName) > 20) {
|
||||
return Base::retError("机器人名称由2-20个字符组成。");
|
||||
}
|
||||
$data = User::botGetOrCreate("user-" . Base::generatePassword(), [
|
||||
$botType = ($sessionSupported ? "user-session-" : "user-normal-") . Base::generatePassword();
|
||||
$data = User::botGetOrCreate($botType, [
|
||||
'nickname' => $botName
|
||||
], $userid);
|
||||
if (empty($data)) {
|
||||
@@ -469,6 +580,14 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($data, $userid);
|
||||
if ($dialog) {
|
||||
if ($sessionSupported) {
|
||||
$dialogSession = WebSocketDialogSession::create([
|
||||
'dialog_id' => $dialog->id,
|
||||
]);
|
||||
$dialogSession->save();
|
||||
$dialog->session_id = $dialogSession->id;
|
||||
$dialog->save();
|
||||
}
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
|
||||
'type' => '/hello',
|
||||
'title' => '创建成功。',
|
||||
@@ -477,4 +596,42 @@ class UserBot extends AbstractModel
|
||||
}
|
||||
return Base::retSuccess("创建成功。", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可选的 webhook 事件
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function webhookEventOptions(): array
|
||||
{
|
||||
return [
|
||||
self::WEBHOOK_EVENT_MESSAGE,
|
||||
self::WEBHOOK_EVENT_DIALOG_OPEN,
|
||||
self::WEBHOOK_EVENT_MEMBER_JOIN,
|
||||
self::WEBHOOK_EVENT_MEMBER_LEAVE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化 webhook 事件配置
|
||||
*
|
||||
* @param mixed $events
|
||||
* @param bool $useFallback
|
||||
* @return array
|
||||
*/
|
||||
public static function normalizeWebhookEvents(mixed $events, bool $useFallback = true): array
|
||||
{
|
||||
if (is_string($events)) {
|
||||
$events = Base::json2array($events);
|
||||
}
|
||||
if ($events === null) {
|
||||
$events = [];
|
||||
}
|
||||
if (!is_array($events)) {
|
||||
$events = [$events];
|
||||
}
|
||||
$events = array_filter(array_map('strval', $events));
|
||||
$events = array_values(array_intersect($events, self::webhookEventOptions()));
|
||||
return $events ?: ($useFallback ? [self::WEBHOOK_EVENT_MESSAGE] : []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,16 +88,32 @@ class UserCheckinRecord extends AbstractModel
|
||||
|
||||
/**
|
||||
* 时间收集
|
||||
* @param string $data
|
||||
* @param array $times
|
||||
* @param string $data 日期
|
||||
* @param array $times 签到时间数组
|
||||
* @param string|null $shiftStart 班次开始时间(如 "09:00"),用于判断跨天
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function atCollect($data, $times)
|
||||
public static function atCollect($data, $times, $shiftStart = null)
|
||||
{
|
||||
$sameTimes = array_map(function($time) use ($data) {
|
||||
$shiftStartMinutes = null;
|
||||
if ($shiftStart) {
|
||||
$parts = explode(':', $shiftStart);
|
||||
$shiftStartMinutes = intval($parts[0]) * 60 + intval($parts[1]);
|
||||
}
|
||||
|
||||
$sameTimes = array_map(function($time) use ($data, $shiftStartMinutes) {
|
||||
$parts = explode(':', $time);
|
||||
$timeMinutes = intval($parts[0]) * 60 + intval($parts[1]);
|
||||
|
||||
// 如果签到时间早于班次开始时间,视为跨天打卡(属于次日凌晨)
|
||||
$targetDate = $data;
|
||||
if ($shiftStartMinutes !== null && $timeMinutes < $shiftStartMinutes) {
|
||||
$targetDate = date("Y-m-d", strtotime($data . " +1 day"));
|
||||
}
|
||||
|
||||
return [
|
||||
"datetime" => "{$data} {$time}",
|
||||
"timestamp" => strtotime("{$data} {$time}")
|
||||
"datetime" => "{$targetDate} {$time}",
|
||||
"timestamp" => strtotime("{$targetDate} {$time}")
|
||||
];
|
||||
}, $times);
|
||||
return collect($sameTimes);
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Module\Base;
|
||||
* @property int|null $userid 用户id
|
||||
* @property string|null $email 邮箱帐号
|
||||
* @property string|null $reason 注销原因
|
||||
* @property string $cache 会员资料缓存
|
||||
* @property string|null $cache 会员资料缓存
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use Cache;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* App\Models\UserDepartment
|
||||
@@ -34,6 +37,10 @@ use App\Exceptions\ApiException;
|
||||
*/
|
||||
class UserDepartment extends AbstractModel
|
||||
{
|
||||
protected $appends = [
|
||||
'deputy_userids',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取所有父级部门
|
||||
* @return array
|
||||
@@ -49,6 +56,55 @@ class UserDepartment extends AbstractModel
|
||||
return $parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门管理员 userid 列表
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyUseridsAttribute(): array
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
return [];
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否部门负责人(与 owner_userid 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (int)$this->owner_userid === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否部门管理员(在 user_department_owners 表里)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if (empty($this->id) || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否负责人(含部门管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存部门
|
||||
* @param $data
|
||||
@@ -64,18 +120,38 @@ class UserDepartment extends AbstractModel
|
||||
}
|
||||
$this->updateInstance($data);
|
||||
//
|
||||
// 防御:新负责人若残留在 user_department_owners 中(如曾是该部门管理员),清理掉
|
||||
// 否则后续 delDeputy / 罢免接口会把当前部门负责人误移出部门
|
||||
if ($this->id && (int)$this->owner_userid > 0) {
|
||||
\DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', (int)$this->owner_userid)
|
||||
->delete();
|
||||
}
|
||||
//
|
||||
if ($this->dialog_id > 0) {
|
||||
// 已有群
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原负责人 role=0、新负责人 role=1(部门管理员 role=2 保留不动)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -85,16 +161,33 @@ class UserDepartment extends AbstractModel
|
||||
if (empty($dialog)) {
|
||||
throw new ApiException("选择现有聊天群不存在");
|
||||
}
|
||||
$oldOwnerId = (int)$dialog->owner_id;
|
||||
$dialog->name = $this->name;
|
||||
$dialog->owner_id = $this->owner_userid;
|
||||
$dialog->group_type = 'department';
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->owner_userid, 0, true);
|
||||
// 同步 role:原负责人 role=0、新负责人 role=1、原部门管理员 role=0
|
||||
// 原部门管理员清零:避免 dialog_users.role=2 与 user_department_owners 不一致
|
||||
// (部门管理员关系不带过来,须通过 addDeputy 显式重新任命)
|
||||
if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) {
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $oldOwnerId)
|
||||
->update(['role' => 0]);
|
||||
}
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', '!=', $this->owner_userid)
|
||||
->where('role', 2)
|
||||
->update(['role' => 0]);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->owner_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'name' => $dialog->name,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'group_type' => $dialog->group_type,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'notice', [
|
||||
'notice' => User::nickname() . " 将此群改为部门群"
|
||||
@@ -115,6 +208,12 @@ class UserDepartment extends AbstractModel
|
||||
$oldUser->department = array_diff($oldUser->department, [$this->id]);
|
||||
$oldUser->department = "," . implode(",", $oldUser->department) . ",";
|
||||
$oldUser->save();
|
||||
// 原主从 users.department 移除后也要退出部门群(保持成员关系=群关系一致)
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->exitGroup($oldUser->userid, 'remove', false, true);
|
||||
}
|
||||
}
|
||||
if ($newUser) {
|
||||
$newUser->department = array_diff($newUser->department, [$this->id]);
|
||||
@@ -125,6 +224,123 @@ class UserDepartment extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 任命部门管理员
|
||||
* - 部门管理员自动加入 users.department(成为部门成员,与负责人对齐)
|
||||
* - 部门管理员自动加入部门群 + 设 role=2
|
||||
* - 幂等(已是部门管理员不报错)
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function addDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
throw new ApiException('请选择有效的成员');
|
||||
}
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if (!$user) {
|
||||
throw new ApiException('该用户不存在');
|
||||
}
|
||||
if ((int)$this->owner_userid === (int)$userid) {
|
||||
throw new ApiException('不能将部门负责人任命为部门管理员');
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid, $user) {
|
||||
// 写部门管理员表(unique key 自动幂等)
|
||||
\DB::table('user_department_owners')->insertOrIgnore([
|
||||
'department_id' => $this->id,
|
||||
'userid' => $userid,
|
||||
]);
|
||||
|
||||
// 加入 users.department(成为部门成员,与负责人对齐)
|
||||
$userDeptIds = $user->department; // accessor 返回数组
|
||||
if (!in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_merge($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 加部门管理员入部门群 + 设 role=2 + important=true
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
// joinGroup($userid, $inviter, $important=null, $pushMsg=true)
|
||||
// important=true:部门管理员成员关系不可被普通群操作打散
|
||||
$dialog->joinGroup($userid, 0, true, true);
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $userid)
|
||||
->update(['role' => 2]);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 罢免部门管理员
|
||||
* - 删部门管理员表记录
|
||||
* - 从 users.department 移除该部门 ID(与负责人"离开部门"对齐)
|
||||
* - 退出部门群(成员关系=群关系一致)
|
||||
* - 幂等
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
*/
|
||||
public function delDeputy($userid)
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防御:当前部门负责人不能被罢免(saveDepartment 应已清理残留,此处兜底)
|
||||
// 仅清理 user_department_owners 中的悬挂记录,绝不联动移除其部门成员关系/部门群成员
|
||||
if ((int)$this->owner_userid === (int)$userid) {
|
||||
\DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
AbstractModel::transaction(function () use ($userid) {
|
||||
$deleted = \DB::table('user_department_owners')
|
||||
->where('department_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
// 从 users.department 移除该部门 ID
|
||||
$user = User::whereUserid($userid)->first();
|
||||
if ($user) {
|
||||
$userDeptIds = $user->department;
|
||||
if (in_array($this->id, $userDeptIds)) {
|
||||
$userDeptIds = array_diff($userDeptIds, [$this->id]);
|
||||
$user->department = "," . implode(",", $userDeptIds) . ",";
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
||||
// 退出部门群(exitGroup 会清除 dialog_users 记录,role 随之消失)
|
||||
if ($this->dialog_id > 0) {
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
if ($dialog) {
|
||||
// checkDelete=false:业务流程跳过 owner_id/important 校验
|
||||
$dialog->exitGroup($userid, 'remove', false, true);
|
||||
$dialog->pushMsg('groupUpdate', [
|
||||
'id' => $dialog->id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
* @return void
|
||||
@@ -147,6 +363,8 @@ class UserDepartment extends AbstractModel
|
||||
// 解散群组
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$dialog?->deleteDialog();
|
||||
// 清理部门管理员记录(防悬挂)
|
||||
\DB::table('user_department_owners')->where('department_id', $this->id)->delete();
|
||||
//
|
||||
$this->delete();
|
||||
}
|
||||
@@ -159,6 +377,7 @@ class UserDepartment extends AbstractModel
|
||||
*/
|
||||
public static function transfer($originalUserid, $newUserid)
|
||||
{
|
||||
// 部门负责人转让(保持现有逻辑)
|
||||
self::whereOwnerUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) {
|
||||
/** @var self $item */
|
||||
foreach ($list as $item) {
|
||||
@@ -167,5 +386,298 @@ class UserDepartment extends AbstractModel
|
||||
]);
|
||||
}
|
||||
});
|
||||
// 部门管理员离职清理(新增):直接删除离职用户的所有部门管理员记录
|
||||
// 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群,role 随成员关系一起消失
|
||||
\DB::table('user_department_owners')
|
||||
->where('userid', $originalUserid)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归获取所有子部门ID
|
||||
* @param int $departmentId
|
||||
* @return array
|
||||
*/
|
||||
public static function getAllSubDepartmentIds($departmentId)
|
||||
{
|
||||
$subIds = [];
|
||||
$directSubs = self::whereParentId($departmentId)->pluck('id')->toArray();
|
||||
|
||||
foreach ($directSubs as $subId) {
|
||||
$subIds[] = $subId;
|
||||
// 递归获取子部门的子部门
|
||||
$subSubIds = self::getAllSubDepartmentIds($subId);
|
||||
$subIds = array_merge($subIds, $subSubIds);
|
||||
}
|
||||
|
||||
return array_unique($subIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可切换负责人视角的部门(正负责人 + 部门管理员)
|
||||
* @param int $userid
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function getManagedDepartments($userid)
|
||||
{
|
||||
$userid = intval($userid);
|
||||
if ($userid <= 0) {
|
||||
return collect();
|
||||
}
|
||||
$deputyDepartmentIds = \DB::table('user_department_owners')
|
||||
->where('userid', $userid)
|
||||
->pluck('department_id')
|
||||
->map(fn($v) => intval($v))
|
||||
->toArray();
|
||||
|
||||
return self::select(['id', 'name', 'parent_id', 'owner_userid'])
|
||||
->where(function ($query) use ($userid, $deputyDepartmentIds) {
|
||||
$query->where('owner_userid', $userid);
|
||||
if ($deputyDepartmentIds) {
|
||||
$query->orWhereIn('id', $deputyDepartmentIds);
|
||||
}
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户选择的负责人视角部门范围(含所有下级部门)
|
||||
* @param int $userid
|
||||
* @param array|string|null $selectedIds all/空表示全部可管理部门
|
||||
* @return array
|
||||
*/
|
||||
public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array
|
||||
{
|
||||
$managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray();
|
||||
if (empty($managedIds)) {
|
||||
return [];
|
||||
}
|
||||
if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) {
|
||||
$selected = $managedIds;
|
||||
} else {
|
||||
if (!is_array($selectedIds)) {
|
||||
$selectedIds = explode(',', (string)$selectedIds);
|
||||
}
|
||||
$selected = array_values(array_intersect(
|
||||
array_map('intval', $selectedIds),
|
||||
$managedIds
|
||||
));
|
||||
}
|
||||
if (empty($selected)) {
|
||||
return [];
|
||||
}
|
||||
$scopeIds = [];
|
||||
foreach ($selected as $departmentId) {
|
||||
$scopeIds[] = $departmentId;
|
||||
$scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId));
|
||||
}
|
||||
return array_values(array_unique(array_map('intval', $scopeIds)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取负责人视角可管理的成员 userid
|
||||
* @param int $userid
|
||||
* @param array|string|null $selectedIds
|
||||
* @return array
|
||||
*/
|
||||
public static function getManagedMemberUserids($userid, $selectedIds = null): array
|
||||
{
|
||||
$departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds);
|
||||
if (empty($departmentIds)) {
|
||||
return [];
|
||||
}
|
||||
return User::select(['userid'])
|
||||
->where(function ($query) use ($departmentIds) {
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$query->orWhere('department', 'like', "%,{$departmentId},%");
|
||||
}
|
||||
})
|
||||
->pluck('userid')
|
||||
->map(fn($v) => intval($v))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门基本信息(缓存时间1小时)
|
||||
* @param int|array $ids
|
||||
* @return \Illuminate\Support\Collection|static|null
|
||||
*/
|
||||
public static function getDepartmentsByIds($ids)
|
||||
{
|
||||
$ids = is_array($ids) ? $ids : [$ids];
|
||||
$departments = collect();
|
||||
$uncachedIds = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$cacheKey = "department_info_{$id}";
|
||||
$department = Cache::get($cacheKey);
|
||||
if ($department) {
|
||||
$departments->push($department);
|
||||
} else {
|
||||
$uncachedIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($uncachedIds)) {
|
||||
$dbDepartments = self::select(['id', 'name', 'parent_id', 'owner_userid'])->whereIn('id', $uncachedIds)->get();
|
||||
foreach ($dbDepartments as $department) {
|
||||
$cacheKey = "department_info_{$department->id}";
|
||||
Cache::put($cacheKey, $department, 60 * 60); // 1小时
|
||||
$departments->push($department);
|
||||
}
|
||||
}
|
||||
|
||||
// 保持返回顺序与传入ids一致
|
||||
$departments = $departments->keyBy('id');
|
||||
$result = collect();
|
||||
foreach ($ids as $id) {
|
||||
if ($departments->has($id)) {
|
||||
$result->push($departments->get($id));
|
||||
}
|
||||
}
|
||||
|
||||
return is_array($ids) ? $result : $result->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门负责人视角上下文(只读)。
|
||||
* $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。
|
||||
*/
|
||||
public static function ownerViewContext(User $user, bool $defaultAll = false): array
|
||||
{
|
||||
$ids = Request::input('department_owner_ids', Request::input('department_ids'));
|
||||
if (($ids === null || $ids === '') && $defaultAll) {
|
||||
$ids = 'all';
|
||||
}
|
||||
$empty = [
|
||||
'enabled' => false,
|
||||
'member_userids' => [],
|
||||
'project_ids' => [],
|
||||
'project_id_map' => [],
|
||||
'own_project_ids' => [],
|
||||
'own_project_id_map' => [],
|
||||
];
|
||||
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return $empty;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
|
||||
if (empty($memberUserids)) {
|
||||
return $empty;
|
||||
}
|
||||
// 项目可单独关闭"部门负责人视角可见",关闭后对负责人隐藏(含项目和任务群聊)
|
||||
$projectIds = ProjectUser::whereIn('project_users.userid', $memberUserids)
|
||||
->join('projects', 'projects.id', '=', 'project_users.project_id')
|
||||
->whereNull('projects.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('projects.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('projects.department_owner_view');
|
||||
})
|
||||
->distinct()
|
||||
->pluck('projects.id')
|
||||
->map(fn($v) => intval($v))
|
||||
->values()
|
||||
->toArray();
|
||||
$ownProjectIds = ProjectUser::whereUserid($user->userid)
|
||||
->pluck('project_id')
|
||||
->map(fn($v) => intval($v))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
return [
|
||||
'enabled' => !empty($projectIds),
|
||||
'member_userids' => $memberUserids,
|
||||
'project_ids' => $projectIds,
|
||||
'project_id_map' => array_fill_keys($projectIds, true),
|
||||
'own_project_ids' => $ownProjectIds,
|
||||
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断项目是否属于部门只读范围(非本人项目)
|
||||
*/
|
||||
public static function isDepartmentReadonlyProject(array $context, int $projectId): bool
|
||||
{
|
||||
return !empty($context['enabled'])
|
||||
&& isset($context['project_id_map'][$projectId])
|
||||
&& !isset($context['own_project_id_map'][$projectId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为项目数据附加部门只读标记
|
||||
*/
|
||||
public static function appendDepartmentReadonlyProject(array $project, array $context): array
|
||||
{
|
||||
$project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id']));
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员卡片「查看该会员项目/任务」的权限上下文。
|
||||
* 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
|
||||
* @param User $viewer 当前登录用户
|
||||
* @param int $targetUserid 目标会员
|
||||
* @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]]
|
||||
* project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制
|
||||
*/
|
||||
public static function userWorksContext(User $viewer, int $targetUserid): array
|
||||
{
|
||||
$result = [
|
||||
'allowed' => false,
|
||||
'is_self' => false,
|
||||
'is_admin' => false,
|
||||
'project_ids' => [],
|
||||
];
|
||||
if ($targetUserid <= 0) {
|
||||
return $result;
|
||||
}
|
||||
// 机器人/系统账号(或不存在)不展示项目与任务
|
||||
$target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first();
|
||||
if (empty($target) || $target->bot) {
|
||||
return $result;
|
||||
}
|
||||
// 本人
|
||||
if ($viewer->userid === $targetUserid) {
|
||||
$result['allowed'] = true;
|
||||
$result['is_self'] = true;
|
||||
return $result;
|
||||
}
|
||||
// 系统管理员
|
||||
if ($viewer->isAdmin()) {
|
||||
$result['allowed'] = true;
|
||||
$result['is_admin'] = true;
|
||||
return $result;
|
||||
}
|
||||
// 部门负责人只读视角
|
||||
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
|
||||
return $result;
|
||||
}
|
||||
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
|
||||
if (!in_array($targetUserid, $memberUserids, true)) {
|
||||
return $result;
|
||||
}
|
||||
// 目标会员参与、且未关闭「部门负责人视角可见」的项目
|
||||
$projectIds = ProjectUser::where('project_users.userid', $targetUserid)
|
||||
->join('projects', 'projects.id', '=', 'project_users.project_id')
|
||||
->whereNull('projects.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('projects.department_owner_view', '<>', 'close')
|
||||
->orWhereNull('projects.department_owner_view');
|
||||
})
|
||||
->distinct()
|
||||
->pluck('projects.id')
|
||||
->map(fn($v) => intval($v))
|
||||
->values()
|
||||
->toArray();
|
||||
if (empty($projectIds)) {
|
||||
return $result;
|
||||
}
|
||||
$result['allowed'] = true;
|
||||
$result['project_ids'] = $projectIds;
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class UserDevice extends AbstractModel
|
||||
* @param string $hash
|
||||
* @return string
|
||||
*/
|
||||
private static function ck(string $hash): string
|
||||
public static function ck(string $hash): string
|
||||
{
|
||||
return "user_devices:{$hash}";
|
||||
}
|
||||
|
||||
340
app/Models/UserFavorite.php
Normal file
340
app/Models/UserFavorite.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\File;
|
||||
|
||||
/**
|
||||
* App\Models\UserFavorite
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $userid 用户ID
|
||||
* @property string|null $favoritable_type 收藏类型(比如:task/project/file/message)
|
||||
* @property int|null $favoritable_id 收藏对象ID
|
||||
* @property string $remark 收藏备注
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $favoritable
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableType($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereRemark($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserFavorite extends AbstractModel
|
||||
{
|
||||
const TYPE_TASK = 'task';
|
||||
const TYPE_PROJECT = 'project';
|
||||
const TYPE_FILE = 'file';
|
||||
const TYPE_MESSAGE = 'message';
|
||||
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'favoritable_type',
|
||||
'favoritable_id',
|
||||
'remark',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid', 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 多态关联
|
||||
*/
|
||||
public function favoritable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换收藏状态
|
||||
* @param int $userid 用户ID
|
||||
* @param string $type 收藏类型
|
||||
* @param int $id 收藏对象ID
|
||||
* @return array ['favorited' => bool, 'action' => 'added'|'removed']
|
||||
*/
|
||||
public static function toggleFavorite($userid, $type, $id)
|
||||
{
|
||||
$favorite = self::whereUserid($userid)
|
||||
->whereFavoritableType($type)
|
||||
->whereFavoritableId($id)
|
||||
->first();
|
||||
|
||||
if ($favorite) {
|
||||
// 取消收藏
|
||||
$favorite->delete();
|
||||
return ['favorited' => false, 'action' => 'removed', 'remark' => ''];
|
||||
}
|
||||
|
||||
// 添加收藏
|
||||
$favorite = self::create([
|
||||
'userid' => $userid,
|
||||
'favoritable_type' => $type,
|
||||
'favoritable_id' => $id,
|
||||
]);
|
||||
|
||||
return ['favorited' => true, 'action' => 'added', 'remark' => $favorite->remark ?? ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新收藏备注
|
||||
* @param int $userid
|
||||
* @param string $type
|
||||
* @param int $id
|
||||
* @param string $remark
|
||||
* @return static|null
|
||||
*/
|
||||
public static function updateRemark($userid, $type, $id, $remark)
|
||||
{
|
||||
$favorite = self::whereUserid($userid)
|
||||
->whereFavoritableType($type)
|
||||
->whereFavoritableId($id)
|
||||
->first();
|
||||
|
||||
if (!$favorite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$favorite->remark = $remark;
|
||||
$favorite->save();
|
||||
|
||||
return $favorite;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已收藏
|
||||
* @param int $userid 用户ID
|
||||
* @param string $type 收藏类型
|
||||
* @param int $id 收藏对象ID
|
||||
* @return bool
|
||||
*/
|
||||
public static function isFavorited($userid, $type, $id)
|
||||
{
|
||||
return self::whereUserid($userid)
|
||||
->whereFavoritableType($type)
|
||||
->whereFavoritableId($id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户收藏列表
|
||||
* @param int $userid 用户ID
|
||||
* @param string|null $type 收藏类型过滤
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页数量
|
||||
* @return array
|
||||
*/
|
||||
public static function getUserFavorites($userid, $type = null, $page = 1, $pageSize = 20)
|
||||
{
|
||||
$query = self::whereUserid($userid)->orderByDesc('created_at');
|
||||
|
||||
if ($type) {
|
||||
$query->whereFavoritableType($type);
|
||||
}
|
||||
|
||||
$favorites = $query->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$data = [
|
||||
'tasks' => [],
|
||||
'projects' => [],
|
||||
'files' => [],
|
||||
'messages' => []
|
||||
];
|
||||
|
||||
// 分组收集ID
|
||||
$taskIds = [];
|
||||
$projectIds = [];
|
||||
$fileIds = [];
|
||||
$messageIds = [];
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
switch ($favorite->favoritable_type) {
|
||||
case self::TYPE_TASK:
|
||||
$taskIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
case self::TYPE_PROJECT:
|
||||
$projectIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
case self::TYPE_FILE:
|
||||
$fileIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
case self::TYPE_MESSAGE:
|
||||
$messageIds[] = $favorite->favoritable_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询具体数据
|
||||
if (!empty($taskIds)) {
|
||||
$tasks = ProjectTask::select([
|
||||
'project_tasks.id',
|
||||
'project_tasks.name',
|
||||
'project_tasks.project_id',
|
||||
'project_tasks.complete_at',
|
||||
'project_tasks.created_at',
|
||||
'project_tasks.flow_item_id',
|
||||
'project_tasks.flow_item_name',
|
||||
'projects.name as project_name'
|
||||
])
|
||||
->leftJoin('projects', 'project_tasks.project_id', '=', 'projects.id')
|
||||
->whereIn('project_tasks.id', $taskIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_TASK && isset($tasks[$favorite->favoritable_id])) {
|
||||
$task = $tasks[$favorite->favoritable_id];
|
||||
|
||||
// 解析 flow_item_name 字段(格式:status|name|color)
|
||||
$flowItemParts = explode('|', $task->flow_item_name ?: '');
|
||||
$flowItemStatus = $flowItemParts[0] ?? '';
|
||||
$flowItemName = $flowItemParts[1] ?? $task->flow_item_name;
|
||||
$flowItemColor = $flowItemParts[2] ?? '';
|
||||
|
||||
$data['tasks'][] = [
|
||||
'id' => $task->id,
|
||||
'name' => $task->name,
|
||||
'project_id' => $task->project_id,
|
||||
'project_name' => $task->project_name,
|
||||
'complete_at' => $task->complete_at,
|
||||
'flow_item_id' => $task->flow_item_id,
|
||||
'flow_item_name' => $flowItemName,
|
||||
'flow_item_status' => $flowItemStatus,
|
||||
'flow_item_color' => $flowItemColor,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($projectIds)) {
|
||||
$projects = Project::select([
|
||||
'id', 'name', 'desc', 'archived_at', 'created_at'
|
||||
])->whereIn('id', $projectIds)->get()->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_PROJECT && isset($projects[$favorite->favoritable_id])) {
|
||||
$project = $projects[$favorite->favoritable_id];
|
||||
$data['projects'][] = [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'desc' => $project->desc,
|
||||
'archived_at' => $project->archived_at,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($fileIds)) {
|
||||
$files = File::select([
|
||||
'id', 'name', 'ext', 'size', 'pid', 'created_at'
|
||||
])->whereIn('id', $fileIds)->get()->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_FILE && isset($files[$favorite->favoritable_id])) {
|
||||
$file = $files[$favorite->favoritable_id];
|
||||
$fileData = File::handleImageUrl(array_merge(
|
||||
$file->only(['id', 'ext']),
|
||||
[
|
||||
'name' => $file->name,
|
||||
'size' => $file->size,
|
||||
'pid' => $file->pid,
|
||||
]
|
||||
));
|
||||
$data['files'][] = [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'ext' => $file->ext,
|
||||
'size' => $file->size,
|
||||
'pid' => $file->pid,
|
||||
'image_url' => $fileData['image_url'] ?? null,
|
||||
'image_width' => $fileData['image_width'] ?? null,
|
||||
'image_height' => $fileData['image_height'] ?? null,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($messageIds)) {
|
||||
$messages = WebSocketDialogMsg::select([
|
||||
'id', 'dialog_id', 'userid', 'type', 'msg', 'created_at'
|
||||
])->whereIn('id', $messageIds)->get()->keyBy('id');
|
||||
|
||||
foreach ($favorites->items() as $favorite) {
|
||||
if ($favorite->favoritable_type === self::TYPE_MESSAGE && isset($messages[$favorite->favoritable_id])) {
|
||||
$message = $messages[$favorite->favoritable_id];
|
||||
|
||||
// 使用 previewMsg 获取消息预览文本
|
||||
$previewText = '';
|
||||
if ($message->msg && is_array($message->msg)) {
|
||||
$previewText = WebSocketDialogMsg::previewMsg($message);
|
||||
}
|
||||
|
||||
// 如果没有预览文本,使用消息类型作为标题
|
||||
if (empty($previewText)) {
|
||||
$previewText = '[' . ucfirst($message->type) . ']';
|
||||
}
|
||||
|
||||
$data['messages'][] = [
|
||||
'id' => $message->id,
|
||||
'name' => $previewText,
|
||||
'dialog_id' => $message->dialog_id,
|
||||
'userid' => $message->userid,
|
||||
'type' => $message->type,
|
||||
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
|
||||
'remark' => $favorite->remark,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'total' => $favorites->total(),
|
||||
'current_page' => $favorites->currentPage(),
|
||||
'per_page' => $favorites->perPage(),
|
||||
'last_page' => $favorites->lastPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户收藏
|
||||
* @param int $userid 用户ID
|
||||
* @param string|null $type 收藏类型,null表示全部类型
|
||||
* @return int 删除的记录数
|
||||
*/
|
||||
public static function cleanUserFavorites($userid, $type = null)
|
||||
{
|
||||
$query = self::whereUserid($userid);
|
||||
|
||||
if ($type) {
|
||||
$query->whereFavoritableType($type);
|
||||
}
|
||||
|
||||
return $query->delete();
|
||||
}
|
||||
}
|
||||
79
app/Models/UserRecentItem.php
Normal file
79
app/Models/UserRecentItem.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\UserRecentItem
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $userid 用户ID
|
||||
* @property string $target_type 目标类型(task/file/task_file/message_file 等)
|
||||
* @property int $target_id 目标ID
|
||||
* @property string $source_type 来源类型(project/filesystem/project_task/dialog 等)
|
||||
* @property int $source_id 来源ID
|
||||
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereBrowsedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereSourceType($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereTargetType($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserRecentItem whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserRecentItem extends AbstractModel
|
||||
{
|
||||
public const TYPE_TASK = 'task';
|
||||
public const TYPE_FILE = 'file';
|
||||
public const TYPE_TASK_FILE = 'task_file';
|
||||
public const TYPE_MESSAGE_FILE = 'message_file';
|
||||
|
||||
public const SOURCE_PROJECT = 'project';
|
||||
public const SOURCE_FILESYSTEM = 'filesystem';
|
||||
public const SOURCE_PROJECT_TASK = 'project_task';
|
||||
public const SOURCE_DIALOG = 'dialog';
|
||||
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'source_type',
|
||||
'source_id',
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
protected $dates = [
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
public static function record(int $userid, string $targetType, int $targetId, string $sourceType = '', int $sourceId = 0): self
|
||||
{
|
||||
return self::updateOrCreate(
|
||||
[
|
||||
'userid' => $userid,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'source_type' => $sourceType,
|
||||
'source_id' => $sourceId,
|
||||
],
|
||||
[
|
||||
'browsed_at' => Carbon::now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
115
app/Models/UserTag.php
Normal file
115
app/Models/UserTag.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* App\Models\UserTag
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id 被标签用户ID
|
||||
* @property string $name 标签名称
|
||||
* @property int $created_by 创建人
|
||||
* @property int|null $updated_by 最后更新人
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\User $creator
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserTagRecognition> $recognitions
|
||||
* @property-read int|null $recognitions_count
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereCreatedBy($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereName($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUpdatedBy($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTag whereUserId($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserTag extends AbstractModel
|
||||
{
|
||||
protected $table = 'user_tags';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by', 'userid')
|
||||
->select(['userid', 'nickname']);
|
||||
}
|
||||
|
||||
public function recognitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserTagRecognition::class, 'tag_id');
|
||||
}
|
||||
|
||||
public function canManage(User $viewer): bool
|
||||
{
|
||||
return $viewer->isAdmin()
|
||||
|| $viewer->userid === $this->user_id
|
||||
|| $viewer->userid === $this->created_by;
|
||||
}
|
||||
|
||||
public static function listWithMeta(int $targetUserId, ?User $viewer): array
|
||||
{
|
||||
$query = static::query()
|
||||
->where('user_id', $targetUserId)
|
||||
->with(['creator'])
|
||||
->withCount(['recognitions as recognition_total'])
|
||||
->orderByDesc('recognition_total')
|
||||
->orderBy('id');
|
||||
|
||||
$tags = $query->get();
|
||||
|
||||
$viewerId = $viewer?->userid ?? 0;
|
||||
$viewerIsAdmin = $viewer?->isAdmin() ?? false;
|
||||
$viewerIsOwner = $viewerId > 0 && $viewerId === $targetUserId;
|
||||
|
||||
$recognizedIds = [];
|
||||
if ($viewerId > 0 && $tags->isNotEmpty()) {
|
||||
$recognizedIds = UserTagRecognition::query()
|
||||
->where('user_id', $viewerId)
|
||||
->whereIn('tag_id', $tags->pluck('id'))
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
}
|
||||
$recognizedLookup = array_flip($recognizedIds);
|
||||
|
||||
$list = $tags->map(function (self $tag) use ($viewerId, $viewerIsAdmin, $viewerIsOwner, $recognizedLookup) {
|
||||
$canManage = $viewerIsAdmin || $viewerIsOwner || $viewerId === $tag->created_by;
|
||||
|
||||
return [
|
||||
'id' => $tag->id,
|
||||
'user_id' => $tag->user_id,
|
||||
'name' => $tag->name,
|
||||
'created_by' => $tag->created_by,
|
||||
'created_by_name' => $tag->creator?->nickname ?: '',
|
||||
'recognition_total' => (int) $tag->recognition_total,
|
||||
'recognized' => isset($recognizedLookup[$tag->id]),
|
||||
'can_edit' => $canManage,
|
||||
'can_delete' => $canManage,
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
return [
|
||||
'list' => $list,
|
||||
'top' => array_slice($list, 0, 10),
|
||||
'total' => count($list),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Models/UserTagRecognition.php
Normal file
52
app/Models/UserTagRecognition.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\UserTagRecognition
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tag_id 标签ID
|
||||
* @property int $user_id 认可人ID
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\UserTag $tag
|
||||
* @property-read \App\Models\User $user
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereTagId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTagRecognition whereUserId($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserTagRecognition extends AbstractModel
|
||||
{
|
||||
protected $table = 'user_tag_recognitions';
|
||||
|
||||
protected $fillable = [
|
||||
'tag_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function tag(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserTag::class, 'tag_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id', 'userid')
|
||||
->select(['userid', 'nickname']);
|
||||
}
|
||||
}
|
||||
138
app/Models/UserTaskBrowse.php
Normal file
138
app/Models/UserTaskBrowse.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\UserTaskBrowse
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $userid 用户ID
|
||||
* @property int|null $task_id 任务ID
|
||||
* @property \Illuminate\Support\Carbon|null $browsed_at 浏览时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\ProjectTask|null $task
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereBrowsedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereTaskId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserTaskBrowse whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserTaskBrowse extends AbstractModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'userid',
|
||||
'task_id',
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
protected $dates = [
|
||||
'browsed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid', 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联任务
|
||||
*/
|
||||
public function task()
|
||||
{
|
||||
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户浏览任务
|
||||
* @param int $userid 用户ID
|
||||
* @param int $task_id 任务ID
|
||||
* @return UserTaskBrowse
|
||||
*/
|
||||
public static function recordBrowse($userid, $task_id)
|
||||
{
|
||||
$record = self::updateOrCreate(
|
||||
[
|
||||
'userid' => $userid,
|
||||
'task_id' => $task_id,
|
||||
],
|
||||
[
|
||||
'browsed_at' => Carbon::now(),
|
||||
]
|
||||
);
|
||||
|
||||
UserRecentItem::record(
|
||||
$userid,
|
||||
UserRecentItem::TYPE_TASK,
|
||||
$task_id,
|
||||
UserRecentItem::SOURCE_PROJECT,
|
||||
0
|
||||
);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户浏览历史
|
||||
* @param int $userid 用户ID
|
||||
* @param int $limit 获取数量
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function getUserBrowseHistory($userid, $limit = 20)
|
||||
{
|
||||
return self::with(['task' => function ($query) {
|
||||
$query->select([
|
||||
'id', 'name', 'project_id', 'column_id', 'parent_id',
|
||||
'flow_item_id', 'flow_item_name',
|
||||
'complete_at', 'archived_at'
|
||||
]);
|
||||
}])
|
||||
->whereUserid($userid)
|
||||
->whereHas('task', function ($query) {
|
||||
// 只获取存在且未被删除的任务
|
||||
$query->whereNull('archived_at');
|
||||
})
|
||||
->orderByDesc('browsed_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户浏览历史
|
||||
* @param int $userid 用户ID
|
||||
* @param int $keepCount 保留数量,0表示全部删除
|
||||
* @return int 删除的记录数
|
||||
*/
|
||||
public static function cleanUserBrowseHistory($userid, $keepCount = 100)
|
||||
{
|
||||
if ($keepCount === 0) {
|
||||
return self::whereUserid($userid)->delete();
|
||||
}
|
||||
|
||||
$keepIds = self::whereUserid($userid)
|
||||
->orderByDesc('browsed_at')
|
||||
->limit($keepCount)
|
||||
->pluck('id');
|
||||
|
||||
return self::whereUserid($userid)
|
||||
->whereNotIn('id', $keepIds)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,15 @@ class UserTransfer extends AbstractModel
|
||||
$dialog->owner_id = $this->new_userid;
|
||||
if ($dialog->save()) {
|
||||
$dialog->joinGroup($this->new_userid, 0);
|
||||
// 同步 role=1:保证 deputy_ids 与 owner_id 一致
|
||||
// 若 new_userid 之前是群管理员(role=2),升为群主后必须从 deputy 列表移出
|
||||
WebSocketDialogUser::where('dialog_id', $dialog->id)
|
||||
->where('userid', $this->new_userid)
|
||||
->update(['role' => 1]);
|
||||
$dialog->pushMsg("groupUpdate", [
|
||||
'id' => $dialog->id,
|
||||
'owner_id' => $dialog->owner_id,
|
||||
'deputy_ids' => $dialog->fresh()->deputy_ids,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace App\Models;
|
||||
* @property string $key
|
||||
* @property string|null $fd
|
||||
* @property string|null $path
|
||||
* @property string|null $platform 平台类型:android, ios, win, mac, web
|
||||
* @property int|null $userid
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
@@ -27,6 +28,7 @@ namespace App\Models;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereKey($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePath($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket wherePlatform($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocket whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
|
||||
@@ -62,6 +62,11 @@ class WebSocketDialog extends AbstractModel
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
// 全员群初始化默认名称(双语字面量),用于识别"管理员尚未自定义"的状态
|
||||
const ALL_GROUP_DEFAULT_NAME = '全体成员 All members';
|
||||
|
||||
protected $appends = ['deputy_ids'];
|
||||
|
||||
/**
|
||||
* 头像地址
|
||||
* @param $value
|
||||
@@ -260,6 +265,15 @@ class WebSocketDialog extends AbstractModel
|
||||
$data[$field] = $data[$field] ?? null;
|
||||
}
|
||||
}
|
||||
// DB::table 列表/search/beyond 渠道进入的是 stdClass,不会触发 Eloquent $appends。
|
||||
// 这里统一补齐 deputy_ids,保证群管理员入口和标识在所有会话来源中一致。
|
||||
if (($data['type'] ?? null) === 'group' && !array_key_exists('deputy_ids', $data)) {
|
||||
$data['deputy_ids'] = WebSocketDialogUser::whereDialogId($data['id'])
|
||||
->where('role', 2)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
$data['avatar'] = Base::fillUrl($data['avatar']);
|
||||
|
||||
// 会员必要字段
|
||||
@@ -355,7 +369,9 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
break;
|
||||
case 'all':
|
||||
$data['name'] = Doo::translate('全体成员');
|
||||
$data['name'] = ($data['name'] && $data['name'] !== self::ALL_GROUP_DEFAULT_NAME)
|
||||
? $data['name']
|
||||
: Doo::translate('全体成员');
|
||||
$data['dialog_mute'] = Base::settingFind('system', 'all_group_mute');
|
||||
break;
|
||||
}
|
||||
@@ -457,11 +473,12 @@ class WebSocketDialog extends AbstractModel
|
||||
* @param int|array $userid 加入的会员ID或会员ID组
|
||||
* @param int $inviter 邀请人
|
||||
* @param bool|null $important 重要人员(null不修改、bool修改)
|
||||
* @param bool $pushMsg 是否推送消息
|
||||
* @return bool
|
||||
*/
|
||||
public function joinGroup($userid, $inviter, $important = null)
|
||||
public function joinGroup($userid, $inviter, $important = null, $pushMsg = true)
|
||||
{
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
|
||||
AbstractModel::transaction(function () use ($important, $inviter, $userid, $pushMsg) {
|
||||
foreach (is_array($userid) ? $userid : [$userid] as $value) {
|
||||
if ($value > 0) {
|
||||
$updateData = [
|
||||
@@ -479,7 +496,7 @@ class WebSocketDialog extends AbstractModel
|
||||
'bot' => User::isBot($value) ? 1 : 0
|
||||
]);
|
||||
}, $isInsert);
|
||||
if ($isInsert) {
|
||||
if ($isInsert && $pushMsg) {
|
||||
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
|
||||
'notice' => User::userid2nickname($value) . " 已加入群组"
|
||||
], $inviter, true, true);
|
||||
@@ -487,9 +504,11 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
});
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -515,11 +534,40 @@ class WebSocketDialog extends AbstractModel
|
||||
foreach ($list as $item) {
|
||||
if ($checkDelete) {
|
||||
if ($type === 'remove') {
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群仅群主或邀请人可以操作
|
||||
// 移出时:如果是全员群仅允许管理员操作,其他群主/群管理员/邀请人可以操作
|
||||
if ($this->group_type === 'all') {
|
||||
User::auth("admin");
|
||||
} elseif (!in_array(User::userid(), [$this->owner_id, $item->inviter])) {
|
||||
throw new ApiException('只有群主或邀请人可以移出成员');
|
||||
} else {
|
||||
$actor = User::userid();
|
||||
// 未认证时拒绝
|
||||
if ($actor <= 0) {
|
||||
throw new ApiException('只有群主或邀请人可以移出成员');
|
||||
}
|
||||
|
||||
// 目标是群主或群管理员时的保护
|
||||
$targetIsPrimaryOwner = $this->isPrimaryOwner($item->userid);
|
||||
$targetIsDeputyOwner = $this->isDeputyOwner($item->userid);
|
||||
|
||||
if ($targetIsPrimaryOwner || $targetIsDeputyOwner) {
|
||||
// 普通邀请人不能移出群主或群管理员
|
||||
$actorIsPrimaryOwner = $this->isPrimaryOwner($actor);
|
||||
$actorIsDeputyOwner = $this->isDeputyOwner($actor);
|
||||
|
||||
if (!$actorIsPrimaryOwner && !$actorIsDeputyOwner) {
|
||||
throw new ApiException('普通成员不能移出群主或群管理员');
|
||||
}
|
||||
|
||||
// 群管理员不能移出群主或其他群管理员
|
||||
if ($actorIsDeputyOwner && !$actorIsPrimaryOwner) {
|
||||
throw new ApiException('群管理员不能移出群主或其他群管理员');
|
||||
}
|
||||
}
|
||||
|
||||
// 普通成员:群主、群管理员、邀请人可移出
|
||||
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
|
||||
if (!$allowedActor) {
|
||||
throw new ApiException('只有群主、群管理员或邀请人可以移出成员');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($item->userid == $this->owner_id) {
|
||||
@@ -530,6 +578,7 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
//
|
||||
$item->operator_id = User::userid();
|
||||
$item->delete();
|
||||
//
|
||||
if ($pushMsg) {
|
||||
@@ -546,9 +595,47 @@ class WebSocketDialog extends AbstractModel
|
||||
});
|
||||
});
|
||||
//
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
if ($pushMsg) {
|
||||
$data = WebSocketDialog::generatePeople($this->id);
|
||||
$data['id'] = $this->id;
|
||||
$this->pushMsg("groupUpdate", $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送成员事件到机器人 webhook
|
||||
* @param string $event
|
||||
* @param int $memberId
|
||||
* @param int $operatorId
|
||||
* @return void
|
||||
*/
|
||||
public function dispatchMemberWebhook(string $event, int $memberId, int $operatorId): void
|
||||
{
|
||||
$botIds = $this->dialogUser()->where('bot', 1)->pluck('userid')->toArray();
|
||||
if (empty($botIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userBots = UserBot::whereIn('bot_id', $botIds)->get();
|
||||
if ($userBots->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$member = User::find($memberId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
|
||||
$operator = $operatorId === $memberId ? $member : User::find($operatorId, ['userid', 'nickname', 'email', 'bot'])?->toArray();
|
||||
|
||||
$payload = [
|
||||
'dialog_id' => $this->id,
|
||||
'dialog_type' => $this->type,
|
||||
'group_type' => $this->group_type,
|
||||
'dialog_name' => $this->getGroupName(),
|
||||
'member' => $member,
|
||||
'operator' => $operator,
|
||||
];
|
||||
|
||||
foreach ($userBots as $userBot) {
|
||||
$userBot->dispatchWebhook($event, $payload, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,6 +685,93 @@ class WebSocketDialog extends AbstractModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群主(与 owner_id 一致)
|
||||
*/
|
||||
public function isPrimaryOwner($userid): bool
|
||||
{
|
||||
return $userid > 0 && (int)$this->owner_id === (int)$userid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群管理员(仅 web_socket_dialog_users.role=2)
|
||||
*/
|
||||
public function isDeputyOwner($userid): bool
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('userid', $userid)
|
||||
->where('role', 2)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否群主(含群管理员)
|
||||
*/
|
||||
public function isOwner($userid): bool
|
||||
{
|
||||
return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有权限设置/取消本会话内「他人」的待办
|
||||
* 放行:群主/群管理员、关联项目负责人/项目管理员、关联任务负责人(及任务所属项目负责人/管理员)
|
||||
*
|
||||
* @param int $userid
|
||||
* @return bool
|
||||
*/
|
||||
public function checkTodoOwnerPermission($userid): bool
|
||||
{
|
||||
$userid = intval($userid);
|
||||
if ($userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
// 系统管理员:可管理任意会话的他人待办(与管理员全局管理能力一致,覆盖无群主的全员群等)
|
||||
if (User::find($userid)?->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
// 群主 / 群管理员
|
||||
if ($this->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
// 关联项目(项目群)负责人 / 项目管理员
|
||||
$project = Project::whereDialogId($this->id)->first();
|
||||
if ($project && $project->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
// 关联任务(任务群)负责人,及任务所属项目负责人 / 管理员
|
||||
$task = ProjectTask::whereDialogId($this->id)->first();
|
||||
if ($task) {
|
||||
if (ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->whereOwner(1)->exists()) {
|
||||
return true;
|
||||
}
|
||||
$taskProject = Project::find($task->project_id);
|
||||
if ($taskProject && $taskProject->isOwner($userid)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 群管理员 userid 列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDeputyIdsAttribute(): array
|
||||
{
|
||||
if (!$this->id) {
|
||||
return [];
|
||||
}
|
||||
return WebSocketDialogUser::where('dialog_id', $this->id)
|
||||
->where('role', 2)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查禁言
|
||||
* @param $userid
|
||||
@@ -655,7 +829,9 @@ class WebSocketDialog extends AbstractModel
|
||||
$name = \DB::table('project_tasks')->where('dialog_id', $this->id)->value('name');
|
||||
break;
|
||||
case 'all':
|
||||
$name = Doo::translate('全体成员');
|
||||
$name = ($name && $name !== self::ALL_GROUP_DEFAULT_NAME)
|
||||
? $name
|
||||
: Doo::translate('全体成员');
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -705,6 +881,27 @@ class WebSocketDialog extends AbstractModel
|
||||
return WebSocketDialogUser::whereDialogId($this->id)->where('userid', '>', 0)->count() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持创建会话
|
||||
* @return bool
|
||||
*/
|
||||
public function isSessionDialog()
|
||||
{
|
||||
// 这个不会有变化,所以可以使用永久缓存
|
||||
return Cache::rememberForever('is-session-dialog-' . $this->id, function () {
|
||||
if ($this->type !== 'user') {
|
||||
return false;
|
||||
}
|
||||
$data = $this->dialogUserBuilder()->get();
|
||||
foreach ($data as $item) {
|
||||
if (preg_match('/^(ai-|user-session-)(.*?)@bot\.system$/', $item->email)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是AI对话
|
||||
* @return bool
|
||||
@@ -762,6 +959,13 @@ class WebSocketDialog extends AbstractModel
|
||||
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
|
||||
return $dialog;
|
||||
}
|
||||
// 部门负责人只读视角:项目/任务群按项目级共享放行(任务数据另按可见性校验,与普通成员一致)
|
||||
if ($projectId > 0 && $checkOwner === false) {
|
||||
$departmentView = UserDepartment::ownerViewContext(User::auth(), true);
|
||||
if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) {
|
||||
return $dialog;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'okr':
|
||||
@@ -799,6 +1003,8 @@ class WebSocketDialog extends AbstractModel
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $value,
|
||||
'role' => ($owner_id > 0 && (int)$value === (int)$owner_id) ? 1 : 0,
|
||||
'bot' => User::isBot($value) ? 1 : 0,
|
||||
'important' => !in_array($group_type, ['user', 'all']),
|
||||
'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null,
|
||||
])->save();
|
||||
@@ -835,17 +1041,17 @@ class WebSocketDialog extends AbstractModel
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $user->userid,
|
||||
'bot' => User::isBot($user->userid) ? 1 : 0,
|
||||
])->save();
|
||||
WebSocketDialogUser::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $receiver,
|
||||
'bot' => User::isBot($receiver) ? 1 : 0,
|
||||
])->save();
|
||||
//
|
||||
if ($user->isAiBot() || User::find($receiver)?->isAiBot()) {
|
||||
$session = WebSocketDialogSession::create([
|
||||
'dialog_id' => $dialog->id,
|
||||
'status' => 1,
|
||||
'title' => '',
|
||||
]);
|
||||
$session->save();
|
||||
$dialog->session_id = $session->id;
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use App\Module\Base;
|
||||
use App\Module\Doo;
|
||||
use App\Module\Image;
|
||||
use App\Tasks\PushTask;
|
||||
use App\Models\ProjectTaskRelation;
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Tasks\WebSocketDialogMsgTask;
|
||||
use Hhxsv5\LaravelS\Swoole\Task\Task;
|
||||
@@ -42,6 +44,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property-read int|mixed $percentage
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @property-read \App\Models\WebSocketDialog|null $webSocketDialog
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg accessibleByUser(int $userid)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
@@ -52,6 +55,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg searchByKeyword(string $keyword)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereBot($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereDeletedAt($value)
|
||||
@@ -109,6 +113,36 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $this->hasOne(User::class, 'userid', 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词搜索消息(Scope)
|
||||
* 搜索 key 字段(消息的可搜索内容)
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $keyword 搜索关键词
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeSearchByKeyword($query, string $keyword)
|
||||
{
|
||||
return $query->where('key', 'like', "%{$keyword}%");
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选用户可访问的对话消息(Scope)
|
||||
* 通过 web_socket_dialog_users 表验证用户对对话的访问权限
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param int $userid 用户ID
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeAccessibleByUser($query, int $userid)
|
||||
{
|
||||
return $query->whereIn('dialog_id', function ($subQuery) use ($userid) {
|
||||
$subQuery->select('dialog_id')
|
||||
->from('web_socket_dialog_users')
|
||||
->where('userid', $userid);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 阅读占比
|
||||
* @return int|mixed
|
||||
@@ -315,6 +349,24 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return Base::retSuccess('success', $resData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否完成所有待办
|
||||
* @param bool $noCache 是否禁止缓存
|
||||
* @return int 1=已完成 0=未完成
|
||||
*/
|
||||
public function isTodoDone(?bool $noCache = false): int
|
||||
{
|
||||
if ($noCache) {
|
||||
Cache::forget('todo_done_' . $this->id);
|
||||
}
|
||||
if ($this->todo <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return (int) Cache::remember('todo_done_' . $this->id, Carbon::now()->addDays(), function () {
|
||||
return WebSocketDialogMsgTodo::whereMsgId($this->id)->whereDoneAt(null)->exists() ? 0 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标注、取消标注
|
||||
* @param int $sender 标注的会员ID
|
||||
@@ -362,28 +414,28 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param array $userids 设置给指定会员
|
||||
* @return mixed
|
||||
*/
|
||||
public function toggleTodoMsg($sender, $userids = [])
|
||||
public function toggleTodoMsg($sender, $userids = [], $remindAt = false)
|
||||
{
|
||||
if (in_array($this->type, ['tag', 'todo', 'notice'])) {
|
||||
return Base::retError('此消息不支持设待办');
|
||||
}
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
$current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray();
|
||||
$cancel = array_diff($current, $userids);
|
||||
$setup = array_diff($userids, $current);
|
||||
// 待办操作权限管控(系统开关:禁止其他人员设置/取消待办)
|
||||
if (Base::settingFind('system', 'todo_set_permission') === 'close') {
|
||||
$affected = array_unique(array_merge($cancel, $setup)); // 本次真正影响到的用户
|
||||
$others = array_diff($affected, [$sender]); // 排除"自己"
|
||||
if ($others && !$dialog->checkTodoOwnerPermission($sender)) {
|
||||
return Base::retError('仅群主、项目/任务负责人或系统管理员可设置或取消他人待办');
|
||||
}
|
||||
}
|
||||
//
|
||||
$this->todo = $setup || count($current) > count($cancel) ? $sender : 0;
|
||||
$this->save();
|
||||
$upData = [
|
||||
'id' => $this->id,
|
||||
'todo' => $this->todo,
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
$dialog = WebSocketDialog::find($this->dialog_id);
|
||||
//
|
||||
$retData = [
|
||||
'add' => [],
|
||||
'update' => $upData
|
||||
];
|
||||
$addData = [];
|
||||
if ($cancel) {
|
||||
$res = self::sendMsg(null, $this->dialog_id, 'todo', [
|
||||
'action' => 'remove',
|
||||
@@ -395,7 +447,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
]
|
||||
], $sender);
|
||||
if (Base::isSuccess($res)) {
|
||||
$retData['add'][] = $res['data'];
|
||||
$addData[] = $res['data'];
|
||||
WebSocketDialogMsgTodo::whereMsgId($this->id)->whereIn('userid', $cancel)->delete();
|
||||
}
|
||||
}
|
||||
@@ -410,7 +462,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
]
|
||||
], $sender);
|
||||
if (Base::isSuccess($res)) {
|
||||
$retData['add'][] = $res['data'];
|
||||
$addData[] = $res['data'];
|
||||
$useridList = $dialog->dialogUser->pluck('userid')->toArray();
|
||||
foreach ($setup as $userid) {
|
||||
if (!in_array($userid, $useridList)) {
|
||||
@@ -425,8 +477,45 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
}
|
||||
}
|
||||
//
|
||||
$upData = [
|
||||
'id' => $this->id,
|
||||
'todo' => $this->todo,
|
||||
'todo_done' => $this->isTodoDone(true),
|
||||
'dialog_id' => $this->dialog_id,
|
||||
];
|
||||
$dialog->pushMsg('update', $upData);
|
||||
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', $retData);
|
||||
//
|
||||
// 提醒时间:仅当调用方显式传入时处理(false=不传则不动既有提醒)
|
||||
if ($remindAt !== false) {
|
||||
$this->setTodoRemind($userids, $remindAt ?: null);
|
||||
}
|
||||
//
|
||||
return Base::retSuccess($this->todo ? '设置成功' : '取消成功', [
|
||||
'add' => $addData,
|
||||
'update' => $upData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/取消本消息指定成员待办的提醒时间(纯数据,无推送)。
|
||||
* 改动会把 reminded_at 重置为 null,使其可再次到点提醒。
|
||||
*
|
||||
* @param array $userids 目标成员
|
||||
* @param string|null $remindAt 提醒时间字符串;null/空 表示取消提醒
|
||||
* @return int 受影响行数
|
||||
*/
|
||||
public function setTodoRemind(array $userids, $remindAt = null)
|
||||
{
|
||||
$userids = array_values(array_filter(array_map('intval', $userids)));
|
||||
if (empty($userids)) {
|
||||
return 0;
|
||||
}
|
||||
return WebSocketDialogMsgTodo::whereMsgId($this->id)
|
||||
->whereIn('userid', $userids)
|
||||
->update([
|
||||
'remind_at' => $remindAt ?: null,
|
||||
'reminded_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,9 +527,58 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param string $leaveMessage 转发留言
|
||||
* @return mixed
|
||||
*/
|
||||
/**
|
||||
* 收集目标对话
|
||||
* @param array|int $userids 转发给的成员ID
|
||||
* @param array|int $dialogids 转发给的对话ID
|
||||
* @param User $user 当前用户
|
||||
* @return array
|
||||
*/
|
||||
private static function collectTargetDialogs($userids, $dialogids, $user)
|
||||
{
|
||||
$dialogs = [];
|
||||
if ($userids) {
|
||||
if (!is_array($userids)) {
|
||||
$userids = [$userids];
|
||||
}
|
||||
foreach ($userids as $userid) {
|
||||
if (!User::whereUserid($userid)->exists()) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($dialogids) {
|
||||
if (!is_array($dialogids)) {
|
||||
$dialogids = [$dialogids];
|
||||
}
|
||||
foreach ($dialogids as $dialogid) {
|
||||
if (isset($dialogs[$dialogid])) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::find($dialogid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $dialogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 不支持转发的消息类型
|
||||
*/
|
||||
public static $unforwardableTypes = ['tag', 'top', 'todo', 'notice', 'word-chain', 'vote', 'template'];
|
||||
|
||||
public function forwardMsg($dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($dialogids, $user, $userids, $showSource, $leaveMessage) {
|
||||
if (in_array($this->type, self::$unforwardableTypes)) {
|
||||
throw new ApiException('此类型消息不支持转发');
|
||||
}
|
||||
$msgData = Base::json2array($this->getRawOriginal('msg'));
|
||||
$forwardData = is_array($msgData['forward_data']) ? $msgData['forward_data'] : [];
|
||||
$forwardId = $forwardData['id'] ?: $this->id;
|
||||
@@ -459,35 +597,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
'leave' => $leaveMessage ? 1 : 0, // 是否留言(用于判断是否发给AI)
|
||||
];
|
||||
$msgs = [];
|
||||
$dialogs = [];
|
||||
if ($userids) {
|
||||
if (!is_array($userids)) {
|
||||
$userids = [$userids];
|
||||
}
|
||||
foreach ($userids as $userid) {
|
||||
if (!User::whereUserid($userid)->exists()) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::checkUserDialog($user, $userid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($dialogids) {
|
||||
if (!is_array($dialogids)) {
|
||||
$dialogids = [$dialogids];
|
||||
}
|
||||
foreach ($dialogids as $dialogid) {
|
||||
if (isset($dialogs[$dialogid])) {
|
||||
continue;
|
||||
}
|
||||
$dialog = WebSocketDialog::find($dialogid);
|
||||
if ($dialog) {
|
||||
$dialogs[$dialog->id] = $dialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
|
||||
foreach ($dialogs as $dialog) {
|
||||
$res = self::sendMsg('forward-' . $forwardId, $dialog->id, $this->type, $msgData, $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
@@ -510,6 +620,105 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并转发消息
|
||||
* @param array $msgIds 消息ID数组
|
||||
* @param array|int $dialogids 转发给的对话ID
|
||||
* @param array|int $userids 转发给的成员ID
|
||||
* @param User $user 当前用户
|
||||
* @param int $showSource 是否显示原发送者信息
|
||||
* @param string $leaveMessage 转发留言
|
||||
* @return array
|
||||
*/
|
||||
public static function mergeForwardMsg($msgIds, $dialogids, $userids, $user, $showSource = 1, $leaveMessage = '')
|
||||
{
|
||||
return AbstractModel::transaction(function () use ($msgIds, $dialogids, $userids, $user, $showSource, $leaveMessage) {
|
||||
// 查询并验证所有消息
|
||||
$msgs = self::whereIn('id', $msgIds)->orderBy('created_at')->get();
|
||||
if ($msgs->isEmpty()) {
|
||||
throw new ApiException('消息不存在或已被删除');
|
||||
}
|
||||
// 验证所有消息属于同一对话
|
||||
$dialogId = $msgs->first()->dialog_id;
|
||||
if ($msgs->pluck('dialog_id')->unique()->count() > 1) {
|
||||
throw new ApiException('只能合并转发同一对话的消息');
|
||||
}
|
||||
WebSocketDialog::checkDialog($dialogId);
|
||||
// 过滤不支持转发的消息类型
|
||||
$msgs = $msgs->filter(function ($msg) {
|
||||
return !in_array($msg->type, self::$unforwardableTypes);
|
||||
});
|
||||
if ($msgs->isEmpty()) {
|
||||
throw new ApiException('所选消息均不支持转发');
|
||||
}
|
||||
// 收集发送者信息
|
||||
$senderIds = $msgs->pluck('userid')->unique()->values()->toArray();
|
||||
$senderNames = User::whereIn('userid', array_slice($senderIds, 0, 2))
|
||||
->pluck('nickname')
|
||||
->toArray();
|
||||
// 组装预览列表(前4条,精简字段)
|
||||
$msgIds = $msgs->pluck('id')->toArray();
|
||||
$preview = [];
|
||||
foreach ($msgs->take(4) as $msg) {
|
||||
$preview[] = [
|
||||
'userid' => $msg->userid,
|
||||
'type' => $msg->type,
|
||||
'msg' => self::buildPreviewMsg($msg->type, Base::json2array($msg->getRawOriginal('msg'))),
|
||||
];
|
||||
}
|
||||
// 构建合并转发消息体
|
||||
$msgData = [
|
||||
'sender_names' => $senderNames,
|
||||
'sender_total' => count($senderIds),
|
||||
'msg_ids' => $msgIds,
|
||||
'preview' => $preview,
|
||||
'count' => count($msgIds),
|
||||
'forward_data' => [
|
||||
'show' => $showSource,
|
||||
'leave' => $leaveMessage ? 1 : 0,
|
||||
],
|
||||
];
|
||||
$dialogs = self::collectTargetDialogs($userids, $dialogids, $user);
|
||||
// 发送到每个目标对话
|
||||
$result = [];
|
||||
foreach ($dialogs as $dialog) {
|
||||
$res = self::sendMsg(null, $dialog->id, 'merge-forward', $msgData, $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$result[] = $res['data'];
|
||||
}
|
||||
if ($leaveMessage) {
|
||||
$res = self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid);
|
||||
if (Base::isSuccess($res)) {
|
||||
$result[] = $res['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('转发成功', [
|
||||
'msgs' => $result
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建预览消息(精简字段)
|
||||
* @param string $type
|
||||
* @param array $msg
|
||||
* @return array
|
||||
*/
|
||||
private static function buildPreviewMsg($type, $msg)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'text':
|
||||
return ['text' => $msg['text'] ?? ''];
|
||||
case 'file':
|
||||
return ['name' => $msg['name'] ?? '', 'ext' => $msg['ext'] ?? ''];
|
||||
case 'location':
|
||||
return ['title' => $msg['title'] ?? ''];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param array|int $ids
|
||||
@@ -641,6 +850,9 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
case 'template':
|
||||
return self::previewTemplateMsg($data['msg']);
|
||||
|
||||
case 'merge-forward':
|
||||
return "[" . Doo::translate("聊天记录") . "]";
|
||||
|
||||
case 'preview':
|
||||
return $data['msg']['preview'];
|
||||
|
||||
@@ -656,11 +868,12 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param bool $preserveHtml 保留html格式
|
||||
* @return string|string[]|null
|
||||
*/
|
||||
private static function previewTextMsg($msgData, $preserveHtml = false)
|
||||
public static function previewTextMsg($msgData, $preserveHtml = false)
|
||||
{
|
||||
$text = $msgData['text'] ?? '';
|
||||
if (!$text) return '';
|
||||
if ($msgData['type'] === 'md') {
|
||||
$text = preg_replace('/<\/?tool-use[^>]*>/', '', $text);
|
||||
$text = preg_replace("/:::\s*reasoning[\s\S]*?:::/", "", $text);
|
||||
if (preg_match('/:::\s*reasoning\s+/', $text)) {
|
||||
return Doo::translate('思考中...');
|
||||
@@ -673,7 +886,6 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$text = $title;
|
||||
} else {
|
||||
$text = Base::markdown2html($text);
|
||||
$text = self::previewConvertTaskList($text);
|
||||
}
|
||||
}
|
||||
$text = preg_replace("/<img\s+class=\"emoticon\"[^>]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text);
|
||||
@@ -689,36 +901,6 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换任务列表
|
||||
* @param $text
|
||||
* @return array|string|string[]|null
|
||||
*/
|
||||
private static function previewConvertTaskList($text) {
|
||||
$pattern = '/:::\s*(create-task-list|create-subtask-list)(.*?):::/s';
|
||||
$replacement = function($matches) {
|
||||
$content = $matches[2];
|
||||
$lines = explode("\n", trim($content));
|
||||
$result = [];
|
||||
$currentTitle = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
|
||||
if (preg_match('/^title:\s*(.+)$/', $line, $titleMatch)) {
|
||||
$currentTitle = $titleMatch[1];
|
||||
$result[] = $currentTitle;
|
||||
} elseif (preg_match('/^desc:\s*(.+)$/', $line, $descMatch)) {
|
||||
if (!empty($currentTitle)) {
|
||||
$result[] = $descMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return implode("\n", $result);
|
||||
};
|
||||
return preg_replace_callback($pattern, $replacement, $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览文件消息
|
||||
* @param $msg
|
||||
@@ -830,6 +1012,92 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取消息内容
|
||||
* 根据消息类型(文件、文本等)提取相应的内容文本
|
||||
*
|
||||
* @param int $maxLength 最大长度,超过则截取,0表示不限制
|
||||
* @return string 提取出的消息文本内容
|
||||
*/
|
||||
public function extractMessageContent(int $maxLength = 0): string
|
||||
{
|
||||
$reserves = [];
|
||||
switch ($this->type) {
|
||||
case "file":
|
||||
// 提取文件消息
|
||||
$result = " 文件:{$this->msg['name']}(大小:{$this->msg['size']}B,URL:{$this->msg['path']}) ";
|
||||
break;
|
||||
|
||||
case "text":
|
||||
// 提取文本消息
|
||||
$result = $this->msg['text'] ?: '';
|
||||
if (empty($result)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 提取快捷键
|
||||
if (preg_match("/<span[^>]*?data-quick-key=([\"'])([^\"']+?)\\1[^>]*?>(.*?)<\/span>/is", $result, $match)) {
|
||||
$command = $match[2] ?? '';
|
||||
$command = preg_replace("/^%3A\.?/", ":", $command);
|
||||
$command = trim($command);
|
||||
if ($command) {
|
||||
return $command;
|
||||
}
|
||||
}
|
||||
|
||||
// 提及任务、文件、报告
|
||||
$result = preg_replace_callback_array([
|
||||
// 用户
|
||||
"/<span class=\"mention user\" data-id=\"(\d+)\">(.*?)<\/span>/" => function ($match) {
|
||||
return "";
|
||||
},
|
||||
|
||||
// 任务
|
||||
"/<span class=\"mention task\" data-id=\"(\d+)\">#?(.*?)<\/span>/" => function ($match) {
|
||||
return " 任务:{$match[2]} (任务ID:{$match[1]}) ";
|
||||
},
|
||||
|
||||
// 文件
|
||||
"/<a class=\"mention file\" href=\"([^\"']+?)\"[^>]*?>~?(.*?)<\/a>/" => function ($match) use (&$reserves) {
|
||||
$idOrCode = "";
|
||||
if (preg_match("/single\/file\/(.*?)$/", $match[1], $subMatch)) {
|
||||
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "文件ID:{$subMatch[1]}" : "文件分享码:{$subMatch[1]}") . ")";
|
||||
}
|
||||
return " 文件:{$match[2]}{$idOrCode} ";
|
||||
},
|
||||
|
||||
// 报告
|
||||
"/<a class=\"mention report\" href=\"([^\"']+?)\"[^>]*?>%?(.*?)<\/a>/" => function ($match) use (&$reserves) {
|
||||
$idOrCode = "";
|
||||
if (preg_match("/single\/report\/detail\/(.*?)$/", $match[1], $subMatch)) {
|
||||
$idOrCode = " (" . (Base::isNumber($subMatch[1]) ? "报告ID:{$subMatch[1]}" : "报告分享码:{$subMatch[1]}") . ")";
|
||||
}
|
||||
return " 工作汇报:{$match[2]}{$idOrCode} ";
|
||||
},
|
||||
], $result);
|
||||
|
||||
// 转成 markdown
|
||||
if ($this->msg['type'] !== 'md') {
|
||||
$result = Base::html2markdown($result);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 其他类型消息不处理
|
||||
return '';
|
||||
}
|
||||
|
||||
// 截取最大长度
|
||||
if ($maxLength > 0 && mb_strlen($result) > $maxLength) {
|
||||
$result = mb_substr($result, 0, $maxLength);
|
||||
}
|
||||
|
||||
// 规范以斜杠开头的命令
|
||||
$result = preg_replace('/^\s*\\//', '/', $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文本消息内容,用于发送前
|
||||
* @param $text
|
||||
@@ -1152,6 +1420,9 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
$msg['height'] = $imageSize[1];
|
||||
}
|
||||
}
|
||||
if ($type === 'merge-forward') {
|
||||
$mtype = 'merge-forward';
|
||||
}
|
||||
if ($push_silence === null) {
|
||||
$push_silence = !in_array($type, ["text", "file", "record", "meeting"]);
|
||||
}
|
||||
@@ -1206,6 +1477,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
];
|
||||
$dialogMsg->updateInstance($updateData);
|
||||
$dialogMsg->generateKeyAndSave($search_key);
|
||||
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
||||
//
|
||||
WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($sender)->whereHide(1)->change([
|
||||
'hide' => 0, // 修改消息时,显示会话(仅自己)
|
||||
@@ -1272,6 +1544,7 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
'updated_at' => Carbon::now()->toDateTimeString('millisecond'),
|
||||
]);
|
||||
});
|
||||
ProjectTaskRelation::recordMentionsFromMessage($dialogMsg);
|
||||
//
|
||||
$task = new WebSocketDialogMsgTask($dialogMsg->id);
|
||||
if ($push_self) {
|
||||
@@ -1337,7 +1610,6 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将被@的人加入群
|
||||
* @param WebSocketDialog $dialog 对话
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* App\Models\WebSocketDialogMsgRead
|
||||
@@ -76,24 +77,74 @@ class WebSocketDialogMsgRead extends AbstractModel
|
||||
*/
|
||||
public static function onlyMarkRead($list)
|
||||
{
|
||||
$dialogMsg = [];
|
||||
if (empty($list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collection = collect($list);
|
||||
if ($collection->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$ids = [];
|
||||
$msgCounts = [];
|
||||
|
||||
/** @var WebSocketDialogMsgRead $item */
|
||||
foreach ($list as $item) {
|
||||
$item->read_at = Carbon::now();
|
||||
$item->save();
|
||||
if (isset($dialogMsg[$item->msg_id])) {
|
||||
$dialogMsg[$item->msg_id]['readNum']++;
|
||||
} else {
|
||||
$dialogMsg[$item->msg_id] = [
|
||||
'dialogMsg' => $item->webSocketDialogMsg,
|
||||
'readNum' => 1
|
||||
];
|
||||
foreach ($collection as $item) {
|
||||
$ids[] = $item->id;
|
||||
if ($item->msg_id) {
|
||||
$msgCounts[$item->msg_id] = ($msgCounts[$item->msg_id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
foreach ($dialogMsg as $item) {
|
||||
if ($item['dialogMsg']) {
|
||||
$item['dialogMsg']->increment('read', $item['readNum']);
|
||||
|
||||
if (!empty($ids)) {
|
||||
DB::table((new self())->getTable())
|
||||
->whereIn('id', $ids)
|
||||
->whereNull('read_at')
|
||||
->update(['read_at' => $now]);
|
||||
}
|
||||
|
||||
if (!empty($msgCounts)) {
|
||||
$cases = [];
|
||||
$bindings = [];
|
||||
foreach ($msgCounts as $msgId => $num) {
|
||||
$cases[] = 'WHEN ? THEN ?';
|
||||
$bindings[] = $msgId;
|
||||
$bindings[] = $num;
|
||||
}
|
||||
$msgIds = array_keys($msgCounts);
|
||||
$bindings = array_merge($bindings, $msgIds);
|
||||
$placeholders = implode(',', array_fill(0, count($msgIds), '?'));
|
||||
$table = DB::getTablePrefix() . (new WebSocketDialogMsg())->getTable();
|
||||
$sql = "UPDATE {$table} SET `read` = `read` + CASE `id` " . implode(' ', $cases) . " END WHERE `deleted_at` IS NULL AND `id` IN ({$placeholders})";
|
||||
DB::update($sql, $bindings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记指定会话的历史消息为已读
|
||||
* @param int $dialogId
|
||||
* @param int $sessionId
|
||||
* @param int $chunkSize
|
||||
* @return void
|
||||
*/
|
||||
public static function markSessionMessagesAsRead(int $dialogId, int $sessionId, int $chunkSize = 100): void
|
||||
{
|
||||
if ($dialogId <= 0 || $sessionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::whereDialogId($dialogId)
|
||||
->whereNull('read_at')
|
||||
->whereIn('msg_id', function ($query) use ($dialogId, $sessionId) {
|
||||
$query->select('id')
|
||||
->from((new WebSocketDialogMsg())->getTable())
|
||||
->where('dialog_id', $dialogId)
|
||||
->where('session_id', $sessionId);
|
||||
})
|
||||
->chunkById($chunkSize, function ($list) {
|
||||
self::onlyMarkRead($list);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\WebSocketDialogMsgTodo
|
||||
*
|
||||
@@ -50,4 +52,21 @@ class WebSocketDialogMsgTodo extends AbstractModel
|
||||
}
|
||||
return $this->appendattrs['msgData'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取到点待提醒的待办行:有提醒时间、未提醒、未完成、提醒时间已到。
|
||||
* 纯查询,无副作用,供 TodoRemindTask 使用。
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function dueReminders()
|
||||
{
|
||||
return self::whereNotNull('remind_at')
|
||||
->whereNull('reminded_at')
|
||||
->whereNull('done_at')
|
||||
->where('remind_at', '<=', Carbon::now())
|
||||
->orderBy('msg_id')
|
||||
->orderBy('id')
|
||||
->limit(500)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +66,14 @@ class WebSocketDialogSession extends AbstractModel
|
||||
if ($dialogMsg->type != 'text') {
|
||||
return;
|
||||
}
|
||||
if ($dialogMsg->msg['text'] === '...') {
|
||||
return;
|
||||
}
|
||||
$cacheKey = 'dialog_session_title_' . $sessionId;
|
||||
if (Cache::has($cacheKey)) {
|
||||
return;
|
||||
}
|
||||
$originalTitle = $dialogMsg->key ?: $dialogMsg->msg['text'] ?: 'Untitled';
|
||||
$title = Base::cutStr($originalTitle, 100);
|
||||
if ($title == '...') {
|
||||
return;
|
||||
}
|
||||
$title = $dialogMsg->key ?: WebSocketDialogMsg::previewTextMsg($dialogMsg->msg) ?: 'Untitled';
|
||||
$session = self::whereId($sessionId)->first();
|
||||
if (!$session) {
|
||||
return;
|
||||
@@ -82,6 +81,6 @@ class WebSocketDialogSession extends AbstractModel
|
||||
$session->title = $title;
|
||||
$session->save();
|
||||
Cache::forever($cacheKey, true);
|
||||
Task::deliver(new UpdateSessionTitleViaAiTask($session->id, $originalTitle));
|
||||
Task::deliver(new UpdateSessionTitleViaAiTask($session->id, $dialogMsg->msg['text']));
|
||||
}
|
||||
}
|
||||
|
||||
1132
app/Module/AI.php
Normal file
1132
app/Module/AI.php
Normal file
File diff suppressed because it is too large
Load Diff
858
app/Module/AiTaskSuggestion.php
Normal file
858
app/Module/AiTaskSuggestion.php
Normal file
@@ -0,0 +1,858 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskAiEvent;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Manticore\ManticoreBase;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AiTaskSuggestion
|
||||
{
|
||||
/**
|
||||
* AI 助手的 userid
|
||||
*/
|
||||
const AI_ASSISTANT_USERID = -1;
|
||||
|
||||
/**
|
||||
* 相似度阈值
|
||||
*/
|
||||
const SIMILAR_THRESHOLD = 0.5;
|
||||
|
||||
/**
|
||||
* 检查是否满足执行条件
|
||||
*/
|
||||
public static function shouldExecute(ProjectTask $task, string $eventType): bool
|
||||
{
|
||||
switch ($eventType) {
|
||||
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
|
||||
// 描述为空或长度 < 20
|
||||
$content = trim($task->content ?? '');
|
||||
return empty($content) || mb_strlen($content) < 20;
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_SUBTASKS:
|
||||
// 无子任务且标题长度 > 5
|
||||
$hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists();
|
||||
return !$hasSubtasks && mb_strlen($task->name) > 5;
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
|
||||
// 未指定负责人
|
||||
$hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists();
|
||||
return !$hasOwner;
|
||||
|
||||
case ProjectTaskAiEvent::EVENT_SIMILAR:
|
||||
// 需要安装 search 插件才能使用向量搜索
|
||||
return Apps::isInstalled('search');
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务描述建议
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function generateDescription(ProjectTask $task): ?array
|
||||
{
|
||||
$language = self::getUserLanguageInfo($task->userid)['name'];
|
||||
$prompt = self::buildDescriptionPrompt($task, $language);
|
||||
$result = self::callAi($prompt);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'description',
|
||||
'content' => $result,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成子任务拆分建议
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function generateSubtasks(ProjectTask $task): ?array
|
||||
{
|
||||
$language = self::getUserLanguageInfo($task->userid)['name'];
|
||||
$prompt = self::buildSubtasksPrompt($task, $language);
|
||||
$result = self::callAi($prompt);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析返回的子任务列表
|
||||
$subtasks = self::parseSubtasksList($result);
|
||||
if (empty($subtasks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'subtasks',
|
||||
'content' => $subtasks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成负责人推荐
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function generateAssignee(ProjectTask $task): ?array
|
||||
{
|
||||
// 获取当前任务已有的成员(负责人和协助人)
|
||||
$existingUserIds = ProjectTaskUser::where('task_id', $task->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// 获取项目成员,排除已有任务成员
|
||||
$members = self::getProjectMembersInfo($task->project_id);
|
||||
$members = array_filter($members, function ($member) use ($existingUserIds) {
|
||||
return !in_array($member['userid'], $existingUserIds);
|
||||
});
|
||||
$members = array_values($members); // 重新索引
|
||||
|
||||
if (empty($members)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$language = self::getUserLanguageInfo($task->userid)['name'];
|
||||
$prompt = self::buildAssigneePrompt($task, $members, $language);
|
||||
$result = self::callAi($prompt);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析推荐结果
|
||||
$recommendations = self::parseAssigneeRecommendations($result, $members);
|
||||
if (empty($recommendations)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'assignee',
|
||||
'content' => $recommendations,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索相似任务
|
||||
* @param ProjectTask $task 任务对象
|
||||
*/
|
||||
public static function findSimilarTasks(ProjectTask $task): ?array
|
||||
{
|
||||
// 使用 AI 模块的 Embedding 搜索
|
||||
$searchText = $task->name;
|
||||
if (empty($searchText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = AI::getEmbedding($searchText);
|
||||
if (Base::isError($result) || empty($result['data'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$embedding = $result['data'];
|
||||
|
||||
// 搜索相似任务(排除自己和子任务)
|
||||
$similarTasks = self::searchSimilarByEmbedding(
|
||||
$embedding,
|
||||
$task->project_id,
|
||||
$task->id
|
||||
);
|
||||
|
||||
if (empty($similarTasks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取用户语言对应的文案
|
||||
$lang = self::getUserLanguageInfo($task->userid)['code'];
|
||||
|
||||
return [
|
||||
'type' => 'similar',
|
||||
'lang' => $lang,
|
||||
'content' => $similarTasks,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('AiTaskSuggestion::findSimilarTasks error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户语言信息
|
||||
* @param int $userid 用户ID
|
||||
* @return array ['code' => 语言代码, 'name' => 语言名称]
|
||||
*/
|
||||
private static function getUserLanguageInfo(int $userid): array
|
||||
{
|
||||
$user = User::find($userid);
|
||||
$code = $user->lang ?? 'zh';
|
||||
$name = Doo::getLanguages($code) ?: '简体中文';
|
||||
return ['code' => $code, 'name' => $name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多语言标题和提示文案
|
||||
* @param string $lang 语言代码
|
||||
* @return array
|
||||
*/
|
||||
private static function getLocalizedTitles(string $lang): array
|
||||
{
|
||||
$titles = [
|
||||
'zh' => [
|
||||
'description' => '建议补充任务描述',
|
||||
'subtasks' => '建议拆分子任务',
|
||||
'assignee' => '推荐负责人',
|
||||
'similar' => '发现相似任务',
|
||||
'similar_hint' => '以下任务与当前任务内容相似,可能是重复任务或可作为参考:',
|
||||
],
|
||||
'zh-CHT' => [
|
||||
'description' => '建議補充任務描述',
|
||||
'subtasks' => '建議拆分子任務',
|
||||
'assignee' => '推薦負責人',
|
||||
'similar' => '發現相似任務',
|
||||
'similar_hint' => '以下任務與當前任務內容相似,可能是重複任務或可作為參考:',
|
||||
],
|
||||
'en' => [
|
||||
'description' => 'Suggested Task Description',
|
||||
'subtasks' => 'Suggested Subtasks',
|
||||
'assignee' => 'Recommended Assignee',
|
||||
'similar' => 'Similar Tasks Found',
|
||||
'similar_hint' => 'The following tasks are similar and may be duplicates or references:',
|
||||
],
|
||||
'ko' => [
|
||||
'description' => '작업 설명 추가 제안',
|
||||
'subtasks' => '하위 작업 분할 제안',
|
||||
'assignee' => '추천 담당자',
|
||||
'similar' => '유사한 작업 발견',
|
||||
'similar_hint' => '다음 작업은 현재 작업과 유사하며 중복되거나 참고할 수 있습니다:',
|
||||
],
|
||||
'ja' => [
|
||||
'description' => 'タスク説明の追加を提案',
|
||||
'subtasks' => 'サブタスクの分割を提案',
|
||||
'assignee' => '推奨担当者',
|
||||
'similar' => '類似タスクを発見',
|
||||
'similar_hint' => '以下のタスクは現在のタスクと類似しており、重複している可能性があります:',
|
||||
],
|
||||
'de' => [
|
||||
'description' => 'Vorgeschlagene Aufgabenbeschreibung',
|
||||
'subtasks' => 'Vorgeschlagene Unteraufgaben',
|
||||
'assignee' => 'Empfohlener Verantwortlicher',
|
||||
'similar' => 'Ähnliche Aufgaben gefunden',
|
||||
'similar_hint' => 'Die folgenden Aufgaben sind ähnlich und könnten Duplikate oder Referenzen sein:',
|
||||
],
|
||||
'fr' => [
|
||||
'description' => 'Description de tâche suggérée',
|
||||
'subtasks' => 'Sous-tâches suggérées',
|
||||
'assignee' => 'Responsable recommandé',
|
||||
'similar' => 'Tâches similaires trouvées',
|
||||
'similar_hint' => 'Les tâches suivantes sont similaires et peuvent être des doublons ou des références:',
|
||||
],
|
||||
'id' => [
|
||||
'description' => 'Saran Deskripsi Tugas',
|
||||
'subtasks' => 'Saran Pembagian Subtugas',
|
||||
'assignee' => 'Penanggung Jawab yang Direkomendasikan',
|
||||
'similar' => 'Tugas Serupa Ditemukan',
|
||||
'similar_hint' => 'Tugas berikut mirip dengan tugas saat ini dan mungkin duplikat atau referensi:',
|
||||
],
|
||||
'ru' => [
|
||||
'description' => 'Предлагаемое описание задачи',
|
||||
'subtasks' => 'Предлагаемые подзадачи',
|
||||
'assignee' => 'Рекомендуемый ответственный',
|
||||
'similar' => 'Найдены похожие задачи',
|
||||
'similar_hint' => 'Следующие задачи похожи на текущую и могут быть дубликатами или справочными:',
|
||||
],
|
||||
];
|
||||
|
||||
return $titles[$lang] ?? $titles['zh'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义用户输入以防止 Prompt 注入
|
||||
*/
|
||||
private static function escapeUserInput(string $input, int $length = 500): string
|
||||
{
|
||||
// 移除可能影响 AI Prompt 解析的特殊字符
|
||||
$input = str_replace(['```', '---', '==='], '', $input);
|
||||
// 截断过长的输入
|
||||
return mb_substr(trim($input), 0, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建描述生成 Prompt
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param string $language 输出语言名称
|
||||
*/
|
||||
private static function buildDescriptionPrompt(ProjectTask $task, string $language): string
|
||||
{
|
||||
$taskName = self::escapeUserInput($task->name, 100);
|
||||
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
|
||||
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
|
||||
|
||||
return <<<PROMPT
|
||||
你是一名任务规划助手,擅长根据任务标题推断并补充任务描述。
|
||||
|
||||
所属项目:{$projectName}
|
||||
所属栏目:{$columnName}
|
||||
任务标题:{$taskName}
|
||||
|
||||
你的任务:
|
||||
根据标题、项目和栏目信息,推断任务意图并生成实用的任务描述。
|
||||
|
||||
生成原则:
|
||||
1. 基于标题关键词和上下文进行合理推断,内容要具体、可执行
|
||||
2. 使用 Markdown 格式,根据任务性质灵活组织结构(可包含目标、要求、验收标准等)
|
||||
3. 简单任务保持简洁,复杂任务可适当展开,避免空泛的套话
|
||||
|
||||
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
|
||||
|
||||
输出要求:
|
||||
- 仅返回 Markdown 格式的描述内容
|
||||
- 禁止输出额外说明、引导语或与任务无关的内容
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建子任务拆分 Prompt
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param string $language 输出语言名称
|
||||
*/
|
||||
private static function buildSubtasksPrompt(ProjectTask $task, string $language): string
|
||||
{
|
||||
$taskName = self::escapeUserInput($task->name, 100);
|
||||
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
|
||||
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
|
||||
$content = self::escapeUserInput($task->content ?? '');
|
||||
|
||||
return <<<PROMPT
|
||||
你是一名任务拆解助手,擅长将复杂任务分解为可执行的子任务。
|
||||
|
||||
所属项目:{$projectName}
|
||||
所属栏目:{$columnName}
|
||||
任务标题:{$taskName}
|
||||
任务描述:{$content}
|
||||
|
||||
你的任务:
|
||||
分析任务内容,拆解出关键的执行步骤作为子任务。
|
||||
|
||||
拆解原则:
|
||||
1. 每个子任务聚焦单一可执行动作,避免含糊或重复
|
||||
2. 根据任务复杂度灵活决定数量(通常 2-5 个),简单任务少拆,复杂任务多拆
|
||||
3. 子任务之间保持合理的执行顺序或逻辑关系
|
||||
4. 子任务名称简洁明了,控制在 8-30 个字符内
|
||||
|
||||
输出语言:与任务标题的语言保持一致,如无法确定则使用{$language}
|
||||
|
||||
输出格式:
|
||||
1. [子任务名称]
|
||||
2. [子任务名称]
|
||||
...
|
||||
|
||||
输出要求:
|
||||
- 仅返回子任务列表,禁止输出额外说明或引导语
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建负责人推荐 Prompt
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param array $members 成员列表
|
||||
* @param string $language 输出语言名称
|
||||
*/
|
||||
private static function buildAssigneePrompt(ProjectTask $task, array $members, string $language): string
|
||||
{
|
||||
$taskName = self::escapeUserInput($task->name, 100);
|
||||
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
|
||||
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
|
||||
$taskContent = self::escapeUserInput($task->content ?? '');
|
||||
|
||||
$membersText = '';
|
||||
foreach ($members as $member) {
|
||||
$nickname = self::escapeUserInput($member['nickname'], 20);
|
||||
$membersText .= "- {$nickname}(ID:{$member['userid']})";
|
||||
if (!empty($member['profession'])) {
|
||||
$profession = self::escapeUserInput($member['profession'], 50);
|
||||
$membersText .= ",职位:{$profession}";
|
||||
}
|
||||
$membersText .= ",进行中:{$member['in_progress_count']}个";
|
||||
$membersText .= ",近期完成:{$member['completed_count']}个";
|
||||
$membersText .= "\n";
|
||||
}
|
||||
|
||||
return <<<PROMPT
|
||||
你是一名任务分配助手,根据任务内容和成员情况推荐合适的负责人。
|
||||
|
||||
所属项目:{$projectName}
|
||||
所属栏目:{$columnName}
|
||||
任务标题:{$taskName}
|
||||
任务描述:{$taskContent}
|
||||
|
||||
可选成员:
|
||||
{$membersText}
|
||||
|
||||
推荐原则:
|
||||
1. 分析任务内容,匹配成员职位或专业方向
|
||||
2. 优先推荐进行中任务较少的成员,平衡工作负载
|
||||
3. 近期完成任务多说明执行力强,可作为参考
|
||||
|
||||
输出语言:推荐理由的语言与任务标题保持一致,如无法确定则使用{$language}
|
||||
|
||||
输出格式:
|
||||
1. [userid]|[推荐理由]
|
||||
2. [userid]|[推荐理由]
|
||||
|
||||
输出要求:
|
||||
- 推荐 1-2 名最合适的负责人,按优先级排序
|
||||
- 推荐理由需具体说明为何此人适合该任务,不超过 20 字
|
||||
- 仅返回推荐列表,禁止输出额外说明
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 AI 接口
|
||||
*/
|
||||
private static function callAi(string $prompt): ?string
|
||||
{
|
||||
try {
|
||||
// 使用 AI 模块调用
|
||||
$result = AI::invoke([
|
||||
['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'],
|
||||
['user', $prompt],
|
||||
]);
|
||||
|
||||
if (Base::isError($result)) {
|
||||
\Log::error('AiTaskSuggestion::callAi error: ' . ($result['msg'] ?? 'Unknown error'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result['data']['content'] ?? null;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('AiTaskSuggestion::callAi error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目成员信息
|
||||
*/
|
||||
private static function getProjectMembersInfo(int $projectId): array
|
||||
{
|
||||
$projectUsers = ProjectUser::where('project_id', $projectId)->get();
|
||||
$members = [];
|
||||
|
||||
foreach ($projectUsers as $pu) {
|
||||
$user = User::find($pu->userid);
|
||||
if (!$user || $user->bot || $user->disable_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取进行中任务数量
|
||||
$inProgressCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', $user->userid)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->count();
|
||||
|
||||
// 获取近期完成任务数量
|
||||
$completedCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', $user->userid)
|
||||
->whereNotNull('project_tasks.complete_at')
|
||||
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(30))
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->count();
|
||||
|
||||
$members[] = [
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname,
|
||||
'profession' => $user->profession ?? '',
|
||||
'in_progress_count' => $inProgressCount,
|
||||
'completed_count' => $completedCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $members;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析子任务列表
|
||||
*/
|
||||
private static function parseSubtasksList(string $text): array
|
||||
{
|
||||
$lines = explode("\n", trim($text));
|
||||
$subtasks = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
// 移除序号前缀
|
||||
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
|
||||
if (!empty($line) && mb_strlen($line) <= 100) {
|
||||
$subtasks[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($subtasks, 0, 5); // 最多5个
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析负责人推荐结果
|
||||
*/
|
||||
private static function parseAssigneeRecommendations(string $text, array $members): array
|
||||
{
|
||||
$memberMap = [];
|
||||
foreach ($members as $m) {
|
||||
$memberMap[$m['userid']] = $m;
|
||||
}
|
||||
|
||||
$lines = explode("\n", trim($text));
|
||||
$recommendations = [];
|
||||
|
||||
$addedUserIds = []; // 记录已添加的用户ID,防止重复
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
|
||||
|
||||
if (preg_match('/^(\d+)\|(.+)$/', $line, $matches)) {
|
||||
$userid = intval($matches[1]);
|
||||
$reason = trim($matches[2]);
|
||||
|
||||
// 跳过已添加的用户
|
||||
if (in_array($userid, $addedUserIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($memberMap[$userid])) {
|
||||
$recommendations[] = [
|
||||
'userid' => $userid,
|
||||
'nickname' => $memberMap[$userid]['nickname'],
|
||||
'reason' => $reason,
|
||||
];
|
||||
$addedUserIds[] = $userid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($recommendations, 0, 2); // 最多2个
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Embedding 搜索相似任务
|
||||
*
|
||||
* @param array $embedding 任务内容的向量表示
|
||||
* @param int $projectId 项目ID(用于过滤同项目任务)
|
||||
* @param int $excludeTaskId 排除的任务ID(当前任务)
|
||||
* @return array 相似任务列表
|
||||
*/
|
||||
private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array
|
||||
{
|
||||
if (empty($embedding)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 ManticoreBase 进行向量搜索
|
||||
// userid=0 跳过权限过滤,我们通过 project_id 过滤
|
||||
$results = ManticoreBase::taskVectorSearch($embedding, 0, 200);
|
||||
|
||||
if (empty($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取当前任务的子任务ID列表
|
||||
$childTaskIds = ProjectTask::where('parent_id', $excludeTaskId)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
// 过滤:同项目、排除当前任务及其子任务、相似度阈值
|
||||
$similarTasks = [];
|
||||
foreach ($results as $item) {
|
||||
// 过滤不同项目的任务
|
||||
if ($item['project_id'] != $projectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 排除当前任务
|
||||
if ($item['task_id'] == $excludeTaskId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 排除子任务
|
||||
if (in_array($item['task_id'], $childTaskIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 相似度阈值
|
||||
$similarity = $item['similarity'] ?? 0;
|
||||
if ($similarity < self::SIMILAR_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$similarTasks[] = [
|
||||
'task_id' => $item['task_id'],
|
||||
'name' => $item['task_name'] ?? '',
|
||||
'similarity' => round($similarity, 2),
|
||||
];
|
||||
|
||||
// 最多返回 5 个相似任务
|
||||
if (count($similarTasks) >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $similarTasks;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('searchSimilarByEmbedding error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Markdown 消息
|
||||
* @param int $taskId 任务ID
|
||||
* @param array $suggestions 建议列表
|
||||
* @param int $msgId 消息ID
|
||||
* @param string $lang 语言代码
|
||||
*/
|
||||
public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0, string $lang = 'zh'): string
|
||||
{
|
||||
$parts = [];
|
||||
$titles = self::getLocalizedTitles($lang);
|
||||
|
||||
foreach ($suggestions as $suggestion) {
|
||||
// 如果 suggestion 中有 lang,使用它(similar 类型)
|
||||
$suggestionLang = $suggestion['lang'] ?? $lang;
|
||||
$suggestionTitles = ($suggestionLang !== $lang) ? self::getLocalizedTitles($suggestionLang) : $titles;
|
||||
|
||||
switch ($suggestion['type']) {
|
||||
case 'description':
|
||||
$parts[] = self::buildDescriptionMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
case 'subtasks':
|
||||
$parts[] = self::buildSubtasksMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
case 'assignee':
|
||||
$parts[] = self::buildAssigneeMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
case 'similar':
|
||||
$parts[] = self::buildSimilarMarkdown($taskId, $msgId, $suggestion['content'], $suggestionTitles);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n---\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建描述建议 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param string $content 描述内容
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content, array $titles): string
|
||||
{
|
||||
$title = $titles['description'];
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$content}
|
||||
|
||||
:::ai-action{type="description" task="{$taskId}" msg="{$msgId}"}:::
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建子任务建议 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param array $subtasks 子任务列表
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildSubtasksMarkdown(int $taskId, int $msgId, array $subtasks, array $titles): string
|
||||
{
|
||||
$title = $titles['subtasks'];
|
||||
$list = '';
|
||||
foreach ($subtasks as $i => $name) {
|
||||
$num = $i + 1;
|
||||
$list .= "{$num}. {$name}\n";
|
||||
}
|
||||
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$list}
|
||||
:::ai-action{type="subtasks" task="{$taskId}" msg="{$msgId}"}:::
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建负责人建议 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param array $recommendations 推荐列表
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildAssigneeMarkdown(int $taskId, int $msgId, array $recommendations, array $titles): string
|
||||
{
|
||||
$title = $titles['assignee'];
|
||||
$list = '';
|
||||
foreach ($recommendations as $rec) {
|
||||
$stUserId = $rec['userid'];
|
||||
$viewUrl = "dootask://contact/{$stUserId}";
|
||||
$list .= "- **[{$rec['nickname']}]({$viewUrl})** - {$rec['reason']} :::ai-action{type=\"assignee\" task=\"{$taskId}\" msg=\"{$msgId}\" userid=\"{$stUserId}\"}:::\n";
|
||||
}
|
||||
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$list}
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建相似任务 Markdown
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $msgId 消息ID
|
||||
* @param array $similarTasks 相似任务列表
|
||||
* @param array $titles 本地化标题
|
||||
*/
|
||||
private static function buildSimilarMarkdown(int $taskId, int $msgId, array $similarTasks, array $titles): string
|
||||
{
|
||||
$title = $titles['similar'];
|
||||
$hint = $titles['similar_hint'];
|
||||
$list = '';
|
||||
foreach ($similarTasks as $i => $st) {
|
||||
$num = $i + 1;
|
||||
$stTaskId = $st['task_id'];
|
||||
$viewUrl = "dootask://task/{$stTaskId}";
|
||||
$list .= "{$num}. **[#{$stTaskId}]({$viewUrl})** {$st['name']} :::ai-action{type=\"similar\" task=\"{$taskId}\" msg=\"{$msgId}\" related=\"{$stTaskId}\"}:::\n";
|
||||
}
|
||||
|
||||
return <<<MD
|
||||
### {$title}
|
||||
|
||||
{$hint}
|
||||
|
||||
{$list}
|
||||
MD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送建议消息
|
||||
* @param ProjectTask $task 任务对象
|
||||
* @param array $suggestions 建议列表
|
||||
*/
|
||||
public static function sendSuggestionMessage(ProjectTask $task, array $suggestions): ?int
|
||||
{
|
||||
if (empty($suggestions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果任务没有对话,自动创建
|
||||
if (!$task->dialog_id) {
|
||||
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
|
||||
if ($dialog) {
|
||||
$task->dialog_id = $dialog->id;
|
||||
$task->save();
|
||||
$task->pushMsg('dialog');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户语言
|
||||
$lang = self::getUserLanguageInfo($task->userid)['code'];
|
||||
|
||||
// 先发送消息获取 msg_id,然后更新消息内容带上 msg_id
|
||||
$tempMarkdown = self::buildMarkdownMessage($task->id, $suggestions, 0, $lang);
|
||||
$result = WebSocketDialogMsg::sendMsg(
|
||||
null,
|
||||
$task->dialog_id,
|
||||
'text',
|
||||
['text' => $tempMarkdown, 'type' => 'md'],
|
||||
self::AI_ASSISTANT_USERID,
|
||||
true, // push_self
|
||||
false, // push_retry
|
||||
true // push_silence
|
||||
);
|
||||
if (Base::isError($result)) {
|
||||
return null;
|
||||
}
|
||||
$msgId = $result['data']->id ?? 0;
|
||||
if (empty($msgId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 更新消息,带上真实的 msg_id
|
||||
$finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId, $lang);
|
||||
WebSocketDialogMsg::sendMsg(
|
||||
'change-' . $msgId,
|
||||
$task->dialog_id,
|
||||
'text',
|
||||
['text' => $finalMarkdown, 'type' => 'md'],
|
||||
self::AI_ASSISTANT_USERID,
|
||||
true, // push_self
|
||||
);
|
||||
return $msgId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息状态(采纳/忽略后)
|
||||
*
|
||||
* @param int $msgId 消息ID
|
||||
* @param int $dialogId 对话ID
|
||||
* @param string $type 建议类型
|
||||
* @param string $status 状态:applied/dismissed
|
||||
* @param int $userid 用户ID(assignee类型单独处理时使用)
|
||||
* @param int $related 关联任务ID(similar类型单独处理时使用)
|
||||
* @return array 更新后的消息数据
|
||||
*/
|
||||
public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status, int $userid = 0, int $related = 0): array
|
||||
{
|
||||
// 验证消息存在且属于指定对话
|
||||
$msg = WebSocketDialogMsg::where('id', $msgId)
|
||||
->where('dialog_id', $dialogId)
|
||||
->first();
|
||||
if (!$msg) {
|
||||
return Base::retError('消息不存在');
|
||||
}
|
||||
|
||||
$content = $msg->msg['text'] ?? '';
|
||||
if (empty($content)) {
|
||||
return Base::retError('消息内容为空');
|
||||
}
|
||||
|
||||
// 根据类型和参数构建匹配模式,添加 status 属性
|
||||
if ($type === 'assignee' && $userid > 0) {
|
||||
$pattern = '/(:::ai-action\{type="assignee"[^}]*userid="' . $userid . '"[^}]*)\}:::/';
|
||||
} elseif ($type === 'similar' && $related > 0) {
|
||||
$pattern = '/(:::ai-action\{type="similar"[^}]*related="' . $related . '"[^}]*)\}:::/';
|
||||
} else {
|
||||
$pattern = '/(:::ai-action\{type="' . preg_quote($type, '/') . '"[^}]*)\}:::/';
|
||||
}
|
||||
|
||||
$newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content);
|
||||
|
||||
// 更新消息并返回结果
|
||||
return WebSocketDialogMsg::sendMsg(
|
||||
'change-' . $msgId,
|
||||
$dialogId,
|
||||
'text',
|
||||
['text' => $newContent, 'type' => 'md'],
|
||||
self::AI_ASSISTANT_USERID
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Services\RequestContext;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use App\Module\Base;
|
||||
use App\Module\Ihttp;
|
||||
|
||||
class Apps
|
||||
{
|
||||
@@ -22,7 +26,7 @@ class Apps
|
||||
|
||||
$key = 'app_installed_' . $appId;
|
||||
if (RequestContext::has($key)) {
|
||||
return RequestContext::get($key);
|
||||
return (bool) RequestContext::get($key, false);
|
||||
}
|
||||
|
||||
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
|
||||
@@ -44,17 +48,84 @@ class Apps
|
||||
{
|
||||
if (!self::isInstalled($appId)) {
|
||||
$name = match ($appId) {
|
||||
'ai' => 'AI Robot',
|
||||
'ai' => 'AI Assistant',
|
||||
'face' => 'Face check-in',
|
||||
'appstore' => 'AppStore',
|
||||
'approve' => 'Approval',
|
||||
'office' => 'OnlyOffice',
|
||||
'drawio' => 'Drawio',
|
||||
'minder' => 'Minder',
|
||||
'search' => 'ZincSearch',
|
||||
'manticore' => 'Manticore Search',
|
||||
default => $appId,
|
||||
};
|
||||
throw new ApiException("应用「{$name}」未安装", [], 0, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
|
||||
*
|
||||
* @param User $user 用户对象
|
||||
* @param string $action Hook 动作: user_onboard, user_offboard, user_update
|
||||
* @param string $eventType 事件类型: onboard, restore, offboarded, delete, profile_update, admin_update
|
||||
* @param array $changedFields 变更字段列表(仅 user_update 时有值)
|
||||
*/
|
||||
public static function dispatchUserHook(User $user, string $action, string $eventType = '', array $changedFields = []): void
|
||||
{
|
||||
$appKey = env('APP_KEY', '');
|
||||
if (empty($appKey)) {
|
||||
info('[appstore_hook] APP_KEY is empty, skip dispatchUserHook');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户部门信息
|
||||
$departments = [];
|
||||
if (!empty($user->department)) {
|
||||
$deptIds = is_array($user->department)
|
||||
? $user->department
|
||||
: array_filter(explode(',', $user->department));
|
||||
if (!empty($deptIds)) {
|
||||
$deptList = UserDepartment::whereIn('id', $deptIds)->get(['id', 'name']);
|
||||
foreach ($deptList as $dept) {
|
||||
$departments[] = [
|
||||
'id' => (string) $dept->id,
|
||||
'name' => (string) $dept->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$url = sprintf('http://appstore/api/v1/internal/hooks/%s', $action);
|
||||
$payload = [
|
||||
'user' => [
|
||||
'id' => (string) $user->userid,
|
||||
'email' => (string) $user->email,
|
||||
'name' => (string) $user->nickname,
|
||||
'role' => $user->isAdmin() ? 'admin' : 'normal',
|
||||
'tel' => (string) ($user->tel ?? ''),
|
||||
'profession' => (string) ($user->profession ?? ''),
|
||||
'birthday' => $user->birthday ? (string) $user->birthday : '',
|
||||
'address' => (string) ($user->address ?? ''),
|
||||
'introduction' => (string) ($user->introduction ?? ''),
|
||||
'departments' => $departments,
|
||||
],
|
||||
'event_type' => $eventType,
|
||||
'changed_fields' => $changedFields,
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . md5($appKey),
|
||||
'Version' => Base::getVersion(),
|
||||
];
|
||||
|
||||
$resp = Ihttp::ihttp_request($url, json_encode($payload, JSON_UNESCAPED_UNICODE), $headers, 5);
|
||||
if (Base::isError($resp)) {
|
||||
info('[appstore_hook] dispatch fail', [
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'error' => $resp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use Overtrue\Pinyin\Pinyin;
|
||||
use Redirect;
|
||||
use Request;
|
||||
use Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Validator;
|
||||
@@ -848,6 +848,13 @@ class Base
|
||||
*/
|
||||
public static function getSchemeAndHost()
|
||||
{
|
||||
// 优先用当前请求的协议+主机:getScheme() 会经 TrustProxies 采信 X-Forwarded-Proto,
|
||||
// 从而正确识别 https;host 取自 Host 头(不信 X-Forwarded-Host,避免 Host 注入)
|
||||
$request = request();
|
||||
if ($request && $request->getHttpHost()) {
|
||||
return $request->getSchemeAndHttpHost();
|
||||
}
|
||||
// 非请求上下文(Task/命令行等)的兜底
|
||||
$scheme = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://';
|
||||
return $scheme.($_SERVER['HTTP_HOST'] ?? '');
|
||||
}
|
||||
@@ -1301,7 +1308,7 @@ class Base
|
||||
/**
|
||||
* 获取或设置
|
||||
* @param $setname // 配置名称
|
||||
* @param bool $array // 保存内容
|
||||
* @param bool|array $array // 保存内容
|
||||
* @param bool $isUpdate // 保存内容为更新模式,默认否
|
||||
* @return array
|
||||
*/
|
||||
@@ -1404,11 +1411,13 @@ class Base
|
||||
*/
|
||||
public static function ajaxError($msg, $data = [], $ret = 0, $abortCode = 404)
|
||||
{
|
||||
if (Request::header('Content-Type') === 'application/json') {
|
||||
return Base::retError($msg, $data, $ret);
|
||||
} else {
|
||||
abort($abortCode, $msg);
|
||||
if (Request::header('Content-Type') !== 'application/json') {
|
||||
$translateMsg = Doo::translate($msg);
|
||||
abort($abortCode, $translateMsg, [
|
||||
'X-Error-Message-Base64' => base64_encode($translateMsg),
|
||||
]);
|
||||
}
|
||||
return Base::retError($msg, $data, $ret);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1825,6 +1834,19 @@ class Base
|
||||
return $platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是PC端(包括 Electron 桌面端和 Web 浏览器)
|
||||
* @param string|null $platform 平台类型,不传则自动获取
|
||||
* @return bool
|
||||
*/
|
||||
public static function isPc($platform = null)
|
||||
{
|
||||
if ($platform === null) {
|
||||
$platform = self::platform();
|
||||
}
|
||||
return in_array($platform, ['win', 'mac', 'web']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是App移动端
|
||||
* @return bool
|
||||
@@ -1858,12 +1880,22 @@ class Base
|
||||
* 获取每页数量
|
||||
* @param $max
|
||||
* @param $default
|
||||
* @param string $inputName
|
||||
* @param string|array $inputName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getPaginate($max, $default, $inputName = 'pagesize')
|
||||
public static function getPaginate($max, $default, $inputName = ['pagesize', 'take'])
|
||||
{
|
||||
return Min(Max(Base::nullShow(Request::input($inputName), $default), 1), $max);
|
||||
$value = null;
|
||||
if (!is_array($inputName)) {
|
||||
$inputName = [$inputName];
|
||||
}
|
||||
foreach ($inputName as $name) {
|
||||
if (Request::exists($name)) {
|
||||
$value = Request::input($name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Min(Max(Base::nullShow($value, $default), 1), $max);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2793,14 +2825,17 @@ class Base
|
||||
|
||||
/**
|
||||
* 字节转格式
|
||||
* @param $bytes
|
||||
* @param int|float $bytes
|
||||
* @return string
|
||||
*/
|
||||
public static function readableBytes($bytes)
|
||||
public static function readableBytes(int|float $bytes): string
|
||||
{
|
||||
$i = floor(log($bytes) / log(1024));
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$i = (int) floor(log($bytes) / log(1024));
|
||||
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
return sprintf('%.02F', $bytes / pow(1024, $i)) * 1 . ' ' . $sizes[$i];
|
||||
return (string) ((float) sprintf('%.02F', $bytes / pow(1024, $i))) . ' ' . $sizes[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2843,9 +2878,15 @@ class Base
|
||||
|
||||
/**
|
||||
* DownloadFileResponse 下载文件
|
||||
*
|
||||
* 返回 Symfony BinaryFileResponse,在 LaravelS/Swoole 环境下由 StaticResponse 走原生
|
||||
* sendfile() 发送——OS 级零拷贝、不占用 PHP 内存,可支持任意大小文件(如几百 MB 的大文件)。
|
||||
* 切勿改回 StreamedResponse:它会被 LaravelS 用 ob_start()/ob_get_clean() 把整个响应体
|
||||
* 缓冲进 PHP 内存,大文件会撞 memory_limit 导致下载失败。
|
||||
*
|
||||
* @param File|\SplFileInfo|string $file 文件对象或路径
|
||||
* @param string|null $name 下载文件名
|
||||
* @return StreamedResponse
|
||||
* @return BinaryFileResponse
|
||||
*/
|
||||
public static function DownloadFileResponse($file, $name = null)
|
||||
{
|
||||
@@ -2864,12 +2905,6 @@ class Base
|
||||
throw new FileException('File must be readable and exist.');
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
$size = $file->getSize();
|
||||
if ($size === false || $size < 0) {
|
||||
throw new FileException('Unable to determine file size.');
|
||||
}
|
||||
|
||||
// 处理文件名
|
||||
if (empty($name)) {
|
||||
$name = basename($file->getPathname());
|
||||
@@ -2887,83 +2922,27 @@ class Base
|
||||
$mimeType = 'application/octet-stream';
|
||||
}
|
||||
|
||||
// 处理 Range 请求
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
$length = $size;
|
||||
$isRangeRequest = false;
|
||||
// BinaryFileResponse:autoEtag=false 避免对大文件做 md5/sha1 全文件哈希,autoLastModified=true 取 mtime(开销极小)
|
||||
$response = new BinaryFileResponse($file, 200, [], true, null, false, true);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('Cache-Control', 'private, no-transform, no-store, must-revalidate, max-age=0');
|
||||
// filename 兜底为纯 ASCII,filename* 用 UTF-8 编码,兼容含中文/特殊字符的文件名
|
||||
$asciiName = preg_replace('/[^\x20-\x7e]/', '_', $name);
|
||||
$response->headers->set('Content-Disposition', sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$asciiName,
|
||||
rawurlencode($name)
|
||||
));
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
$range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
|
||||
if (preg_match('/^(\d+)-(\d*)$/', $range, $matches)) {
|
||||
$start = intval($matches[1]);
|
||||
$end = !empty($matches[2]) ? intval($matches[2]) : $size - 1;
|
||||
|
||||
// 验证范围的有效性
|
||||
if ($start >= 0 && $end < $size && $start <= $end) {
|
||||
$length = $end - $start + 1;
|
||||
$isRangeRequest = true;
|
||||
} else {
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
}
|
||||
}
|
||||
// LaravelS/Swoole 下 StaticResponse 用 sendfile() 整文件发送,不支持分段;
|
||||
// 若放任 Symfony 处理 Range 会返回 206 头却仍发送完整文件,导致内容错位/损坏。
|
||||
// 故在 Swoole 环境下移除 Range 请求头,始终以 200 返回完整文件。
|
||||
if (app()->bound('swoole')) {
|
||||
Request::instance()->headers->remove('Range');
|
||||
$response->headers->set('Accept-Ranges', 'none');
|
||||
}
|
||||
|
||||
// 设置基本响应头
|
||||
$headers = [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Disposition' => sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$name,
|
||||
rawurlencode($name)
|
||||
),
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Cache-Control' => 'private, no-transform, no-store, must-revalidate, max-age=0',
|
||||
'Content-Length' => $length,
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $file->getMTime()) . ' GMT',
|
||||
'ETag' => sprintf('"%s"', md5_file($file->getPathname()))
|
||||
];
|
||||
|
||||
if ($isRangeRequest) {
|
||||
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
|
||||
$statusCode = 206;
|
||||
} else {
|
||||
$statusCode = 200;
|
||||
}
|
||||
|
||||
// 创建流式响应
|
||||
return new StreamedResponse(
|
||||
function () use ($file, $start, $length) {
|
||||
$handle = fopen($file->getPathname(), 'rb');
|
||||
if ($handle === false) {
|
||||
throw new FileException('Cannot open file for reading');
|
||||
}
|
||||
|
||||
if (fseek($handle, $start) === -1) {
|
||||
fclose($handle);
|
||||
throw new FileException('Cannot seek to position ' . $start);
|
||||
}
|
||||
|
||||
$remaining = $length;
|
||||
$bufferSize = 8192; // 8KB chunks
|
||||
|
||||
while ($remaining > 0 && !feof($handle)) {
|
||||
$readSize = min($bufferSize, $remaining);
|
||||
$buffer = fread($handle, $readSize);
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
}
|
||||
echo $buffer;
|
||||
flush();
|
||||
$remaining -= strlen($buffer);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
},
|
||||
$statusCode,
|
||||
$headers
|
||||
);
|
||||
return $response;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('File download failed', [
|
||||
'error' => $e->getMessage(),
|
||||
@@ -3040,7 +3019,7 @@ class Base
|
||||
{
|
||||
try {
|
||||
$converter = new CommonMarkConverter();
|
||||
return $converter->convert($markdown);
|
||||
return $converter->convert($markdown)->getContent();
|
||||
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
|
||||
return $markdown;
|
||||
}
|
||||
@@ -3049,15 +3028,73 @@ class Base
|
||||
/**
|
||||
* html 转 MD(markdown)
|
||||
* @param $html
|
||||
* @param array $options
|
||||
* @return mixed|string
|
||||
*/
|
||||
public static function html2markdown($html)
|
||||
public static function html2markdown($html, $options = [])
|
||||
{
|
||||
try {
|
||||
$converter = new HtmlConverter();
|
||||
$converter = new HtmlConverter($options);
|
||||
return $converter->convert($html);
|
||||
} catch (\Exception) {
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时读取 .env 配置(不受配置缓存影响)
|
||||
* @param string $key 配置键名
|
||||
* @param mixed $default 默认值
|
||||
* @return mixed
|
||||
*/
|
||||
public static function liveEnv($key, $default = null)
|
||||
{
|
||||
$envFile = base_path('.env');
|
||||
if (!file_exists($envFile)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$envContent = file_get_contents($envFile);
|
||||
$lines = explode("\n", $envContent);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
// 跳过注释和空行
|
||||
if (empty($line) || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析 KEY=VALUE
|
||||
if (str_contains($line, '=')) {
|
||||
[$envKey, $envValue] = explode('=', $line, 2);
|
||||
$envKey = trim($envKey);
|
||||
|
||||
if ($envKey === $key) {
|
||||
$envValue = trim($envValue);
|
||||
|
||||
// 移除引号
|
||||
if (preg_match('/^(["\'])(.*)\1$/', $envValue, $matches)) {
|
||||
$envValue = $matches[2];
|
||||
}
|
||||
|
||||
// 处理布尔值
|
||||
$lowerValue = strtolower($envValue);
|
||||
if ($lowerValue === 'true') {
|
||||
return true;
|
||||
}
|
||||
if ($lowerValue === 'false') {
|
||||
return false;
|
||||
}
|
||||
if ($lowerValue === 'null' || $lowerValue === '(null)') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $envValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
83
app/Module/ClientContext.php
Normal file
83
app/Module/ClientContext.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
/**
|
||||
* 客户端上下文
|
||||
*/
|
||||
class ClientContext
|
||||
{
|
||||
public array $context = [];
|
||||
public float $createdAt = 0;
|
||||
public float $updatedAt = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = microtime(true);
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置上下文
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
$this->context[$key] = $value;
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置上下文
|
||||
* @param array $data
|
||||
* @return void
|
||||
*/
|
||||
public function setMultiple(array $data): void
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
$this->context[$key] = $value;
|
||||
}
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上下文
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->context[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断上下文是否存在
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return isset($this->context[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新上下文
|
||||
* @return void
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->updatedAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除上下文
|
||||
* @return void
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->context = [];
|
||||
}
|
||||
}
|
||||
@@ -2,73 +2,40 @@
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use FFI;
|
||||
use App\Module\Interface\DooSo;
|
||||
use App\Services\RequestContext;
|
||||
|
||||
class Doo
|
||||
{
|
||||
private static $doo;
|
||||
private static $userLanguage = "";
|
||||
private const DOO_INSTANCE = 'doo_instance';
|
||||
private const DOO_LANGUAGE = 'doo_language';
|
||||
|
||||
/**
|
||||
* char转为字符串
|
||||
* @param $text
|
||||
* @return string
|
||||
*/
|
||||
private static function string($text): string
|
||||
{
|
||||
return FFI::string($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 装载
|
||||
* 加载Doo实例
|
||||
* - 如果已经存在,则直接返回
|
||||
* - 否则,创建一个新的FFI实例,并初始化
|
||||
* @param $token
|
||||
* @param $language
|
||||
* @return DooSo
|
||||
*/
|
||||
public static function load($token = null, $language = null)
|
||||
public static function load($token = null, $language = null): DooSo
|
||||
{
|
||||
self::$doo = FFI::cdef(<<<EOF
|
||||
void initialize(char* work, char* token, char* lang);
|
||||
char* license();
|
||||
char* licenseDecode(char* license);
|
||||
char* licenseSave(char* license);
|
||||
int userId();
|
||||
char* userExpiredAt();
|
||||
char* userEmail();
|
||||
char* userEncrypt();
|
||||
char* userToken();
|
||||
char* userCreate(char* email, char* password);
|
||||
char* tokenEncode(int userid, char* email, char* encrypt, int days);
|
||||
char* tokenDecode(char* val);
|
||||
char* translate(char* val, char* val);
|
||||
char* md5s(char* text, char* password);
|
||||
char* macs();
|
||||
char* dooSN();
|
||||
char* version();
|
||||
char* pgpGenerateKeyPair(char* name, char* email, char* passphrase);
|
||||
char* pgpEncrypt(char* plainText, char* publicKey);
|
||||
char* pgpDecrypt(char* cipherText, char* privateKey, char* passphrase);
|
||||
EOF, "/usr/lib/doo/doo.so");
|
||||
$token = $token ?: Base::token();
|
||||
$language = $language ?: Base::headerOrInput('language');
|
||||
self::$doo->initialize("/var/www", $token, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例
|
||||
* @param $token
|
||||
* @param $language
|
||||
* @return mixed
|
||||
*/
|
||||
public static function doo($token = null, $language = null)
|
||||
{
|
||||
if (self::$doo == null) {
|
||||
self::load($token, $language);
|
||||
if (RequestContext::has(self::DOO_INSTANCE)) {
|
||||
return RequestContext::get(self::DOO_INSTANCE);
|
||||
}
|
||||
return self::$doo;
|
||||
|
||||
$request = request();
|
||||
if ($request && method_exists($request, 'header')) {
|
||||
$token = $token ?: Base::token();
|
||||
$language = $language ?: Base::headerOrInput('language');
|
||||
}
|
||||
$instance = new DooSo($token, $language);
|
||||
|
||||
RequestContext::set(self::DOO_INSTANCE, $instance);
|
||||
RequestContext::set(self::DOO_LANGUAGE, $language);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,41 +44,7 @@ class Doo
|
||||
*/
|
||||
public static function license(): array
|
||||
{
|
||||
$array = Base::json2array(self::string(self::doo()->license()));
|
||||
|
||||
$ips = explode(",", $array['ip']);
|
||||
$array['ip'] = [];
|
||||
foreach ($ips as $ip) {
|
||||
if (Base::is_ipv4($ip)) {
|
||||
$array['ip'][] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
$domains = explode(",", $array['domain']);
|
||||
$array['domain'] = [];
|
||||
foreach ($domains as $domain) {
|
||||
if (Base::is_domain($domain)) {
|
||||
$array['domain'][] = $domain;
|
||||
}
|
||||
}
|
||||
|
||||
$macs = explode(",", $array['mac']);
|
||||
$array['mac'] = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array['mac'][] = $mac;
|
||||
}
|
||||
}
|
||||
|
||||
$emails = explode(",", $array['email']);
|
||||
$array['email'] = [];
|
||||
foreach ($emails as $email) {
|
||||
if (Base::isEmail($email)) {
|
||||
$array['email'][] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
return self::load()->license();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,26 +72,13 @@ class Doo
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析License
|
||||
* @param $license
|
||||
* @return array
|
||||
*/
|
||||
public static function licenseDecode($license): array
|
||||
{
|
||||
return Base::json2array(self::string(self::doo()->licenseDecode($license)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存License
|
||||
* @param $license
|
||||
*/
|
||||
public static function licenseSave($license): void
|
||||
{
|
||||
$res = self::string(self::doo()->licenseSave($license));
|
||||
if ($res != 'success') {
|
||||
throw new ApiException($res ?: 'LICENSE 保存失败');
|
||||
}
|
||||
self::load()->licenseSave($license);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +87,7 @@ class Doo
|
||||
*/
|
||||
public static function userId(): int
|
||||
{
|
||||
return intval(self::doo()->userId());
|
||||
return self::load()->userId();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,8 +96,7 @@ class Doo
|
||||
*/
|
||||
public static function userExpired(): bool
|
||||
{
|
||||
$expiredAt = self::userExpiredAt();
|
||||
return $expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now());
|
||||
return self::load()->userExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,8 +105,7 @@ class Doo
|
||||
*/
|
||||
public static function userExpiredAt(): ?string
|
||||
{
|
||||
$expiredAt = self::string(self::doo()->userExpiredAt());
|
||||
return $expiredAt === 'forever' ? null : $expiredAt;
|
||||
return self::load()->userExpiredAt();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +114,7 @@ class Doo
|
||||
*/
|
||||
public static function userEmail(): string
|
||||
{
|
||||
return self::string(self::doo()->userEmail());
|
||||
return self::load()->userEmail();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,7 +123,7 @@ class Doo
|
||||
*/
|
||||
public static function userEncrypt(): string
|
||||
{
|
||||
return self::string(self::doo()->userEncrypt());
|
||||
return self::load()->userEncrypt();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +132,7 @@ class Doo
|
||||
*/
|
||||
public static function userToken(): string
|
||||
{
|
||||
return self::string(self::doo()->userToken());
|
||||
return self::load()->userToken();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,23 +143,7 @@ class Doo
|
||||
*/
|
||||
public static function userCreate($email, $password): User|null
|
||||
{
|
||||
$data = Base::json2array(self::string(self::doo()->userCreate($email, $password)));
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg'] ?: '注册失败');
|
||||
}
|
||||
if (\DB::transactionLevel() > 0) {
|
||||
try {
|
||||
\DB::commit();
|
||||
\DB::beginTransaction();
|
||||
} catch (\Throwable) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
throw new ApiException('注册失败');
|
||||
}
|
||||
return $user;
|
||||
return self::load()->userCreate($email, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,7 +156,7 @@ class Doo
|
||||
*/
|
||||
public static function tokenEncode($userid, $email, $encrypt, int $days = 15): string
|
||||
{
|
||||
return self::string(self::doo()->tokenEncode($userid, $email, $encrypt, $days));
|
||||
return self::load()->tokenEncode($userid, $email, $encrypt, $days);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,40 +166,42 @@ class Doo
|
||||
*/
|
||||
public static function tokenDecode($token): array
|
||||
{
|
||||
$array = Base::json2array(self::string(self::doo()->tokenDecode($token)));
|
||||
$array['expired_at'] = $array['expired_at'] === 'forever' ? null : $array['expired_at'];
|
||||
return $array;
|
||||
return self::load()->tokenDecode($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译
|
||||
* @param $text
|
||||
* @param string $lang
|
||||
* @param ?string $lang
|
||||
* @return string
|
||||
*/
|
||||
public static function translate($text, string $lang = ""): string
|
||||
public static function translate($text, ?string $lang = ""): string
|
||||
{
|
||||
return self::string(self::doo()->translate($text, $lang ?: self::$userLanguage));
|
||||
if (empty($lang)) {
|
||||
$lang = RequestContext::get(self::DOO_LANGUAGE);
|
||||
}
|
||||
return self::load()->translate($text, $lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置语言
|
||||
* @param string|integer $lang 语言 或 会员ID
|
||||
* @param int|string $lang 语言 或 会员ID
|
||||
* @return void
|
||||
*/
|
||||
public static function setLanguage($lang) {
|
||||
public static function setLanguage(int|string $lang): void
|
||||
{
|
||||
if (Base::isNumber($lang)) {
|
||||
$lang = User::find(intval($lang))?->lang ?: "";
|
||||
}
|
||||
self::$userLanguage = $lang;
|
||||
RequestContext::set(self::DOO_LANGUAGE, $lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言列表 或 语言名称
|
||||
* @param string|false $lang
|
||||
* @param bool|string $lang
|
||||
* @return string|string[]
|
||||
*/
|
||||
public static function getLanguages($lang = false)
|
||||
public static function getLanguages(bool|string $lang = false): array|string
|
||||
{
|
||||
$array = [
|
||||
"zh" => "简体中文",
|
||||
@@ -334,7 +238,7 @@ class Doo
|
||||
*/
|
||||
public static function md5s($text, string $password = ""): string
|
||||
{
|
||||
return self::string(self::doo()->md5s($text, $password));
|
||||
return self::load()->md5s($text, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,14 +247,7 @@ class Doo
|
||||
*/
|
||||
public static function macs(): array
|
||||
{
|
||||
$macs = explode(",", self::string(self::doo()->macs()));
|
||||
$array = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array[] = $mac;
|
||||
}
|
||||
}
|
||||
return $array;
|
||||
return self::load()->macs();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,7 +256,7 @@ class Doo
|
||||
*/
|
||||
public static function dooSN(): string
|
||||
{
|
||||
return self::string(self::doo()->dooSN());
|
||||
return self::load()->dooSN();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,7 +265,7 @@ class Doo
|
||||
*/
|
||||
public static function dooVersion(): string
|
||||
{
|
||||
return self::string(self::doo()->version());
|
||||
return self::load()->dooVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,7 +277,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpGenerateKeyPair($name, $email, string $passphrase = ""): array
|
||||
{
|
||||
return Base::json2array(self::string(self::doo()->pgpGenerateKeyPair($name, $email, $passphrase)));
|
||||
return self::load()->pgpGenerateKeyPair($name, $email, $passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,11 +288,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpEncrypt($plaintext, $publicKey): string
|
||||
{
|
||||
if (strlen($publicKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $publicKey));
|
||||
$publicKey = $keyCache['public_key'];
|
||||
}
|
||||
return self::string(self::doo()->pgpEncrypt($plaintext, $publicKey));
|
||||
return self::load()->pgpEncrypt($plaintext, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,12 +300,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpDecrypt($encryptedText, $privateKey, $passphrase = null): string
|
||||
{
|
||||
if (strlen($privateKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $privateKey));
|
||||
$privateKey = $keyCache['private_key'];
|
||||
$passphrase = $keyCache['passphrase'];
|
||||
}
|
||||
return self::string(self::doo()->pgpDecrypt($encryptedText, $privateKey, $passphrase));
|
||||
return self::load()->pgpDecrypt($encryptedText, $privateKey, $passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,9 +311,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpEncryptApi($plaintext, $publicKey): string
|
||||
{
|
||||
$content = Base::array2json($plaintext);
|
||||
$content = self::pgpEncrypt($content, $publicKey);
|
||||
return preg_replace("/\s*-----(BEGIN|END) PGP MESSAGE-----\s*/i", "", $content);
|
||||
return self::load()->pgpEncryptApi($plaintext, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,9 +323,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpDecryptApi($encryptedText, $privateKey, $passphrase = null): array
|
||||
{
|
||||
$content = "-----BEGIN PGP MESSAGE-----\n\n" . $encryptedText . "\n-----END PGP MESSAGE-----";
|
||||
$content = self::pgpDecrypt($content, $privateKey, $passphrase);
|
||||
return Base::json2array($content);
|
||||
return self::load()->pgpDecryptApi($encryptedText, $privateKey, $passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,24 +333,7 @@ class Doo
|
||||
*/
|
||||
public static function pgpParseStr($string): array
|
||||
{
|
||||
$array = [
|
||||
'encrypt_type' => '',
|
||||
'encrypt_id' => '',
|
||||
'client_type' => '',
|
||||
'client_key' => '',
|
||||
];
|
||||
$string = str_replace(";", "&", $string);
|
||||
parse_str($string, $params);
|
||||
foreach ($params as $key => $value) {
|
||||
$key = strtolower(trim($key));
|
||||
if ($key) {
|
||||
$array[$key] = trim($value);
|
||||
}
|
||||
}
|
||||
if ($array['client_type'] === 'pgp' && $array['client_key']) {
|
||||
$array['client_key'] = self::pgpPublicFormat($array['client_key']);
|
||||
}
|
||||
return $array;
|
||||
return self::load()->pgpParseStr($string);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,10 +343,6 @@ class Doo
|
||||
*/
|
||||
public static function pgpPublicFormat($key): string
|
||||
{
|
||||
$key = str_replace(["-", "_", "$"], ["+", "/", "\n"], $key);
|
||||
if (!str_contains($key, '-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
|
||||
$key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" . $key . "\n-----END PGP PUBLIC KEY BLOCK-----";
|
||||
}
|
||||
return $key;
|
||||
return self::load()->pgpPublicFormat($key);
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Module/Down.php
Normal file
37
app/Module/Down.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use Request;
|
||||
use Cache;
|
||||
|
||||
class Down
|
||||
{
|
||||
/**
|
||||
* @param $data
|
||||
* @param null $ttl
|
||||
* @return string
|
||||
*/
|
||||
public static function cache_encode($data, $ttl = null): string
|
||||
{
|
||||
$base64 = base64_encode(Base::array2string($data));
|
||||
$key = md5($base64);
|
||||
Cache::put("down::{$key}", $base64, $ttl ?: now()->addHour());
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $inputName
|
||||
* @return array
|
||||
*/
|
||||
public static function cache_decode(?string $inputName = 'key'): array
|
||||
{
|
||||
$key = Request::input($inputName);
|
||||
$base64 = Cache::get("down::{$key}");
|
||||
if (empty($base64)) {
|
||||
return Base::ajaxError("请求已过期,请重新导出!", [], 0, 403);
|
||||
}
|
||||
//
|
||||
return Base::string2array(base64_decode($base64));
|
||||
}
|
||||
}
|
||||
@@ -4,319 +4,12 @@ namespace App\Module;
|
||||
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* 外网资源请求
|
||||
*/
|
||||
class Extranet
|
||||
{
|
||||
/**
|
||||
* 通过 openAI 语音转文字
|
||||
* @param string $filePath
|
||||
* @param array $extParams
|
||||
* @return array
|
||||
*/
|
||||
public static function openAItranscriptions($filePath, $extParams = [])
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return Base::retError("语音文件不存在");
|
||||
}
|
||||
$systemSetting = Base::setting('system');
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
if ($systemSetting['voice2text'] !== 'open' || empty($aibotSetting['openai_key'])) {
|
||||
return Base::retError("语音转文字功能未开启");
|
||||
}
|
||||
$extra = [
|
||||
'Content-Type' => 'multipart/form-data',
|
||||
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
|
||||
];
|
||||
if ($aibotSetting['openai_agency']) {
|
||||
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
|
||||
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
|
||||
}
|
||||
$post = array_merge($extParams, [
|
||||
'file' => new \CURLFile($filePath),
|
||||
'model' => 'whisper-1',
|
||||
]);
|
||||
$cacheKey = "openAItranscriptions::" . md5($filePath . '_' . Base::array2json($extra) . '_' . Base::array2json($extParams));
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) {
|
||||
$res = Ihttp::ihttp_request('https://api.openai.com/v1/audio/transcriptions', $post, $extra, 15);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("语音转文字失败", $res);
|
||||
}
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['text'])) {
|
||||
return Base::retError("语音转文字失败", $resData);
|
||||
}
|
||||
return Base::retSuccess("success", $resData['text']);
|
||||
});
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 翻译
|
||||
* @param $text
|
||||
* @param $targetLanguage
|
||||
* @return array
|
||||
*/
|
||||
public static function openAItranslations($text, $targetLanguage)
|
||||
{
|
||||
$systemSetting = Base::setting('system');
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
if ($systemSetting['translation'] !== 'open' || empty($aibotSetting['openai_key'])) {
|
||||
return Base::retError("翻译功能未开启");
|
||||
}
|
||||
$extra = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
|
||||
];
|
||||
if ($aibotSetting['openai_agency']) {
|
||||
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
|
||||
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
|
||||
}
|
||||
$post = json_encode([
|
||||
"model" => "gpt-4o-mini",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一名专业翻译人员,请将 <translation_original_text> 标签内的内容翻译为{$targetLanguage}。
|
||||
|
||||
翻译要求:
|
||||
- 翻译结果需符合“项目任务管理系统”的专业术语和使用场景。
|
||||
- 保持原文格式、结构和排版不变。
|
||||
- 语言表达准确、简洁,符合项目管理领域的行业规范。
|
||||
- 注意专业术语的一致性和连贯性。
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => "<translation_original_text>{$text}</translation_original_text>"
|
||||
]
|
||||
]
|
||||
]);
|
||||
$cacheKey = "openAItranslations::" . md5(Base::array2json($extra) . '_' . Base::array2json($post));
|
||||
$result = Cache::remember($cacheKey, Carbon::now()->addDays(), function() use ($extra, $post) {
|
||||
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', $post, $extra, 15);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("翻译失败", $res);
|
||||
}
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['choices'])) {
|
||||
return Base::retError("翻译失败", $resData);
|
||||
}
|
||||
$result = $resData['choices'][0]['message']['content'];
|
||||
$result = preg_replace('/^\"|\"$/', '', trim($result));
|
||||
$result = preg_replace('/<\/*translation_original_text>/', '', trim($result));
|
||||
if (empty($result)) {
|
||||
return Base::retError("翻译失败", $result);
|
||||
}
|
||||
return Base::retSuccess("success", $result);
|
||||
});
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成标题
|
||||
* @param $text
|
||||
* @return array
|
||||
*/
|
||||
public static function openAIGenerateTitle($text)
|
||||
{
|
||||
$aibotSetting = Base::setting('aibotSetting');
|
||||
if (empty($aibotSetting['openai_key'])) {
|
||||
return Base::retError("AI接口未配置");
|
||||
}
|
||||
$extra = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $aibotSetting['openai_key'],
|
||||
];
|
||||
if ($aibotSetting['openai_agency']) {
|
||||
$extra['CURLOPT_PROXY'] = $aibotSetting['openai_agency'];
|
||||
$extra['CURLOPT_PROXYTYPE'] = str_contains($aibotSetting['openai_agency'], 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
|
||||
}
|
||||
$res = Ihttp::ihttp_request('https://api.openai.com/v1/chat/completions', json_encode([
|
||||
"model" => "gpt-4o-mini",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => "你是一个专业的标题生成器,擅长为对话生成简洁的标题,请将提供的文本生成一个标题。"
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => $text
|
||||
]
|
||||
]
|
||||
]), $extra, 15);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("生成失败", $res);
|
||||
}
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['choices'])) {
|
||||
return Base::retError("生成失败", $resData);
|
||||
}
|
||||
$result = $resData['choices'][0]['message']['content'];
|
||||
$result = preg_replace('/^\"|\"$/', '', $result);
|
||||
if (empty($result)) {
|
||||
return Base::retError("生成失败", $result);
|
||||
}
|
||||
return Base::retSuccess("success", $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ollama 模型
|
||||
* @param $baseUrl
|
||||
* @param $key
|
||||
* @param $agency
|
||||
* @return array
|
||||
*/
|
||||
public static function ollamaModels($baseUrl, $key = null, $agency = null)
|
||||
{
|
||||
$extra = [
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
if ($key) {
|
||||
$extra['Authorization'] = 'Bearer ' . $key;
|
||||
}
|
||||
if ($agency) {
|
||||
$extra['CURLOPT_PROXY'] = $agency;
|
||||
$extra['CURLOPT_PROXYTYPE'] = str_contains($agency, 'socks') ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP;
|
||||
}
|
||||
$res = Ihttp::ihttp_request(rtrim($baseUrl, '/') . '/api/tags', [], $extra, 15);
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("获取失败", $res);
|
||||
}
|
||||
$resData = Base::json2array($res['data']);
|
||||
if (empty($resData['models'])) {
|
||||
return Base::retError("获取失败", $resData);
|
||||
}
|
||||
$models = [];
|
||||
foreach ($resData['models'] as $model) {
|
||||
if ($model['name'] !== $model['model']) {
|
||||
$models[] = "{$model['model']} | {$model['name']}";
|
||||
} else {
|
||||
$models[] = $model['model'];
|
||||
}
|
||||
}
|
||||
return Base::retSuccess("success", [
|
||||
'models' => $models,
|
||||
'original' => $resData['models']
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取IP地址经纬度
|
||||
* @param string $ip
|
||||
* @return array
|
||||
*/
|
||||
public static function getIpGcj02(string $ip = ''): array
|
||||
{
|
||||
if (empty($ip)) {
|
||||
$ip = Base::getIp();
|
||||
}
|
||||
$cacheKey = "getIpPoint::" . md5($ip);
|
||||
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
|
||||
return Ihttp::ihttp_request("https://www.ifreesite.com/ipaddress/address.php?q=" . $ip, [], [], 12);
|
||||
});
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
return $result;
|
||||
}
|
||||
$data = $result['data'];
|
||||
$lastPos = strrpos($data, ',');
|
||||
$long = floatval(Base::getMiddle(substr($data, $lastPos + 1), null, ')'));
|
||||
$lat = floatval(Base::getMiddle(substr($data, strrpos(substr($data, 0, $lastPos), ',') + 1), null, ','));
|
||||
return Base::retSuccess("success", [
|
||||
'long' => $long,
|
||||
'lat' => $lat,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 百度接口:根据ip获取经纬度
|
||||
* @param string $ip
|
||||
* @return array
|
||||
*/
|
||||
public static function getIpGcj02ByBaidu(string $ip = ''): array
|
||||
{
|
||||
if (empty($ip)) {
|
||||
$ip = Base::getIp();
|
||||
}
|
||||
|
||||
$cacheKey = "getIpPoint::" . md5($ip);
|
||||
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
|
||||
$ak = Config::get('app.baidu_app_key');
|
||||
$url = 'http://api.map.baidu.com/location/ip?ak=' . $ak . '&ip=' . $ip . '&coor=bd09ll';
|
||||
return Ihttp::ihttp_request($url, [], [], 12);
|
||||
});
|
||||
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
return $result;
|
||||
}
|
||||
$data = json_decode($result['data'], true);
|
||||
|
||||
// x坐标纬度, y坐标经度
|
||||
$long = Arr::get($data, 'content.point.x');
|
||||
$lat = Arr::get($data, 'content.point.y');
|
||||
return Base::retSuccess("success", [
|
||||
'long' => $long,
|
||||
'lat' => $lat,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取IP地址详情
|
||||
* @param string $ip
|
||||
* @return array
|
||||
*/
|
||||
public static function getIpInfo(string $ip = ''): array
|
||||
{
|
||||
if (empty($ip)) {
|
||||
$ip = Base::getIp();
|
||||
}
|
||||
$cacheKey = "getIpInfo::" . md5($ip);
|
||||
$result = Cache::rememberForever($cacheKey, function () use ($ip) {
|
||||
return Ihttp::ihttp_request("http://ip.taobao.com/service/getIpInfo.php?accessKey=alibaba-inc&ip=" . $ip, [], [], 12);
|
||||
});
|
||||
if (Base::isError($result)) {
|
||||
Cache::forget($cacheKey);
|
||||
return $result;
|
||||
}
|
||||
$data = json_decode($result['data'], true);
|
||||
if (!is_array($data) || intval($data['code']) != 0) {
|
||||
Cache::forget($cacheKey);
|
||||
return Base::retError("error ip: -1");
|
||||
}
|
||||
$data = $data['data'];
|
||||
if (!is_array($data) || !isset($data['country'])) {
|
||||
return Base::retError("error ip: -2");
|
||||
}
|
||||
$data['text'] = $data['country'];
|
||||
$data['textSmall'] = $data['country'];
|
||||
if ($data['region'] && $data['region'] != $data['country'] && $data['region'] != "XX") {
|
||||
$data['text'] .= " " . $data['region'];
|
||||
$data['textSmall'] = $data['region'];
|
||||
}
|
||||
if ($data['city'] && $data['city'] != $data['region'] && $data['city'] != "XX") {
|
||||
$data['text'] .= " " . $data['city'];
|
||||
$data['textSmall'] .= " " . $data['city'];
|
||||
}
|
||||
if ($data['county'] && $data['county'] != $data['city'] && $data['county'] != "XX") {
|
||||
$data['text'] .= " " . $data['county'];
|
||||
$data['textSmall'] .= " " . $data['county'];
|
||||
}
|
||||
return Base::retSuccess("success", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否工作日
|
||||
* @param string $Ymd 年月日(如:20220102)
|
||||
@@ -372,125 +65,6 @@ class Extranet
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机笑话接口
|
||||
* @return string
|
||||
*/
|
||||
public static function randJoke(): string
|
||||
{
|
||||
$data = self::curl("https://hmajax.itheima.net/api/randjoke");
|
||||
$data = Base::json2array($data);
|
||||
if ($data['message'] === '获取成功' && $text = trim($data['data'])) {
|
||||
return $text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 心灵鸡汤
|
||||
* @return string
|
||||
*/
|
||||
public static function soups(): string
|
||||
{
|
||||
$data = self::curl("https://hmajax.itheima.net/api/ambition");
|
||||
$data = Base::json2array($data);
|
||||
if ($data['message'] === '获取成功' && $text = trim($data['data'])) {
|
||||
return $text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到机器人网络内容
|
||||
* @param $type
|
||||
* @return string
|
||||
*/
|
||||
public static function checkinBotQuickMsg($type): string
|
||||
{
|
||||
$text = "维护中...";
|
||||
switch ($type) {
|
||||
case "it":
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/hotlist?type=itNews', 3600);
|
||||
if ($data = Base::json2array($data)) {
|
||||
$i = 1;
|
||||
$array = array_map(function ($item) use (&$i) {
|
||||
if ($item['title'] && $item['desc']) {
|
||||
return "<p>" . ($i++) . ". <strong><a href='{$item['mobilUrl']}' target='_blank'>{$item['title']}</a></strong></p><p>{$item['desc']}</p>";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, $data['data']);
|
||||
$array = array_values(array_filter($array));
|
||||
if ($array) {
|
||||
array_unshift($array, "<p><strong>{$data['title']}</strong>({$data['update_time']})</p>");
|
||||
$text = implode("<p> </p>", $array);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "36ke":
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/hotlist?type=36Ke', 3600);
|
||||
if ($data = Base::json2array($data)) {
|
||||
$i = 1;
|
||||
$array = array_map(function ($item) use (&$i) {
|
||||
if ($item['title'] && $item['desc']) {
|
||||
return "<p>" . ($i++) . ". <strong><a href='{$item['mobilUrl']}' target='_blank'>{$item['title']}</a></strong></p><p>{$item['desc']}</p>";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, $data['data']);
|
||||
$array = array_values(array_filter($array));
|
||||
if ($array) {
|
||||
array_unshift($array, "<p><strong>{$data['title']}</strong>({$data['update_time']})</p>");
|
||||
$text = implode("<p> </p>", $array);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "60s":
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/60s?type=json', 3600);
|
||||
if ($data = Base::json2array($data)) {
|
||||
$i = 1;
|
||||
$array = array_map(function ($item) use (&$i) {
|
||||
if ($item) {
|
||||
return "<p>" . ($i++) . ". {$item}</p>";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, $data['data']);
|
||||
$array = array_values(array_filter($array));
|
||||
if ($array) {
|
||||
array_unshift($array, "<p><strong>{$data['name']}</strong>({$data['time'][0]})</p>");
|
||||
$text = implode("<p> </p>", $array);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "joke":
|
||||
$text = "笑话被掏空";
|
||||
$data = self::curl('http://vvhan.api.hitosea.com/api/joke?type=json', 5);
|
||||
if ($data = Base::json2array($data)) {
|
||||
if ($data = trim($data['joke'])) {
|
||||
$text = "开心笑话:{$data}";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "soup":
|
||||
$text = "鸡汤分完了";
|
||||
$data = self::curl('https://api.ayfre.com/jt/?type=bot', 5);
|
||||
if ($data) {
|
||||
$text = "心灵鸡汤:{$data}";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$text = "";
|
||||
break;
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取搜狗表情包
|
||||
* @param $keyword
|
||||
|
||||
412
app/Module/Interface/DooSo.php
Normal file
412
app/Module/Interface/DooSo.php
Normal file
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Interface;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Module\Base;
|
||||
use App\Models\User;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use FFI;
|
||||
use FFI\CData;
|
||||
use FFI\Exception;
|
||||
use Throwable;
|
||||
|
||||
class DooSo
|
||||
{
|
||||
private mixed $so;
|
||||
|
||||
public function __construct($token = null, $language = null)
|
||||
{
|
||||
$this->so = FFI::cdef(<<<EOF
|
||||
void initialize(char* work, char* token, char* lang);
|
||||
char* license();
|
||||
char* licenseDecode(char* license);
|
||||
char* licenseSave(char* license);
|
||||
int userId();
|
||||
char* userExpiredAt();
|
||||
char* userEmail();
|
||||
char* userEncrypt();
|
||||
char* userToken();
|
||||
char* userCreate(char* email, char* password);
|
||||
char* tokenEncode(int userid, char* email, char* encrypt, int days);
|
||||
char* tokenDecode(char* val);
|
||||
char* translate(char* val, char* val);
|
||||
char* md5s(char* text, char* password);
|
||||
char* macs();
|
||||
char* dooSN();
|
||||
char* version();
|
||||
char* pgpGenerateKeyPair(char* name, char* email, char* passphrase);
|
||||
char* pgpEncrypt(char* plainText, char* publicKey);
|
||||
char* pgpDecrypt(char* cipherText, char* privateKey, char* passphrase);
|
||||
EOF, "/usr/lib/doo/doo.so");
|
||||
$this->so->initialize("/var/www", $token, $language);
|
||||
return $this->so;
|
||||
}
|
||||
|
||||
/**
|
||||
* char转为字符串
|
||||
* @param $text
|
||||
* @return string
|
||||
*/
|
||||
private static function string($text): string
|
||||
{
|
||||
if (!($text instanceof CData)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return FFI::string($text);
|
||||
} catch (Exception) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* License
|
||||
* @return array
|
||||
*/
|
||||
public function license(): array
|
||||
{
|
||||
$array = Base::json2array(self::string($this->so->license()));
|
||||
|
||||
$ips = explode(",", $array['ip']);
|
||||
$array['ip'] = [];
|
||||
foreach ($ips as $ip) {
|
||||
if (Base::is_ipv4($ip)) {
|
||||
$array['ip'][] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
$domains = explode(",", $array['domain']);
|
||||
$array['domain'] = [];
|
||||
foreach ($domains as $domain) {
|
||||
if (Base::is_domain($domain)) {
|
||||
$array['domain'][] = $domain;
|
||||
}
|
||||
}
|
||||
|
||||
$macs = explode(",", $array['mac']);
|
||||
$array['mac'] = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array['mac'][] = $mac;
|
||||
}
|
||||
}
|
||||
|
||||
$emails = explode(",", $array['email']);
|
||||
$array['email'] = [];
|
||||
foreach ($emails as $email) {
|
||||
if (Base::isEmail($email)) {
|
||||
$array['email'][] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析License
|
||||
* @param $license
|
||||
* @return array
|
||||
*/
|
||||
public function licenseDecode($license): array
|
||||
{
|
||||
return Base::json2array(self::string($this->so->licenseDecode($license)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存License
|
||||
* @param $license
|
||||
*/
|
||||
public function licenseSave($license): void
|
||||
{
|
||||
$res = self::string($this->so->licenseSave($license));
|
||||
if ($res != 'success') {
|
||||
throw new ApiException($res ?: 'LICENSE 保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员ID(来自请求的token)
|
||||
* @return int
|
||||
*/
|
||||
public function userId(): int
|
||||
{
|
||||
return intval($this->so->userId());
|
||||
}
|
||||
|
||||
/**
|
||||
* token是否过期(来自请求的token)
|
||||
* @return bool
|
||||
*/
|
||||
public function userExpired(): bool
|
||||
{
|
||||
$expiredAt = $this->userExpiredAt();
|
||||
return $expiredAt && Carbon::parse($expiredAt)->isBefore(Carbon::now());
|
||||
}
|
||||
|
||||
/**
|
||||
* token过期时间(来自请求的token)
|
||||
* @return string|null
|
||||
*/
|
||||
public function userExpiredAt(): ?string
|
||||
{
|
||||
$expiredAt = self::string($this->so->userExpiredAt());
|
||||
return $expiredAt === 'forever' ? null : $expiredAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员邮箱地址(来自请求的token)
|
||||
* @return string
|
||||
*/
|
||||
public function userEmail(): string
|
||||
{
|
||||
return self::string($this->so->userEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员Encrypt(来自请求的token)
|
||||
* @return string
|
||||
*/
|
||||
public function userEncrypt(): string
|
||||
{
|
||||
return self::string($this->so->userEncrypt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员token(来自请求的token)
|
||||
* @return string
|
||||
*/
|
||||
public function userToken(): string
|
||||
{
|
||||
return self::string($this->so->userToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建帐号
|
||||
* @param $email
|
||||
* @param $password
|
||||
* @return User|null
|
||||
*/
|
||||
public function userCreate($email, $password): User|null
|
||||
{
|
||||
$data = Base::json2array(self::string($this->so->userCreate($email, $password)));
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg'] ?: '注册失败');
|
||||
}
|
||||
if (DB::transactionLevel() > 0) {
|
||||
try {
|
||||
DB::commit();
|
||||
DB::beginTransaction();
|
||||
} catch (Throwable) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (empty($user)) {
|
||||
throw new ApiException('注册失败');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成token(编码token)
|
||||
* @param $userid
|
||||
* @param $email
|
||||
* @param $encrypt
|
||||
* @param int $days 有效时间(天)
|
||||
* @return string
|
||||
*/
|
||||
public function tokenEncode($userid, $email, $encrypt, int $days = 15): string
|
||||
{
|
||||
return self::string($this->so->tokenEncode($userid, $email, $encrypt, $days));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码token
|
||||
* @param $token
|
||||
* @return array
|
||||
*/
|
||||
public function tokenDecode($token): array
|
||||
{
|
||||
$array = Base::json2array(self::string($this->so->tokenDecode($token)));
|
||||
$array['expired_at'] = $array['expired_at'] === 'forever' ? null : $array['expired_at'];
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译
|
||||
* @param $text
|
||||
* @param ?string $lang
|
||||
* @return string
|
||||
*/
|
||||
public function translate($text, ?string $lang = ""): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return "";
|
||||
}
|
||||
if (empty($lang)) {
|
||||
$lang = "";
|
||||
}
|
||||
return self::string($this->so->translate($text, $lang));
|
||||
}
|
||||
|
||||
/**
|
||||
* md5防破解
|
||||
* @param $text
|
||||
* @param string $password
|
||||
* @return string
|
||||
*/
|
||||
public function md5s($text, string $password = ""): string
|
||||
{
|
||||
return self::string($this->so->md5s($text, $password));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取php容器mac地址组
|
||||
* @return array
|
||||
*/
|
||||
public function macs(): array
|
||||
{
|
||||
$macs = explode(",", self::string($this->so->macs()));
|
||||
$array = [];
|
||||
foreach ($macs as $mac) {
|
||||
if (Base::isMac($mac)) {
|
||||
$array[] = $mac;
|
||||
}
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前SN
|
||||
* @return string
|
||||
*/
|
||||
public function dooSN(): string
|
||||
{
|
||||
return self::string($this->so->dooSN());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前版本
|
||||
* @return string
|
||||
*/
|
||||
public function dooVersion(): string
|
||||
{
|
||||
return self::string($this->so->version());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成PGP密钥对
|
||||
* @param $name
|
||||
* @param $email
|
||||
* @param string $passphrase
|
||||
* @return array
|
||||
*/
|
||||
public function pgpGenerateKeyPair($name, $email, string $passphrase = ""): array
|
||||
{
|
||||
return Base::json2array(self::string($this->so->pgpGenerateKeyPair($name, $email, $passphrase)));
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP加密
|
||||
* @param $plaintext
|
||||
* @param $publicKey
|
||||
* @return string
|
||||
*/
|
||||
public function pgpEncrypt($plaintext, $publicKey): string
|
||||
{
|
||||
if (strlen($publicKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $publicKey));
|
||||
$publicKey = $keyCache['public_key'];
|
||||
}
|
||||
return self::string($this->so->pgpEncrypt($plaintext, $publicKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP解密
|
||||
* @param $encryptedText
|
||||
* @param $privateKey
|
||||
* @param null $passphrase
|
||||
* @return string
|
||||
*/
|
||||
public function pgpDecrypt($encryptedText, $privateKey, $passphrase = null): string
|
||||
{
|
||||
if (strlen($privateKey) < 50) {
|
||||
$keyCache = Base::json2array(Cache::get("KeyPair::" . $privateKey));
|
||||
$privateKey = $keyCache['private_key'];
|
||||
$passphrase = $keyCache['passphrase'];
|
||||
}
|
||||
return self::string($this->so->pgpDecrypt($encryptedText, $privateKey, $passphrase));
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP加密API
|
||||
* @param $plaintext
|
||||
* @param $publicKey
|
||||
* @return string
|
||||
*/
|
||||
public function pgpEncryptApi($plaintext, $publicKey): string
|
||||
{
|
||||
$content = Base::array2json($plaintext);
|
||||
$content = $this->pgpEncrypt($content, $publicKey);
|
||||
return preg_replace("/\s*-----(BEGIN|END) PGP MESSAGE-----\s*/i", "", $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* PGP解密API
|
||||
* @param $encryptedText
|
||||
* @param null $privateKey
|
||||
* @param null $passphrase
|
||||
* @return array
|
||||
*/
|
||||
public function pgpDecryptApi($encryptedText, $privateKey, $passphrase = null): array
|
||||
{
|
||||
$content = "-----BEGIN PGP MESSAGE-----\n\n" . $encryptedText . "\n-----END PGP MESSAGE-----";
|
||||
$content = $this->pgpDecrypt($content, $privateKey, $passphrase);
|
||||
return Base::json2array($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析PGP参数
|
||||
* @param $string
|
||||
* @return string[]
|
||||
*/
|
||||
public function pgpParseStr($string): array
|
||||
{
|
||||
$array = [
|
||||
'encrypt_type' => '',
|
||||
'encrypt_id' => '',
|
||||
'client_type' => '',
|
||||
'client_key' => '',
|
||||
];
|
||||
$string = str_replace(";", "&", $string);
|
||||
parse_str($string, $params);
|
||||
foreach ($params as $key => $value) {
|
||||
$key = strtolower(trim($key));
|
||||
if ($key) {
|
||||
$array[$key] = trim($value);
|
||||
}
|
||||
}
|
||||
if ($array['client_type'] === 'pgp' && $array['client_key']) {
|
||||
$array['client_key'] = $this->pgpPublicFormat($array['client_key']);
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原公钥格式
|
||||
* @param $key
|
||||
* @return string
|
||||
*/
|
||||
public function pgpPublicFormat($key): string
|
||||
{
|
||||
$key = str_replace(["-", "_", "$"], ["+", "/", "\n"], $key);
|
||||
if (!str_contains($key, '-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
|
||||
$key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" . $key . "\n-----END PGP PUBLIC KEY BLOCK-----";
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
2129
app/Module/Manticore/ManticoreBase.php
Normal file
2129
app/Module/Manticore/ManticoreBase.php
Normal file
File diff suppressed because it is too large
Load Diff
670
app/Module/Manticore/ManticoreFile.php
Normal file
670
app/Module/Manticore/ManticoreFile.php
Normal file
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\FileUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\TextExtractor;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 文件搜索类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索文件: search($userid, $keyword, $searchType, $from, $size);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(File $file);
|
||||
* - 批量同步: batchSync($files);
|
||||
* - 删除索引: delete($fileId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新权限: updateAllowedUsers($fileId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreFile
|
||||
{
|
||||
/**
|
||||
* 可搜索的文件类型
|
||||
*/
|
||||
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
|
||||
|
||||
/**
|
||||
* 最大内容长度(字符)- 提取后的文本内容限制
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
|
||||
|
||||
/**
|
||||
* 不同文件类型的最大大小限制(字节)
|
||||
*/
|
||||
public const MAX_FILE_SIZE = [
|
||||
'office' => 50 * 1024 * 1024, // 50MB - Office 文件图片占空间大但文本少
|
||||
'text' => 5 * 1024 * 1024, // 5MB - 纯文本文件
|
||||
'other' => 20 * 1024 * 1024, // 20MB - PDF 等其他文件
|
||||
];
|
||||
|
||||
/**
|
||||
* Office 文件扩展名
|
||||
*/
|
||||
public const OFFICE_EXTENSIONS = [
|
||||
'doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf',
|
||||
'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv',
|
||||
'ppt', 'pptx', 'pps', 'ppsx', 'odp', 'otp'
|
||||
];
|
||||
|
||||
/**
|
||||
* 纯文本文件扩展名
|
||||
*/
|
||||
public const TEXT_EXTENSIONS = [
|
||||
'txt', 'md', 'text', 'log', 'json', 'xml', 'html', 'htm', 'css', 'js', 'ts',
|
||||
'php', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'rb', 'sh', 'bash', 'sql',
|
||||
'yaml', 'yml', 'ini', 'conf', 'vue', 'jsx', 'tsx'
|
||||
];
|
||||
|
||||
/**
|
||||
* 搜索文件(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
// 未安装 Manticore,降级到 MySQL LIKE 搜索
|
||||
return self::searchByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
// 纯全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
// 纯向量搜索(需要先获取 embedding)
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
// embedding 获取失败,降级到全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::vectorSearch($embedding, $userid, $size)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
// 混合搜索
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::hybridSearch($keyword, $embedding, $userid, $size)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore search error: ' . $e->getMessage());
|
||||
return self::searchByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'id' => $item['file_id'],
|
||||
'file_id' => $item['file_id'],
|
||||
'name' => $item['file_name'],
|
||||
'type' => $item['file_type'],
|
||||
'ext' => $item['file_ext'],
|
||||
'userid' => $item['userid'],
|
||||
'content_preview' => isset($item['content']) ? mb_substr($item['content'], 0, 500) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 降级搜索(仅搜索文件名)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
private static function searchByMysql(int $userid, string $keyword, int $from, int $size): array
|
||||
{
|
||||
// 搜索用户自己的文件
|
||||
$builder = File::where('userid', $userid)
|
||||
->where('name', 'like', "%{$keyword}%")
|
||||
->where('type', '!=', 'folder');
|
||||
|
||||
$results = $builder->skip($from)->take($size)->get();
|
||||
|
||||
return $results->map(function ($file) {
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'file_id' => $file->id,
|
||||
'name' => $file->name,
|
||||
'type' => $file->type,
|
||||
'ext' => $file->ext,
|
||||
'userid' => $file->userid,
|
||||
'content_preview' => null,
|
||||
'relevance' => 0,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限计算方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取文件的 allowed_users 列表
|
||||
*
|
||||
* 有权限查看此文件的用户列表:
|
||||
* - 文件所有者 (userid)
|
||||
* - 共享用户(FileUser 表中的 userid)
|
||||
* - userid=0 表示公开共享
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(File $file): array
|
||||
{
|
||||
$userids = [$file->userid]; // 所有者
|
||||
|
||||
// 获取共享用户(包括 userid=0 表示公开)
|
||||
$shareUsers = FileUser::where('file_id', $file->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
return array_unique(array_merge($userids, $shareUsers));
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个文件到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(File $file, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 不处理文件夹
|
||||
if ($file->type === 'folder') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据文件类型检查大小限制
|
||||
$maxSize = self::getMaxFileSizeByExt($file->ext);
|
||||
if ($file->size > $maxSize) {
|
||||
// 删除可能存在的旧索引(文件更新后可能超限)
|
||||
self::delete($file->id);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 提取文件内容
|
||||
$content = self::extractFileContent($file);
|
||||
|
||||
// 限制提取后的内容长度
|
||||
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && Apps::isInstalled('ai')) {
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($vectorContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($file);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertFileVector([
|
||||
'file_id' => $file->id,
|
||||
'userid' => $file->userid,
|
||||
'pshare' => $file->pshare ?? 0,
|
||||
'file_name' => $file->name,
|
||||
'file_type' => $file->type,
|
||||
'file_ext' => $file->ext,
|
||||
'content' => $content,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore sync error: ' . $e->getMessage(), [
|
||||
'file_id' => $file->id,
|
||||
'file_name' => $file->name,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取最大文件大小限制
|
||||
*
|
||||
* @param string|null $ext 文件扩展名
|
||||
* @return int 最大文件大小(字节)
|
||||
*/
|
||||
private static function getMaxFileSizeByExt(?string $ext): int
|
||||
{
|
||||
$ext = strtolower($ext ?? '');
|
||||
|
||||
if (in_array($ext, self::OFFICE_EXTENSIONS)) {
|
||||
return self::MAX_FILE_SIZE['office'];
|
||||
}
|
||||
|
||||
if (in_array($ext, self::TEXT_EXTENSIONS)) {
|
||||
return self::MAX_FILE_SIZE['text'];
|
||||
}
|
||||
|
||||
return self::MAX_FILE_SIZE['other'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有文件类型中的最大文件大小限制
|
||||
*
|
||||
* @return int 最大文件大小(字节)
|
||||
*/
|
||||
public static function getMaxFileSize(): int
|
||||
{
|
||||
return max(self::MAX_FILE_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步文件
|
||||
*
|
||||
* @param iterable $files 文件列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $files, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($files as $file) {
|
||||
if (self::sync($file, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件索引
|
||||
*
|
||||
* @param int $fileId 文件ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $fileId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteFileVector($fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件内容(支持分页)
|
||||
*
|
||||
* @param File|string $fileOrPath 文件模型 或 文件路径/URL
|
||||
* @param int $offset 起始位置(字符数),默认 0
|
||||
* @param int $limit 获取长度(字符数),默认 50000,最大 200000
|
||||
* @return array 包含 content, total_length, offset, limit, has_more, 或 error
|
||||
*/
|
||||
public static function extractFileContentPaginated(File|string $fileOrPath, int $offset = 0, int $limit = 50000): array
|
||||
{
|
||||
$offset = max(0, $offset);
|
||||
$limit = min(max(1, $limit), 200000);
|
||||
|
||||
// 根据参数类型获取完整内容
|
||||
if ($fileOrPath instanceof File) {
|
||||
if ($fileOrPath->type === 'folder') {
|
||||
return ['error' => '文件夹无法提取内容'];
|
||||
}
|
||||
$fullContent = self::extractFileContent($fileOrPath);
|
||||
} else {
|
||||
$fullContent = self::extractFileContentFromPath($fileOrPath);
|
||||
if (is_array($fullContent)) {
|
||||
return $fullContent; // 返回错误信息
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($fullContent)) {
|
||||
return ['error' => '无法提取文件内容'];
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
$totalLength = mb_strlen($fullContent);
|
||||
|
||||
if ($offset >= $totalLength) {
|
||||
return [
|
||||
'content' => '',
|
||||
'total_length' => $totalLength,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'has_more' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$content = mb_substr($fullContent, $offset, $limit);
|
||||
$hasMore = ($offset + mb_strlen($content)) < $totalLength;
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'total_length' => $totalLength,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'has_more' => $hasMore,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径/URL 提取完整内容
|
||||
* @return string|array 内容字符串,或错误数组
|
||||
*/
|
||||
private static function extractFileContentFromPath(string $pathOrUrl): string|array
|
||||
{
|
||||
// 从 URL 中提取相对路径
|
||||
if (str_starts_with($pathOrUrl, 'http://') || str_starts_with($pathOrUrl, 'https://')) {
|
||||
$parsed = parse_url($pathOrUrl);
|
||||
$pathOrUrl = ltrim($parsed['path'] ?? '', '/');
|
||||
}
|
||||
if (preg_match('/^.*?(uploads\/.*)$/', $pathOrUrl, $matches)) {
|
||||
$pathOrUrl = $matches[1];
|
||||
}
|
||||
|
||||
// 安全检查:只允许 uploads 目录
|
||||
if (!str_starts_with($pathOrUrl, 'uploads/')) {
|
||||
return ['error' => '不支持的文件路径'];
|
||||
}
|
||||
|
||||
return self::extractFromPath($pathOrUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件内容(内部使用,返回完整内容)
|
||||
*
|
||||
* @param File $file 文件模型
|
||||
* @return string 文件内容文本
|
||||
*/
|
||||
private static function extractFileContent(File $file): string
|
||||
{
|
||||
// 1. 先尝试从 FileContent 的 text 字段获取(已提取的文本内容)
|
||||
$fileContent = FileContent::where('fid', $file->id)->orderByDesc('id')->first();
|
||||
if (!$fileContent) {
|
||||
return '';
|
||||
}
|
||||
if (!empty($fileContent->text)) {
|
||||
return $fileContent->text;
|
||||
}
|
||||
|
||||
// 2. 尝试从 FileContent 的 content 字段获取
|
||||
if (!empty($fileContent->content)) {
|
||||
$contentData = Base::json2array($fileContent->content);
|
||||
|
||||
// 2.1 某些文件类型直接存储内容
|
||||
if (!empty($contentData['content']) && is_string($contentData['content'])) {
|
||||
return $contentData['content'];
|
||||
}
|
||||
|
||||
// 2.2 通过路径提取
|
||||
$filePath = $contentData['url'] ?? null;
|
||||
if ($filePath && str_starts_with($filePath, 'uploads/')) {
|
||||
$result = self::extractFromPath($filePath);
|
||||
if (is_string($result)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径提取内容(核心方法)
|
||||
* @return string|array 内容字符串,或错误数组
|
||||
*/
|
||||
private static function extractFromPath(string $relativePath): string|array
|
||||
{
|
||||
$fullPath = public_path($relativePath);
|
||||
if (!file_exists($fullPath)) {
|
||||
return ['error' => '文件不存在'];
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||
$maxFileSize = self::getMaxFileSizeByExt($ext);
|
||||
|
||||
$result = TextExtractor::extractFile(
|
||||
$fullPath,
|
||||
(int) ($maxFileSize / 1024),
|
||||
(int) (self::MAX_CONTENT_LENGTH / 1024)
|
||||
);
|
||||
|
||||
if (!Base::isSuccess($result)) {
|
||||
return ['error' => $result['msg'] ?? '无法提取文件内容'];
|
||||
}
|
||||
|
||||
return $result['data'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用于生成向量的内容
|
||||
* 包含文件名和文件内容,确保语义搜索能匹配文件名
|
||||
*
|
||||
* @param string $fileName 文件名
|
||||
* @param string $content 文件内容
|
||||
* @return string 用于生成向量的文本
|
||||
*/
|
||||
private static function buildVectorContent(string $fileName, string $content): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($fileName)) {
|
||||
$parts[] = $fileName;
|
||||
}
|
||||
if (!empty($content)) {
|
||||
$parts[] = $content;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllFileVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引文件数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedFileCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新文件的 allowed_users 权限列表
|
||||
* 从 MySQL 获取最新的共享用户并更新到 Manticore
|
||||
*
|
||||
* @param int $fileId 文件ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function updateAllowedUsers(int $fileId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $fileId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$file = File::find($fileId);
|
||||
if (!$file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userids = self::getAllowedUsers($file);
|
||||
return ManticoreBase::updateFileAllowedUsers($fileId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['file_id' => $fileId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成文件向量
|
||||
* 用于后台异步处理,将已索引文件的向量批量生成
|
||||
*
|
||||
* @param array $fileIds 文件ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $fileIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($fileIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询文件信息
|
||||
$files = File::whereIn('id', $fileIds)
|
||||
->where('type', '!=', 'folder')
|
||||
->get();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个文件的内容(包含文件名)
|
||||
$fileContents = [];
|
||||
foreach ($files as $file) {
|
||||
// 检查文件大小限制
|
||||
$maxSize = self::getMaxFileSizeByExt($file->ext);
|
||||
if ($file->size > $maxSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = self::extractFileContent($file);
|
||||
// 向量内容包含文件名和文件内容
|
||||
$vectorContent = self::buildVectorContent($file->name, $content);
|
||||
if (!empty($vectorContent)) {
|
||||
// 限制内容长度
|
||||
$vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
$fileContents[$file->id] = $vectorContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($fileContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($fileContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $fileId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$fileId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateFileVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
139
app/Module/Manticore/ManticoreKeyValue.php
Normal file
139
app/Module/Manticore/ManticoreKeyValue.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Module\Apps;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 键值存储类
|
||||
*
|
||||
* 用于存储同步进度等配置信息
|
||||
*/
|
||||
class ManticoreKeyValue
|
||||
{
|
||||
/**
|
||||
* 获取值
|
||||
*
|
||||
* @param string $key 键
|
||||
* @param mixed $default 默认值
|
||||
* @return mixed 值
|
||||
*/
|
||||
public static function get(string $key, $default = null)
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
$result = $instance->queryOne(
|
||||
"SELECT v FROM key_values WHERE k = ?",
|
||||
[$key]
|
||||
);
|
||||
|
||||
return $result ? $result['v'] : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置值
|
||||
*
|
||||
* @param string $key 键
|
||||
* @param mixed $value 值
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function set(string $key, $value): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
|
||||
// 先删除已存在的记录
|
||||
$instance->execute("DELETE FROM key_values WHERE k = ?", [$key]);
|
||||
|
||||
// 生成唯一 ID(基于 key 的 hash)
|
||||
$id = abs(crc32($key));
|
||||
|
||||
// 插入新记录
|
||||
return $instance->execute(
|
||||
"INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)",
|
||||
[$id, $key, (string)$value]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除值
|
||||
*
|
||||
* @param string $key 键
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(string $key): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
return $instance->execute("DELETE FROM key_values WHERE k = ?", [$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有键值
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
return $instance->execute("TRUNCATE TABLE key_values");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查键是否存在
|
||||
*
|
||||
* @param string $key 键
|
||||
* @return bool 是否存在
|
||||
*/
|
||||
public static function exists(string $key): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
$result = $instance->queryOne(
|
||||
"SELECT id FROM key_values WHERE k = ?",
|
||||
[$key]
|
||||
);
|
||||
|
||||
return $result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有键值对
|
||||
*
|
||||
* @return array 键值对数组
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$instance = new ManticoreBase();
|
||||
$results = $instance->query("SELECT k, v FROM key_values");
|
||||
|
||||
$data = [];
|
||||
foreach ($results as $row) {
|
||||
$data[$row['k']] = $row['v'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
561
app/Module/Manticore/ManticoreMsg.php
Normal file
561
app/Module/Manticore/ManticoreMsg.php
Normal file
@@ -0,0 +1,561 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 消息搜索类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索消息: search($userid, $keyword, $searchType, $from, $size);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(WebSocketDialogMsg $msg);
|
||||
* - 批量同步: batchSync($msgs);
|
||||
* - 删除索引: delete($msgId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新对话权限: updateDialogAllowedUsers($dialogId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
* - 判断是否索引: shouldIndex($msg);
|
||||
*/
|
||||
class ManticoreMsg
|
||||
{
|
||||
/**
|
||||
* 可索引的消息类型
|
||||
*/
|
||||
public const INDEXABLE_TYPES = ['text', 'file', 'record', 'meeting', 'vote'];
|
||||
|
||||
/**
|
||||
* 最大内容长度(字符)
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
|
||||
|
||||
/**
|
||||
* 判断消息是否应该被索引
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @return bool 是否应该索引
|
||||
*/
|
||||
public static function shouldIndex(WebSocketDialogMsg $msg): bool
|
||||
{
|
||||
// 1. 排除机器人消息
|
||||
if ($msg->bot === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查消息类型
|
||||
if (!in_array($msg->type, self::INDEXABLE_TYPES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 排除 key 为空的消息
|
||||
if (empty($msg->key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索消息(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @param int $dialogId 对话ID(0表示不限制)
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20, int $dialogId = 0): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
// 纯全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgFullTextSearch($keyword, $userid, $size, $from, $dialogId)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
// 纯向量搜索(需要先获取 embedding)
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
// embedding 获取失败,降级到全文搜索
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgFullTextSearch($keyword, $userid, $size, $from, $dialogId)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgVectorSearch($embedding, $userid, $size, $dialogId)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
// 混合搜索
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::msgHybridSearch($keyword, $embedding, $userid, $size, $dialogId)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore msg search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'id' => $item['msg_id'],
|
||||
'msg_id' => $item['msg_id'],
|
||||
'dialog_id' => $item['dialog_id'],
|
||||
'userid' => $item['userid'],
|
||||
'msg_type' => $item['msg_type'],
|
||||
'content_preview' => isset($item['content']) ? mb_substr($item['content'], 0, 200) : null,
|
||||
'created_at' => $item['created_at'] ?? null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按对话搜索消息(用于对话列表搜索)
|
||||
*
|
||||
* 返回包含匹配消息的对话列表,每个对话只返回一次
|
||||
* 当 Manticore 未安装时,回退到 MySQL LIKE 搜索
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 对话列表
|
||||
*/
|
||||
public static function searchDialogs(int $userid, string $keyword, int $from = 0, int $size = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 未安装 Manticore 时使用 MySQL 回退搜索
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return self::searchDialogsByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用全文搜索获取更多结果,然后按对话分组
|
||||
$results = ManticoreBase::msgFullTextSearch($keyword, $userid, 100, 0);
|
||||
|
||||
if (empty($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 收集所有对话ID
|
||||
$dialogIds = array_unique(array_column($results, 'dialog_id'));
|
||||
|
||||
// 获取用户在这些对话中的信息
|
||||
$dialogUsers = WebSocketDialogUser::where('userid', $userid)
|
||||
->whereIn('dialog_id', $dialogIds)
|
||||
->get()
|
||||
->keyBy('dialog_id');
|
||||
|
||||
// 按对话分组,每个对话只保留最相关的消息
|
||||
$msgs = [];
|
||||
$seenDialogs = [];
|
||||
foreach ($results as $item) {
|
||||
$dialogId = $item['dialog_id'];
|
||||
|
||||
// 每个对话只取第一条(最相关的)
|
||||
if (isset($seenDialogs[$dialogId])) {
|
||||
continue;
|
||||
}
|
||||
$seenDialogs[$dialogId] = true;
|
||||
|
||||
// 获取用户在该对话的信息
|
||||
$dialogUser = $dialogUsers->get($dialogId);
|
||||
if (!$dialogUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$msgs[] = [
|
||||
'id' => $dialogId,
|
||||
'search_msg_id' => $item['msg_id'],
|
||||
'user_at' => $dialogUser->updated_at ? Carbon::parse($dialogUser->updated_at)->format('Y-m-d H:i:s') : null,
|
||||
'mark_unread' => $dialogUser->mark_unread,
|
||||
'silence' => $dialogUser->silence,
|
||||
'hide' => $dialogUser->hide,
|
||||
'color' => $dialogUser->color,
|
||||
'top_at' => $dialogUser->top_at ? Carbon::parse($dialogUser->top_at)->format('Y-m-d H:i:s') : null,
|
||||
'last_at' => $dialogUser->last_at ? Carbon::parse($dialogUser->last_at)->format('Y-m-d H:i:s') : null,
|
||||
];
|
||||
|
||||
// 已达到需要的数量
|
||||
if (count($msgs) >= $from + $size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用分页
|
||||
return array_slice($msgs, $from, $size);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore searchDialogs error: ' . $e->getMessage());
|
||||
// 出错时回退到 MySQL 搜索
|
||||
return self::searchDialogsByMysql($userid, $keyword, $from, $size);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 回退搜索(按对话搜索消息)
|
||||
*
|
||||
* 通过联表查询获取用户有权限的对话中匹配的消息
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param int $from 起始位置
|
||||
* @param int $size 返回数量
|
||||
* @return array 对话列表
|
||||
*/
|
||||
private static function searchDialogsByMysql(int $userid, string $keyword, int $from = 0, int $size = 20): array
|
||||
{
|
||||
$items = DB::table('web_socket_dialog_users as u')
|
||||
->select([
|
||||
'd.*',
|
||||
'u.top_at',
|
||||
'u.last_at',
|
||||
'u.mark_unread',
|
||||
'u.silence',
|
||||
'u.hide',
|
||||
'u.color',
|
||||
'u.updated_at as user_at',
|
||||
'm.id as search_msg_id'
|
||||
])
|
||||
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
|
||||
->join('web_socket_dialog_msgs as m', 'm.dialog_id', '=', 'd.id')
|
||||
->where('u.userid', $userid)
|
||||
->where('m.bot', 0)
|
||||
->whereNull('d.deleted_at')
|
||||
->where('m.key', 'like', "%{$keyword}%")
|
||||
->orderByDesc('m.id')
|
||||
->offset($from)
|
||||
->limit($size)
|
||||
->get()
|
||||
->all();
|
||||
|
||||
$msgs = [];
|
||||
foreach ($items as $item) {
|
||||
$msgs[] = [
|
||||
'id' => $item->id,
|
||||
'search_msg_id' => $item->search_msg_id,
|
||||
'user_at' => Carbon::parse($item->user_at)->format('Y-m-d H:i:s'),
|
||||
'mark_unread' => $item->mark_unread,
|
||||
'silence' => $item->silence,
|
||||
'hide' => $item->hide,
|
||||
'color' => $item->color,
|
||||
'top_at' => Carbon::parse($item->top_at)->format('Y-m-d H:i:s'),
|
||||
'last_at' => Carbon::parse($item->last_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
return $msgs;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限计算方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取消息的 allowed_users 列表
|
||||
*
|
||||
* 对话的所有成员都有权限查看该对话的消息
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(WebSocketDialogMsg $msg): array
|
||||
{
|
||||
return self::getDialogUserIds($msg->dialog_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话的所有成员ID
|
||||
*
|
||||
* @param int $dialogId 对话ID
|
||||
* @return array 成员用户ID数组
|
||||
*/
|
||||
public static function getDialogUserIds(int $dialogId): array
|
||||
{
|
||||
if ($dialogId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return WebSocketDialogUser::where('dialog_id', $dialogId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个消息到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param WebSocketDialogMsg $msg 消息模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否应该索引
|
||||
if (!self::shouldIndex($msg)) {
|
||||
// 不符合索引条件,尝试删除已存在的索引
|
||||
return ManticoreBase::deleteMsgVector($msg->id);
|
||||
}
|
||||
|
||||
try {
|
||||
// 提取消息内容(使用 key 字段)
|
||||
$content = $msg->key ?? '';
|
||||
|
||||
// 限制内容长度
|
||||
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($content) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($content);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取消息的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($msg);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertMsgVector([
|
||||
'msg_id' => $msg->id,
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
'userid' => $msg->userid,
|
||||
'msg_type' => $msg->type,
|
||||
'content' => $content,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore msg sync error: ' . $e->getMessage(), [
|
||||
'msg_id' => $msg->id,
|
||||
'dialog_id' => $msg->dialog_id,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步消息
|
||||
*
|
||||
* @param iterable $msgs 消息列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $msgs, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($msgs as $msg) {
|
||||
if (self::sync($msg, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成向量(供后台任务调用)
|
||||
*
|
||||
* @param array $msgIds 消息ID数组
|
||||
* @param int $batchSize 每批 embedding 数量
|
||||
* @return int 成功生成向量的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $msgIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled('ai') || empty($msgIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
// 分批处理
|
||||
foreach (array_chunk($msgIds, $batchSize) as $batchIds) {
|
||||
// 获取消息
|
||||
$msgs = WebSocketDialogMsg::whereIn('id', $batchIds)
|
||||
->whereIn('type', self::INDEXABLE_TYPES)
|
||||
->where('bot', '!=', 1)
|
||||
->whereNotNull('key')
|
||||
->where('key', '!=', '')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($msgs->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 准备文本
|
||||
$texts = [];
|
||||
$idsArray = [];
|
||||
foreach ($batchIds as $id) {
|
||||
if (isset($msgs[$id])) {
|
||||
$content = mb_substr($msgs[$id]->key ?? '', 0, self::MAX_CONTENT_LENGTH);
|
||||
if (!empty($content)) {
|
||||
$texts[] = $content;
|
||||
$idsArray[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($texts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 批量获取 embeddings
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
|
||||
if (Base::isError($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'] ?? [];
|
||||
|
||||
// 构建批量更新数据 [msg_id => vectorStr]
|
||||
$vectorData = [];
|
||||
foreach ($embeddings as $index => $embedding) {
|
||||
if (empty($embedding) || !is_array($embedding)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$msgId = $idsArray[$index] ?? null;
|
||||
if (!$msgId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vectorData[$msgId] = '[' . implode(',', $embedding) . ']';
|
||||
}
|
||||
|
||||
// 批量更新向量(优化:减少数据库操作次数)
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateMsgVectors($vectorData);
|
||||
$count += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息索引
|
||||
*
|
||||
* @param int $msgId 消息ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $msgId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteMsgVector($msgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllMsgVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引消息数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedMsgCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新对话下所有消息的 allowed_users 权限列表
|
||||
* 从 MySQL 获取最新的对话成员并更新到 Manticore
|
||||
*
|
||||
* @param int $dialogId 对话ID
|
||||
* @return int 更新的消息数量
|
||||
*/
|
||||
public static function updateDialogAllowedUsers(int $dialogId): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $dialogId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$userids = self::getDialogUserIds($dialogId);
|
||||
return ManticoreBase::updateDialogAllowedUsers($dialogId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateDialogAllowedUsers error: ' . $e->getMessage(), ['dialog_id' => $dialogId]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
369
app/Module/Manticore/ManticoreProject.php
Normal file
369
app/Module/Manticore/ManticoreProject.php
Normal file
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 项目搜索类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索项目: search($userid, $keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(Project $project);
|
||||
* - 批量同步: batchSync($projects);
|
||||
* - 删除索引: delete($projectId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新权限: updateAllowedUsers($projectId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreProject
|
||||
{
|
||||
/**
|
||||
* 搜索项目(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID(权限过滤)
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectVectorSearch($embedding, $userid, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::projectHybridSearch($keyword, $embedding, $userid, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore project search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'project_id' => $item['project_id'],
|
||||
'id' => $item['project_id'],
|
||||
'userid' => $item['userid'],
|
||||
'personal' => $item['personal'],
|
||||
'name' => $item['project_name'],
|
||||
'desc_preview' => isset($item['project_desc']) ? mb_substr($item['project_desc'], 0, 300) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取项目的 allowed_users 列表
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(int $projectId): array
|
||||
{
|
||||
return ProjectUser::where('project_id', $projectId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个项目到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param Project $project 项目模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(Project $project, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已归档的项目不索引
|
||||
if ($project->archived_at) {
|
||||
return self::delete($project->id);
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($project);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取项目成员列表(作为 allowed_users)
|
||||
$allowedUsers = self::getAllowedUsers($project->id);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertProjectVector([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $project->userid ?? 0,
|
||||
'personal' => $project->personal ?? 0,
|
||||
'project_name' => $project->name ?? '',
|
||||
'project_desc' => $project->desc ?? '',
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore project sync error: ' . $e->getMessage(), [
|
||||
'project_id' => $project->id,
|
||||
'project_name' => $project->name,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param Project $project 项目模型
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(Project $project): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($project->name)) {
|
||||
$parts[] = $project->name;
|
||||
}
|
||||
if (!empty($project->desc)) {
|
||||
$parts[] = $project->desc;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步项目
|
||||
*
|
||||
* @param iterable $projects 项目列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $projects, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($projects as $project) {
|
||||
if (self::sync($project, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目索引
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $projectId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteProjectVector($projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllProjectVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引项目数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedProjectCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新项目的 allowed_users 权限列表
|
||||
* 从 MySQL 获取最新的项目成员并更新到 Manticore
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function updateAllowedUsers(int $projectId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $projectId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$userids = self::getAllowedUsers($projectId);
|
||||
return ManticoreBase::updateProjectAllowedUsers($projectId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['project_id' => $projectId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成项目向量
|
||||
* 用于后台异步处理,将已索引项目的向量批量生成
|
||||
*
|
||||
* @param array $projectIds 项目ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $projectIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($projectIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询项目信息
|
||||
$projects = Project::whereIn('id', $projectIds)
|
||||
->whereNull('archived_at')
|
||||
->get();
|
||||
|
||||
if ($projects->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个项目的内容
|
||||
$projectContents = [];
|
||||
foreach ($projects as $project) {
|
||||
$searchableContent = self::buildSearchableContent($project);
|
||||
if (!empty($searchableContent)) {
|
||||
$projectContents[$project->id] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($projectContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($projectContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $projectId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$projectId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateProjectVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreProject generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
593
app/Module/Manticore/ManticoreTask.php
Normal file
593
app/Module/Manticore/ManticoreTask.php
Normal file
@@ -0,0 +1,593 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskContent;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 任务搜索类
|
||||
*
|
||||
* 权限逻辑说明:
|
||||
* - visibility = 1: 项目人员可见,通过项目成员计算 allowed_users
|
||||
* - visibility = 2: 任务人员可见,通过任务成员计算 allowed_users
|
||||
* - visibility = 3: 指定成员可见,通过任务成员 + 可见性成员计算 allowed_users
|
||||
* - 子任务继承父任务的 allowed_users
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索任务: search($userid, $keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(ProjectTask $task);
|
||||
* - 批量同步: batchSync($tasks);
|
||||
* - 删除索引: delete($taskId);
|
||||
*
|
||||
* 3. 权限更新方法
|
||||
* - 更新权限: updateAllowedUsers($taskId);
|
||||
* - 项目成员变更级联更新: cascadeUpdateByProject($projectId);
|
||||
* - 父任务变更级联到子任务: cascadeToChildren($taskId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreTask
|
||||
{
|
||||
/**
|
||||
* 最大内容长度(字符)
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
|
||||
|
||||
/**
|
||||
* 搜索任务(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID(权限过滤)
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskVectorSearch($embedding, $userid, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::taskHybridSearch($keyword, $embedding, $userid, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore task search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'task_id' => $item['task_id'],
|
||||
'id' => $item['task_id'],
|
||||
'project_id' => $item['project_id'],
|
||||
'userid' => $item['userid'],
|
||||
'visibility' => $item['visibility'],
|
||||
'name' => $item['task_name'],
|
||||
'desc_preview' => isset($item['task_desc']) ? mb_substr($item['task_desc'], 0, 300) : null,
|
||||
'content_preview' => isset($item['task_content']) ? mb_substr($item['task_content'], 0, 500) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限计算方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取任务的 allowed_users 列表
|
||||
*
|
||||
* 根据 visibility 计算有权限查看此任务的用户列表:
|
||||
* - visibility=1: 项目成员
|
||||
* - visibility=2: 任务成员(负责人/协作人)
|
||||
* - visibility=3: 任务成员 + 可见性指定成员
|
||||
* - 子任务: 还需要继承父任务的成员
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @param int $depth 递归深度(防止无限递归)
|
||||
* @param array $visited 已访问的任务ID(防止循环引用)
|
||||
* @return array 有权限的用户ID数组
|
||||
*/
|
||||
public static function getAllowedUsers(ProjectTask $task, int $depth = 0, array $visited = []): array
|
||||
{
|
||||
// 防止无限递归:深度超过10层或循环引用
|
||||
if ($depth > 10 || in_array($task->id, $visited)) {
|
||||
return [];
|
||||
}
|
||||
$visited[] = $task->id;
|
||||
|
||||
$userids = [];
|
||||
|
||||
// 1. 根据 visibility 获取基础成员
|
||||
if ($task->visibility == 1) {
|
||||
// visibility=1: 项目成员
|
||||
$userids = ProjectUser::where('project_id', $task->project_id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
} else {
|
||||
// visibility=2,3: 任务成员(负责人/协作人)
|
||||
$userids = ProjectTaskUser::where('task_id', $task->id)
|
||||
->orWhere('task_pid', $task->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// visibility=3: 加上可见性指定成员
|
||||
if ($task->visibility == 3) {
|
||||
$visUsers = ProjectTaskVisibilityUser::where('task_id', $task->id)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
$userids = array_merge($userids, $visUsers);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果是子任务,继承父任务成员
|
||||
if ($task->parent_id > 0) {
|
||||
$parentTask = ProjectTask::find($task->parent_id);
|
||||
if ($parentTask) {
|
||||
$parentUsers = self::getAllowedUsers($parentTask, $depth + 1, $visited);
|
||||
$userids = array_merge($userids, $parentUsers);
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($userids);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个任务到 Manticore(含 allowed_users)
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(ProjectTask $task, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已归档或已删除的任务不索引
|
||||
if ($task->archived_at || $task->deleted_at) {
|
||||
return self::delete($task->id);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取任务详细内容
|
||||
$taskContent = self::getTaskContent($task);
|
||||
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($task, $taskContent);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务的 allowed_users
|
||||
$allowedUsers = self::getAllowedUsers($task);
|
||||
|
||||
// 写入 Manticore(含 allowed_users)
|
||||
$result = ManticoreBase::upsertTaskVector([
|
||||
'task_id' => $task->id,
|
||||
'project_id' => $task->project_id ?? 0,
|
||||
'userid' => $task->userid ?? 0,
|
||||
'visibility' => $task->visibility ?? 1,
|
||||
'task_name' => $task->name ?? '',
|
||||
'task_desc' => $task->desc ?? '',
|
||||
'task_content' => $taskContent,
|
||||
'content_vector' => $embedding,
|
||||
'allowed_users' => $allowedUsers,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore task sync error: ' . $e->getMessage(), [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详细内容
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @return string 任务内容
|
||||
*/
|
||||
private static function getTaskContent(ProjectTask $task): string
|
||||
{
|
||||
try {
|
||||
$content = ProjectTaskContent::where('task_id', $task->id)->first();
|
||||
if (!$content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 解析内容
|
||||
$contentData = Base::json2array($content->content);
|
||||
$text = '';
|
||||
|
||||
// 提取文本内容(内容可能是 blocks 格式)
|
||||
if (is_array($contentData)) {
|
||||
$text = self::extractTextFromContent($contentData);
|
||||
} elseif (is_string($contentData)) {
|
||||
$text = $contentData;
|
||||
}
|
||||
|
||||
// 限制内容长度
|
||||
return mb_substr($text, 0, self::MAX_CONTENT_LENGTH);
|
||||
} catch (\Exception $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从内容数组中提取文本
|
||||
*
|
||||
* @param array $contentData 内容数据
|
||||
* @return string 提取的文本
|
||||
*/
|
||||
private static function extractTextFromContent(array $contentData): string
|
||||
{
|
||||
$texts = [];
|
||||
|
||||
// 处理 blocks 格式
|
||||
if (isset($contentData['blocks']) && is_array($contentData['blocks'])) {
|
||||
foreach ($contentData['blocks'] as $block) {
|
||||
if (isset($block['text'])) {
|
||||
$texts[] = $block['text'];
|
||||
}
|
||||
if (isset($block['data']['text'])) {
|
||||
$texts[] = $block['data']['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他格式
|
||||
if (isset($contentData['text'])) {
|
||||
$texts[] = $contentData['text'];
|
||||
}
|
||||
|
||||
return implode(' ', $texts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @param string $taskContent 任务详细内容
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(ProjectTask $task, string $taskContent): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($task->name)) {
|
||||
$parts[] = $task->name;
|
||||
}
|
||||
if (!empty($task->desc)) {
|
||||
$parts[] = $task->desc;
|
||||
}
|
||||
if (!empty($taskContent)) {
|
||||
$parts[] = $taskContent;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步任务
|
||||
*
|
||||
* @param iterable $tasks 任务列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $tasks, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($tasks as $task) {
|
||||
if (self::sync($task, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务索引
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $taskId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteTaskVector($taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllTaskVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引任务数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedTaskCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 权限更新方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 更新任务的 allowed_users 权限列表
|
||||
* 重新计算并更新 Manticore 中的权限
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function updateAllowedUsers(int $taskId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$task = ProjectTask::find($taskId);
|
||||
if (!$task) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userids = self::getAllowedUsers($task);
|
||||
return ManticoreBase::updateTaskAllowedUsers($taskId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['task_id' => $taskId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联更新项目下所有 visibility=1 任务的 allowed_users
|
||||
* 当项目成员变更时调用
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return int 更新的任务数量
|
||||
*/
|
||||
public static function cascadeUpdateByProject(int $projectId): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $projectId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取项目成员
|
||||
$projectUsers = ProjectUser::where('project_id', $projectId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// 分批更新该项目下所有 visibility=1 的任务
|
||||
$count = 0;
|
||||
ProjectTask::where('project_id', $projectId)
|
||||
->where('visibility', 1)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->chunk(100, function ($tasks) use ($projectUsers, &$count) {
|
||||
foreach ($tasks as $task) {
|
||||
// 对于子任务,需要合并父任务成员
|
||||
$allowedUsers = $projectUsers;
|
||||
if ($task->parent_id > 0) {
|
||||
$parentTask = ProjectTask::find($task->parent_id);
|
||||
if ($parentTask) {
|
||||
$parentUsers = self::getAllowedUsers($parentTask);
|
||||
$allowedUsers = array_unique(array_merge($allowedUsers, $parentUsers));
|
||||
}
|
||||
}
|
||||
|
||||
ManticoreBase::updateTaskAllowedUsers($task->id, $allowedUsers);
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore cascadeUpdateByProject error: ' . $e->getMessage(), ['project_id' => $projectId]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联更新所有子任务的 allowed_users
|
||||
* 当父任务的成员变更时调用
|
||||
*
|
||||
* @param int $taskId 父任务ID
|
||||
* @return void
|
||||
*/
|
||||
public static function cascadeToChildren(int $taskId): void
|
||||
{
|
||||
if (!Apps::isInstalled("search") || $taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ProjectTask::where('parent_id', $taskId)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->each(function ($child) {
|
||||
$allowedUsers = self::getAllowedUsers($child);
|
||||
ManticoreBase::updateTaskAllowedUsers($child->id, $allowedUsers);
|
||||
// 递归处理子任务的子任务
|
||||
self::cascadeToChildren($child->id);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore cascadeToChildren error: ' . $e->getMessage(), ['task_id' => $taskId]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成任务向量
|
||||
* 用于后台异步处理,将已索引任务的向量批量生成
|
||||
*
|
||||
* @param array $taskIds 任务ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $taskIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($taskIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询任务信息
|
||||
$tasks = ProjectTask::whereIn('id', $taskIds)
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('archived_at')
|
||||
->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个任务的内容
|
||||
$taskContents = [];
|
||||
foreach ($tasks as $task) {
|
||||
$taskContent = self::getTaskContent($task);
|
||||
$searchableContent = self::buildSearchableContent($task, $taskContent);
|
||||
if (!empty($searchableContent)) {
|
||||
// 限制内容长度
|
||||
$searchableContent = mb_substr($searchableContent, 0, self::MAX_CONTENT_LENGTH);
|
||||
$taskContents[$task->id] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($taskContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($taskContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $taskId) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$taskId] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateTaskVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreTask generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
362
app/Module/Manticore/ManticoreUser.php
Normal file
362
app/Module/Manticore/ManticoreUser.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\Manticore;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserTag;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manticore Search 用户搜索类(联系人搜索)
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索用户: search($keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(User $user);
|
||||
* - 批量同步: batchSync($users);
|
||||
* - 删除索引: delete($userid);
|
||||
*
|
||||
* 3. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class ManticoreUser
|
||||
{
|
||||
/**
|
||||
* 搜索用户(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userFullTextSearch($keyword, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userFullTextSearch($keyword, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userVectorSearch($embedding, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = ManticoreBase::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
ManticoreBase::userHybridSearch($keyword, $embedding, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore user search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results Manticore 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'userid' => $item['userid'],
|
||||
'nickname' => $item['nickname'],
|
||||
'email' => $item['email'],
|
||||
'profession' => $item['profession'],
|
||||
'tags' => $item['tags'] ?? '',
|
||||
'introduction_preview' => isset($item['introduction']) ? mb_substr($item['introduction'], 0, 200) : null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 获取用户的标签(按认可数排序,最多10个)
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return string 标签名称,空格分隔
|
||||
*/
|
||||
public static function getUserTags(int $userid): string
|
||||
{
|
||||
$tags = UserTag::where('user_id', $userid)
|
||||
->withCount('recognitions')
|
||||
->orderByDesc('recognitions_count')
|
||||
->limit(10)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
return implode(' ', $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个用户到 Manticore
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(User $user, bool $withVector = false): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 不处理机器人账号
|
||||
if ($user->bot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 不处理已禁用的账号
|
||||
if ($user->disable_at) {
|
||||
return self::delete($user->userid);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户标签(Top 10)
|
||||
$tags = self::getUserTags($user->userid);
|
||||
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
|
||||
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
|
||||
$embedding = null;
|
||||
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 Manticore
|
||||
$result = ManticoreBase::upsertUserVector([
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'profession' => $user->profession ?? '',
|
||||
'tags' => $tags,
|
||||
'introduction' => $user->introduction ?? '',
|
||||
'content_vector' => $embedding,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Manticore user sync error: ' . $e->getMessage(), [
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param string $tags 用户标签(空格分隔)
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(User $user, string $tags = ''): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($user->nickname)) {
|
||||
$parts[] = $user->nickname;
|
||||
}
|
||||
if (!empty($user->email)) {
|
||||
$parts[] = $user->email;
|
||||
}
|
||||
if (!empty($user->profession)) {
|
||||
$parts[] = $user->profession;
|
||||
}
|
||||
if (!empty($tags)) {
|
||||
$parts[] = $tags;
|
||||
}
|
||||
if (!empty($user->introduction)) {
|
||||
$parts[] = $user->introduction;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步用户
|
||||
*
|
||||
* @param iterable $users 用户列表
|
||||
* @param bool $withVector 是否同时生成向量
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $users, bool $withVector = false): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($users as $user) {
|
||||
if (self::sync($user, $withVector)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户索引
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::deleteUserVector($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ManticoreBase::clearAllUserVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引用户数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("search")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ManticoreBase::getIndexedUserCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 批量向量生成方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 批量生成用户向量
|
||||
* 用于后台异步处理,将已索引用户的向量批量生成
|
||||
*
|
||||
* @param array $userIds 用户ID数组
|
||||
* @param int $batchSize 每批 embedding 数量(默认20)
|
||||
* @return int 成功处理的数量
|
||||
*/
|
||||
public static function generateVectorsBatch(array $userIds, int $batchSize = 20): int
|
||||
{
|
||||
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($userIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询用户信息
|
||||
$users = User::whereIn('userid', $userIds)
|
||||
->where('bot', 0)
|
||||
->whereNull('disable_at')
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 提取每个用户的内容(包含标签)
|
||||
$userContents = [];
|
||||
foreach ($users as $user) {
|
||||
$tags = self::getUserTags($user->userid);
|
||||
$searchableContent = self::buildSearchableContent($user, $tags);
|
||||
if (!empty($searchableContent)) {
|
||||
$userContents[$user->userid] = $searchableContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($userContents)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 分批处理
|
||||
$successCount = 0;
|
||||
$chunks = array_chunk($userContents, $batchSize, true);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$texts = array_values($chunk);
|
||||
$ids = array_keys($chunk);
|
||||
|
||||
// 4. 批量获取 embedding
|
||||
$result = AI::getBatchEmbeddings($texts);
|
||||
if (!Base::isSuccess($result) || empty($result['data'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$embeddings = $result['data'];
|
||||
|
||||
// 5. 构建批量更新数据
|
||||
$vectorData = [];
|
||||
foreach ($ids as $index => $userid) {
|
||||
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
|
||||
continue;
|
||||
}
|
||||
$vectorData[$userid] = '[' . implode(',', $embeddings[$index]) . ']';
|
||||
}
|
||||
|
||||
// 6. 批量更新向量
|
||||
if (!empty($vectorData)) {
|
||||
$batchCount = ManticoreBase::batchUpdateUserVectors($vectorData);
|
||||
$successCount += $batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ManticoreUser generateVectorsBatch error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
300
app/Module/PromptPlaceholder.php
Normal file
300
app/Module/PromptPlaceholder.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Models\UserTag;
|
||||
use App\Models\WebSocketDialog;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
|
||||
/**
|
||||
* AI 提示词模块
|
||||
*
|
||||
* 提供用户上下文和条件性提示块的构建能力
|
||||
*/
|
||||
class PromptPlaceholder
|
||||
{
|
||||
/**
|
||||
* 构建条件性提示块(用户上下文 + 格式指南)
|
||||
*
|
||||
* @param int|null $userid
|
||||
* @param WebSocketDialog|null $dialog
|
||||
* @return string
|
||||
*/
|
||||
public static function buildOptionalPrompts($userid, ?WebSocketDialog $dialog = null): string
|
||||
{
|
||||
$blocks = [];
|
||||
|
||||
// 用户上下文块
|
||||
if ($userid && $userid > 0) {
|
||||
$userContext = self::buildUserContext($userid, $dialog);
|
||||
if ($userContext) {
|
||||
$blocks[] = <<<EOF
|
||||
<optional-user-context>
|
||||
以下是当前对话用户的背景信息,当需要了解用户身份、工作职责或任务情况时可参考:
|
||||
|
||||
{$userContext}
|
||||
|
||||
注意:此上下文仅供参考,用于理解用户背景和提供个性化帮助。如果与当前对话无关,请忽略。
|
||||
</optional-user-context>
|
||||
EOF;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式指南块
|
||||
$blocks[] = <<<'EOF'
|
||||
<optional-format-guide>
|
||||
当你的回答中包含 DooTask 系统资源(任务、项目、文件等)时,建议使用以下链接格式使其可点击:
|
||||
- 任务: [任务名称](dootask://task/{task_id}/{parent_id}),其中 parent_id 为主任务ID,主任务时为 0
|
||||
- 项目: [项目名称](dootask://project/{project_id})
|
||||
- 文件: [文件名称](dootask://file/{file_id})
|
||||
- 联系人: [用户名](dootask://contact/{userid})
|
||||
- 消息: [消息预览](dootask://message/{dialog_id}/{msg_id})
|
||||
|
||||
注意:此格式指南不影响正常对话,仅在涉及上述资源时参考。如果与当前对话无关,请忽略。
|
||||
</optional-format-guide>
|
||||
EOF;
|
||||
|
||||
return implode("\n\n", $blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整用户上下文
|
||||
*/
|
||||
private static function buildUserContext(int $userid, ?WebSocketDialog $dialog = null): string
|
||||
{
|
||||
$lines = [];
|
||||
|
||||
// 基础信息
|
||||
$basicInfo = self::getUserBasicInfo($userid);
|
||||
$nickname = $basicInfo['nickname'] ?? '';
|
||||
if ($nickname) {
|
||||
$basicLine = "与您对话的用户:{$nickname}";
|
||||
if ($basicInfo['profession'] ?? '') {
|
||||
$basicLine .= "({$basicInfo['profession']})";
|
||||
}
|
||||
$lines[] = "{$basicLine}(user_id: {$userid})";
|
||||
}
|
||||
|
||||
if ($basicInfo['department'] ?? '') {
|
||||
$lines[] = "所属部门:{$basicInfo['department']}";
|
||||
}
|
||||
|
||||
if ($basicInfo['introduction'] ?? '') {
|
||||
$lines[] = "个人简介:{$basicInfo['introduction']}";
|
||||
}
|
||||
|
||||
// 同事印象
|
||||
$tags = self::getUserTags($userid);
|
||||
if ($tags) {
|
||||
$lines[] = "同事印象:{$tags}";
|
||||
}
|
||||
|
||||
// 场景角色
|
||||
if ($dialog) {
|
||||
$role = self::getUserRole($userid, $dialog);
|
||||
if ($role) {
|
||||
$lines[] = $role;
|
||||
}
|
||||
}
|
||||
|
||||
// 进行中任务
|
||||
$inProgressTasks = self::getInProgressTasks($userid);
|
||||
if ($inProgressTasks) {
|
||||
$lines[] = "\n进行中的任务:\n{$inProgressTasks}";
|
||||
}
|
||||
|
||||
// 最近完成
|
||||
$completedTasks = self::getCompletedTasks($userid);
|
||||
if ($completedTasks) {
|
||||
$lines[] = "\n最近完成:\n{$completedTasks}";
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户基础信息
|
||||
*/
|
||||
private static function getUserBasicInfo(int $userid): array
|
||||
{
|
||||
$user = User::find($userid);
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'nickname' => $user->nickname ?: '',
|
||||
'profession' => $user->profession ?: '',
|
||||
'introduction' => $user->introduction ? mb_substr($user->introduction, 0, 100) : '',
|
||||
'department' => $user->getDepartmentName() ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户标签 Top 5
|
||||
*/
|
||||
private static function getUserTags(int $userid): string
|
||||
{
|
||||
$tags = UserTag::where('user_id', $userid)
|
||||
->withCount(['recognitions as recognition_total'])
|
||||
->orderByDesc('recognition_total')
|
||||
->orderBy('id')
|
||||
->take(5)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
return implode('、', $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在场景中的角色
|
||||
*/
|
||||
private static function getUserRole(int $userid, WebSocketDialog $dialog): string
|
||||
{
|
||||
if ($dialog->type !== 'group') {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch ($dialog->group_type) {
|
||||
case 'project':
|
||||
$project = Project::whereDialogId($dialog->id)->first();
|
||||
if ($project) {
|
||||
$projectUser = ProjectUser::whereProjectId($project->id)->whereUserid($userid)->first();
|
||||
if ($projectUser?->owner) {
|
||||
return '该用户是此项目的负责人';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
$task = ProjectTask::whereDialogId($dialog->id)->first();
|
||||
if ($task) {
|
||||
$taskUser = ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->first();
|
||||
if ($taskUser) {
|
||||
return $taskUser->owner ? '该用户是此任务的负责人' : '该用户是此任务的协助人';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'department':
|
||||
$department = UserDepartment::whereDialogId($dialog->id)->first();
|
||||
if ($department?->owner_userid === $userid) {
|
||||
return '该用户是此部门的负责人';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进行中的任务(缓存 3 分钟)
|
||||
*
|
||||
* 排序策略:逾期优先 → 最近活跃优先 → 负责人优先 → 高优先级优先 → 截止时间近优先
|
||||
*/
|
||||
private static function getInProgressTasks(int $userid): string
|
||||
{
|
||||
$cacheKey = "prompt:tasks:in_progress:{$userid}";
|
||||
|
||||
return Cache::remember($cacheKey, 180, function () use ($userid) {
|
||||
$now = Carbon::now();
|
||||
$threeDaysAgo = $now->copy()->subDays(3);
|
||||
|
||||
// orderByRaw 中的表名需要带前缀
|
||||
$prefix = DB::getTablePrefix();
|
||||
$t = $prefix . 'project_tasks';
|
||||
$du = $prefix . 'web_socket_dialog_users';
|
||||
|
||||
$tasks = ProjectTask::query()
|
||||
->select([
|
||||
'project_tasks.id',
|
||||
'project_tasks.name',
|
||||
'project_tasks.p_name',
|
||||
'project_tasks.end_at',
|
||||
'project_task_users.owner'
|
||||
])
|
||||
->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->leftJoin('web_socket_dialog_users', function ($join) use ($userid) {
|
||||
$join->on('project_tasks.dialog_id', '=', 'web_socket_dialog_users.dialog_id')
|
||||
->where('web_socket_dialog_users.userid', '=', $userid);
|
||||
})
|
||||
->where('project_task_users.userid', $userid)
|
||||
->where('project_tasks.visibility', 1)
|
||||
->whereNull('project_tasks.complete_at')
|
||||
->whereNull('project_tasks.archived_at')
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->orderByRaw("CASE WHEN {$t}.end_at IS NOT NULL AND {$t}.end_at < ? THEN 0 ELSE 1 END", [$now])
|
||||
->orderByRaw("CASE WHEN {$du}.last_at >= ? THEN 0 ELSE 1 END", [$threeDaysAgo])
|
||||
->orderByDesc('web_socket_dialog_users.last_at')
|
||||
->orderByDesc('project_task_users.owner')
|
||||
->orderByDesc('project_tasks.p_level')
|
||||
->orderByRaw("CASE WHEN {$t}.end_at IS NULL THEN 1 ELSE 0 END")
|
||||
->orderBy('project_tasks.end_at')
|
||||
->take(20)
|
||||
->get();
|
||||
|
||||
return self::formatTaskList($tasks, $now);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近完成的任务(缓存 3 分钟)
|
||||
*/
|
||||
private static function getCompletedTasks(int $userid): string
|
||||
{
|
||||
$cacheKey = "prompt:tasks:completed:{$userid}";
|
||||
|
||||
return Cache::remember($cacheKey, 180, function () use ($userid) {
|
||||
$tasks = ProjectTask::query()
|
||||
->select([
|
||||
'project_tasks.id',
|
||||
'project_tasks.name'
|
||||
])
|
||||
->join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
|
||||
->where('project_task_users.userid', $userid)
|
||||
->where('project_tasks.visibility', 1)
|
||||
->whereNotNull('project_tasks.complete_at')
|
||||
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(7))
|
||||
->whereNull('project_tasks.deleted_at')
|
||||
->orderByDesc('project_tasks.complete_at')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $tasks->map(fn($task) => "- {$task->name} (task:{$task->id})")->implode("\n");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化任务列表
|
||||
*/
|
||||
private static function formatTaskList($tasks, Carbon $now): string
|
||||
{
|
||||
if ($tasks->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $tasks->map(function ($task) use ($now) {
|
||||
$line = '- ';
|
||||
if ($task->p_name) {
|
||||
$line .= "[{$task->p_name}] ";
|
||||
}
|
||||
$line .= "{$task->name} (task_id:{$task->id})";
|
||||
if ($task->end_at && Carbon::parse($task->end_at)->lt($now)) {
|
||||
$line .= ' ⚠️逾期';
|
||||
}
|
||||
return $line;
|
||||
})->implode("\n");
|
||||
}
|
||||
}
|
||||
@@ -233,11 +233,12 @@ class TextExtractor
|
||||
/**
|
||||
* 获取文件内容
|
||||
* @param $filePath
|
||||
* @param int $fileMaxSize 最大文件大小,单位字节,默认1024KB
|
||||
* @param int $contentMaxSize 最大内容大小,单位字节,默认300KB
|
||||
* @param int $fileMaxSize 最大文件大小,单位KB,默认1024KB
|
||||
* @param int $contentMaxSize 最大内容大小,单位KB,默认300KB
|
||||
* @param bool $truncate 超过contentMaxSize时是否截取,默认true截取,false返回错误
|
||||
* @return array
|
||||
*/
|
||||
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300): array
|
||||
public static function extractFile($filePath, int $fileMaxSize = 1024, int $contentMaxSize = 300, bool $truncate = true): array
|
||||
{
|
||||
if (!file_exists($filePath) || !is_file($filePath)) {
|
||||
return Base::retError("Failed to read contents of {$filePath}");
|
||||
@@ -248,8 +249,13 @@ class TextExtractor
|
||||
try {
|
||||
$extractor = new self($filePath);
|
||||
$content = $extractor->extractContent();
|
||||
if (strlen($content) > $contentMaxSize * 1024) {
|
||||
return Base::retError("Content size exceeds " . Base::readableBytes($contentMaxSize * 1024) . ", unable to display content");
|
||||
$maxBytes = $contentMaxSize * 1024;
|
||||
if (strlen($content) > $maxBytes) {
|
||||
if ($truncate) {
|
||||
$content = mb_substr($content, 0, $maxBytes);
|
||||
} else {
|
||||
return Base::retError("Content size exceeds " . Base::readableBytes($maxBytes) . ", unable to display content");
|
||||
}
|
||||
}
|
||||
return Base::retSuccess("success", $content);
|
||||
} catch (Exception $e) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user