Compare commits
593
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa5954ef6a | ||
|
|
963bf5c0da | ||
|
|
a71d2701d1 | ||
|
|
025fbe73f0 | ||
|
|
0d161f318f | ||
|
|
2eac1fe31d | ||
|
|
09b0f89e8d | ||
|
|
8fb1f52777 | ||
|
|
44b7abcd0f | ||
|
|
181b338780 | ||
|
|
657190d5a8 | ||
|
|
3e2d6a06d9 | ||
|
|
fc9e429187 | ||
|
|
5d139783c9 | ||
|
|
ddd2406f32 | ||
|
|
272aa62434 | ||
|
|
a41ab18636 | ||
|
|
8999ff395d | ||
|
|
229f1d0198 | ||
|
|
2dadca6288 | ||
|
|
2cf8f9a1c3 | ||
|
|
d9a1670a72 | ||
|
|
5ff4626b33 | ||
|
|
6bbde0b100 | ||
|
|
884324b8fd | ||
|
|
7baaeddf26 | ||
|
|
99d49a1765 | ||
|
|
92f7606f65 | ||
|
|
c2ba3df47c | ||
|
|
226dc275bb | ||
|
|
7a2ff3f80e | ||
|
|
23321a0b0c | ||
|
|
5d44ac239b | ||
|
|
848c746505 | ||
|
|
4b0fbd73d3 | ||
|
|
2fe24b9db7 | ||
|
|
15b540cdd6 | ||
|
|
50b3dd4a8e | ||
|
|
1d34e47673 | ||
|
|
2367a5d12b | ||
|
|
ebc5f890fd | ||
|
|
e60c47123c | ||
|
|
5c20cf1ab9 | ||
|
|
45cf405285 | ||
|
|
4f80a691d1 | ||
|
|
47f19a98eb | ||
|
|
62792413df | ||
|
|
34c775620d | ||
|
|
b3774e0112 | ||
|
|
0e6f7531d4 | ||
|
|
73a8db8498 | ||
|
|
cf657707f9 | ||
|
|
40728e685b | ||
|
|
6abb09e5c2 | ||
|
|
df2da25833 | ||
|
|
1ea4cfeba3 | ||
|
|
ce6e3ef70a | ||
|
|
87e20c31a9 | ||
|
|
22edf34ff1 | ||
|
|
16ba79a336 | ||
|
|
9c25b1b397 | ||
|
|
5a80e6693f | ||
|
|
ab58641bbe | ||
|
|
45e3d84f59 | ||
|
|
a3ff636e45 | ||
|
|
7b360ddccd | ||
|
|
758c7521bb | ||
|
|
e730cfaa7d | ||
|
|
60a87a8d15 | ||
|
|
0b519b8ca1 | ||
|
|
e6b4a747ce | ||
|
|
f009f75c03 | ||
|
|
91b621fe75 | ||
|
|
5d1a157085 | ||
|
|
5552caa6c1 | ||
|
|
ad84704d28 | ||
|
|
08eddc9ed2 | ||
|
|
660afaf66d | ||
|
|
f9bc6eee6d | ||
|
|
5ef5dd8eaf | ||
|
|
a35b02dd18 | ||
|
|
04d25cd23d | ||
|
|
217ff24f1c | ||
|
|
40a884cc85 | ||
|
|
345408d5e7 | ||
|
|
090f149b31 | ||
|
|
f66eae5baf | ||
|
|
4219186ab0 | ||
|
|
7e6075a2e9 | ||
|
|
cd51aabe7e | ||
|
|
c5468a669b | ||
|
|
7b694938c6 | ||
|
|
71b0c7315a | ||
|
|
ec15950851 | ||
|
|
7d563f2cd6 | ||
|
|
6bdd245a83 | ||
|
|
316baf28d6 | ||
|
|
70da939a58 | ||
|
|
07754ff4e4 | ||
|
|
4159af1732 | ||
|
|
0143fa036c | ||
|
|
5227762aeb | ||
|
|
a4317bb6f1 | ||
|
|
9adf8c5630 | ||
|
|
8bb7f63c62 | ||
|
|
b8b3fd867b | ||
|
|
e92ec118fe | ||
|
|
4c4b53cd69 | ||
|
|
d7f226659c | ||
|
|
979e6ebea3 | ||
|
|
3a581163aa | ||
|
|
97c2153c2e | ||
|
|
27a15dd1b4 | ||
|
|
cf37d6c408 | ||
|
|
c9b8ec6805 | ||
|
|
31d88b634a | ||
|
|
5704f80929 | ||
|
|
652ef050ee | ||
|
|
8f35160b56 | ||
|
|
0e8fb1582d | ||
|
|
aa74770256 | ||
|
|
01ae6d44f0 | ||
|
|
ff14762426 | ||
|
|
419119725e | ||
|
|
b31784010c | ||
|
|
4049cfd2d0 | ||
|
|
c70054e83d | ||
|
|
9baab3777a | ||
|
|
3036b782f9 | ||
|
|
1fa3631e53 | ||
|
|
2df4e5cc9f | ||
|
|
8f1103dfa7 | ||
|
|
aa532eb568 | ||
|
|
33c1172da5 | ||
|
|
4c614a5ece | ||
|
|
030df5c5dc | ||
|
|
b82a05f82e | ||
|
|
ae053b0268 | ||
|
|
9a74876b3f | ||
|
|
6ddddef5ec | ||
|
|
0a2ca5d379 | ||
|
|
fe18a2c9ea | ||
|
|
1d316791b1 | ||
|
|
d52b805adf | ||
|
|
7a16feb548 | ||
|
|
08380a52c8 | ||
|
|
96d58c53f7 | ||
|
|
6252a07b5d | ||
|
|
3303a91e3c | ||
|
|
7ae05698fa | ||
|
|
c511d04415 | ||
|
|
dfc978c620 | ||
|
|
c6f729fb27 | ||
|
|
c7ff729f84 | ||
|
|
e0ae38ce08 | ||
|
|
e2e2a21d2f | ||
|
|
a3b6bf3a55 | ||
|
|
fd402d9efe | ||
|
|
1052b97306 | ||
|
|
d9cdbc8e15 | ||
|
|
edd279372d | ||
|
|
7ab6dc62dd | ||
|
|
22fc173305 | ||
|
|
0b914dd274 | ||
|
|
c7e5de5f99 | ||
|
|
e6b5b904fc | ||
|
|
ba628e3a4a | ||
|
|
ad4be92189 | ||
|
|
5d018657d5 | ||
|
|
24bd00b537 | ||
|
|
250a6142f3 | ||
|
|
e90debe810 | ||
|
|
2d8e5cf53e | ||
|
|
7ab22d26b1 | ||
|
|
5c7973471d | ||
|
|
c81e3643d5 | ||
|
|
4297e02a67 | ||
|
|
844b6abb25 | ||
|
|
e6e8a347fc | ||
|
|
fc8f507864 | ||
|
|
f016df0bf1 | ||
|
|
141874a0bc | ||
|
|
d788d70752 | ||
|
|
cb45bafad6 | ||
|
|
56532c2c03 | ||
|
|
12502a2e69 | ||
|
|
e46a9fd347 | ||
|
|
908023ddc4 | ||
|
|
d9db07573e | ||
|
|
b2621d67ca | ||
|
|
aaa6f06477 | ||
|
|
562d8941b9 | ||
|
|
b286137cb0 | ||
|
|
99e9b4f7bf | ||
|
|
12a011fdce | ||
|
|
fa95bf05d5 | ||
|
|
5c631c6f23 | ||
|
|
19fd56d477 | ||
|
|
35c8cf0b9c | ||
|
|
4b6c4ba928 | ||
|
|
4e93f7bdd4 | ||
|
|
81b5c99b6a | ||
|
|
f496c9fa16 | ||
|
|
1de35bfa30 | ||
|
|
48fdee2c4b | ||
|
|
3d15807919 | ||
|
|
3c64525a3e | ||
|
|
5103527635 | ||
|
|
52bc80a402 | ||
|
|
87c7f786d2 | ||
|
|
16e1657063 | ||
|
|
f54f21be37 | ||
|
|
f357347d28 | ||
|
|
afe45fe8c5 | ||
|
|
c415177287 | ||
|
|
76d6cc9703 | ||
|
|
a674865538 | ||
|
|
708b39ffcc | ||
|
|
7579d3163a | ||
|
|
8e6213aed8 | ||
|
|
b169173901 | ||
|
|
c08fb7b3aa | ||
|
|
65e8fbfc70 | ||
|
|
88794b816e | ||
|
|
256821b824 | ||
|
|
f90336963b | ||
|
|
4c20c82f96 | ||
|
|
530da2b1b4 | ||
|
|
b391ad31af | ||
|
|
368cd04742 | ||
|
|
f1c11df5d5 | ||
|
|
64ddfe8cae | ||
|
|
82dd899ad0 | ||
|
|
3a25534bc1 | ||
|
|
da60bd8b0f | ||
|
|
60fa96b789 | ||
|
|
ad5b104e0d | ||
|
|
e1580c70ed | ||
|
|
4f3bb0090c | ||
|
|
32710c2e06 | ||
|
|
42dd68f266 | ||
|
|
6d3997cbae | ||
|
|
fa4977a71d | ||
|
|
d1aaf2e5c0 | ||
|
|
0b2709339e | ||
|
|
b5f1699240 | ||
|
|
c2974957ba | ||
|
|
bce8d37d88 | ||
|
|
fdc7314087 | ||
|
|
c5def287b8 | ||
|
|
5e509b20ed | ||
|
|
f6ce79c65a | ||
|
|
0f477c6b15 | ||
|
|
0de7436baf | ||
|
|
a755a5f657 | ||
|
|
0a48d2739a | ||
|
|
36cd6a0fcd | ||
|
|
de8f02a542 | ||
|
|
374b0bd89c | ||
|
|
efd1c0d38c | ||
|
|
d214a9791c | ||
|
|
aadb02a9c4 | ||
|
|
3afb182361 | ||
|
|
e50e6c7170 | ||
|
|
a38809cc5d | ||
|
|
7fc5eb9067 | ||
|
|
ce221c99c8 | ||
|
|
d34410971a | ||
|
|
1de202ad4a | ||
|
|
38aac37a4e | ||
|
|
c6192fdbd0 | ||
|
|
f2a3d79f53 | ||
|
|
f51adf5a98 | ||
|
|
1aa003a5af | ||
|
|
68c00db549 | ||
|
|
8113a4efd3 | ||
|
|
fd59d1a19d | ||
|
|
df5cfb8c83 | ||
|
|
e08c73efe8 | ||
|
|
7a6ecc295f | ||
|
|
bc37f647ad | ||
|
|
06757e9ab4 | ||
|
|
2ed8f339db | ||
|
|
a92f903738 | ||
|
|
90b4f9d73a | ||
|
|
cd108ba6c5 | ||
|
|
63e20c2968 | ||
|
|
70949b8f4b | ||
|
|
f54c009acd | ||
|
|
a67bba1178 | ||
|
|
af2f2c9411 | ||
|
|
7c5800bef1 | ||
|
|
ee43b4e186 | ||
|
|
bf9d611fcc | ||
|
|
2bf9e8500c | ||
|
|
9ffbdc0926 | ||
|
|
2a2e301e57 | ||
|
|
e14d266dfd | ||
|
|
f73efc8653 | ||
|
|
041d97a0a3 | ||
|
|
a80a0ed4ca | ||
|
|
4a6a31d3cd | ||
|
|
8e1c8b7408 | ||
|
|
800537076d | ||
|
|
fbb1b6c30a | ||
|
|
75352e9817 | ||
|
|
748dea3424 | ||
|
|
d82d5c76be | ||
|
|
452fe74b3d | ||
|
|
053993dd5f | ||
|
|
43d04519c2 | ||
|
|
cc187a53c0 | ||
|
|
ef526c6360 | ||
|
|
e66e1ed922 | ||
|
|
c43321e19f | ||
|
|
aa69d4b0c6 | ||
|
|
3755b766af | ||
|
|
83f7890851 | ||
|
|
dfa24c0f43 | ||
|
|
6435238ad7 | ||
|
|
4aa80d0fc7 | ||
|
|
02a675a07c | ||
|
|
c9561edb63 | ||
|
|
ccf12cac8c | ||
|
|
7d3d131f28 | ||
|
|
fa6f443a98 | ||
|
|
729b369408 | ||
|
|
065318accd | ||
|
|
2cf9009a8c | ||
|
|
447a767617 | ||
|
|
8b176a6880 | ||
|
|
fdf3f04b23 | ||
|
|
97b55185e6 | ||
|
|
811b78375e | ||
|
|
97b5c4ac37 | ||
|
|
0877b1bb0f | ||
|
|
f19d21ef96 | ||
|
|
671953bff3 | ||
|
|
678652cb20 | ||
|
|
de584257e5 | ||
|
|
a4ea054df7 | ||
|
|
d91d1d59b9 | ||
|
|
16316e2f0a | ||
|
|
caab79d4c2 | ||
|
|
939d985f67 | ||
|
|
c93ba886b4 | ||
|
|
674b3d4ac8 | ||
|
|
4b0ca0e9e7 | ||
|
|
3073852153 | ||
|
|
ac0639e157 | ||
|
|
e95bc0b513 | ||
|
|
b9d8a0aa9a | ||
|
|
edc0e24021 | ||
|
|
5b5c590cad | ||
|
|
2a1e3ac8b7 | ||
|
|
9cbfa874c9 | ||
|
|
bd804ff05e | ||
|
|
5c7128136d | ||
|
|
cd2ab673a4 | ||
|
|
dc124b17a9 | ||
|
|
691ef33e5b | ||
|
|
c9b70e4e9b | ||
|
|
6e0757a246 | ||
|
|
5c8088a29c | ||
|
|
4541ca6bce | ||
|
|
fe84d8b7b5 | ||
|
|
8d9e3afc9a | ||
|
|
988362db00 | ||
|
|
1816142929 | ||
|
|
44d319a911 | ||
|
|
4ae07c7734 | ||
|
|
0cc5d4f0ee | ||
|
|
10bb3dbe6e | ||
|
|
3334b45334 | ||
|
|
c1d29baa87 | ||
|
|
4e2f80fefa | ||
|
|
df2023a523 | ||
|
|
62a0d3636d | ||
|
|
73404d68ed | ||
|
|
2715f4babe | ||
|
|
e15926a15e | ||
|
|
bc426f73a3 | ||
|
|
f58814eaea | ||
|
|
c35507681e | ||
|
|
c40bd52a8e | ||
|
|
691ec2e134 | ||
|
|
4a2e066608 | ||
|
|
2ccfc14473 | ||
|
|
6f18a8892b | ||
|
|
b70e5a90fd | ||
|
|
ae3c62b60f | ||
|
|
bb0b7377fe | ||
|
|
0ded8ddf19 | ||
|
|
b30123b727 | ||
|
|
3d560b8caf | ||
|
|
923fab80e8 | ||
|
|
63b086998e | ||
|
|
eeb5e0eaf8 | ||
|
|
e26e57ac18 | ||
|
|
83f13e9a6e | ||
|
|
8b37e23951 | ||
|
|
212834d59d | ||
|
|
793d6e542a | ||
|
|
8b64060388 | ||
|
|
7aebcba35c | ||
|
|
2441234dba | ||
|
|
1abc190f95 | ||
|
|
ab891a0840 | ||
|
|
76e6c3546f | ||
|
|
01911016d2 | ||
|
|
244a6ce79e | ||
|
|
f51f07d77e | ||
|
|
bd53efc4c4 | ||
|
|
317daf4c2b | ||
|
|
01990259af | ||
|
|
d7e69fac80 | ||
|
|
0d2e6ba87f | ||
|
|
8767200235 | ||
|
|
d977f1472b | ||
|
|
f6d6887b84 | ||
|
|
218bd880e1 | ||
|
|
4081b9c893 | ||
|
|
19c608fb31 | ||
|
|
980ce77192 | ||
|
|
4070c4e39e | ||
|
|
c21f0309ba | ||
|
|
8a37996617 | ||
|
|
5c2c273798 | ||
|
|
e22e942c27 | ||
|
|
03e17166ab | ||
|
|
e03215f445 | ||
|
|
80a3b27353 | ||
|
|
7801fad1b5 | ||
|
|
98b3d30ca0 | ||
|
|
d9088be2a6 | ||
|
|
8e70dd220a | ||
|
|
7b1030c1ba | ||
|
|
258be6e4e7 | ||
|
|
696e36a991 | ||
|
|
71e2f266ff | ||
|
|
6fd532beae | ||
|
|
5cc6ce9d32 | ||
|
|
7613726b1a | ||
|
|
c69327062b | ||
|
|
f21a334ca8 | ||
|
|
cd711c5d5c | ||
|
|
0f8652c083 | ||
|
|
93b2299630 | ||
|
|
d88a7de2eb | ||
|
|
68d101cc8d | ||
|
|
6127fef897 | ||
|
|
3cbdca6861 | ||
|
|
c491c329af | ||
|
|
801e904552 | ||
|
|
38c8f51315 | ||
|
|
b8676d0731 | ||
|
|
477014f64b | ||
|
|
0ee3e4759e | ||
|
|
b400e36d8f | ||
|
|
91bbfd5622 | ||
|
|
5356f24632 | ||
|
|
9c96e18672 | ||
|
|
ce655ce5db | ||
|
|
c0fea67442 | ||
|
|
8aa84a39bc | ||
|
|
2807ae6a47 | ||
|
|
c4281b08b7 | ||
|
|
919e749554 | ||
|
|
be9e381612 | ||
|
|
c14b4cb9ad | ||
|
|
5e5517135a | ||
|
|
b025bfda36 | ||
|
|
81c1bbb1e5 | ||
|
|
0e3ddfc1b1 | ||
|
|
af28161e2f | ||
|
|
f6187df51a | ||
|
|
34357846f4 | ||
|
|
810d9fc2dc | ||
|
|
131f0c4e06 | ||
|
|
28d2679b83 | ||
|
|
6b5e70e554 | ||
|
|
5c93ba9131 | ||
|
|
1bf74bdd4c | ||
|
|
d3b347aa09 | ||
|
|
21e0d7f383 | ||
|
|
35d92c0148 | ||
|
|
903e7db085 | ||
|
|
f68cd78f8b | ||
|
|
575187a678 | ||
|
|
c1c3c1ce9a | ||
|
|
1493fce319 | ||
|
|
5b7be2e28c | ||
|
|
f4003edc58 | ||
|
|
56300754b3 | ||
|
|
f1aaaee814 | ||
|
|
78fb99530d | ||
|
|
758eb69f55 | ||
|
|
3403a521eb | ||
|
|
461fb8ea15 | ||
|
|
6c966af5b2 | ||
|
|
81fcd9a817 | ||
|
|
8926f9b5f3 | ||
|
|
10794fa615 | ||
|
|
2490c78473 | ||
|
|
538a7df258 | ||
|
|
8b4e0ff5ca | ||
|
|
70b9823d5b | ||
|
|
6405dff7c5 | ||
|
|
1a5951ce98 | ||
|
|
cd1c2bc34d | ||
|
|
b70449b232 | ||
|
|
e49664ce55 | ||
|
|
87a37e987a | ||
|
|
fe2e179bdc | ||
|
|
73292ba74d | ||
|
|
f97574b57e | ||
|
|
2a06190cfe | ||
|
|
aa54e03d52 | ||
|
|
3b2afc9f47 | ||
|
|
f1d56b1040 | ||
|
|
3f84bc6568 | ||
|
|
deb6111419 | ||
|
|
a1f88b6553 | ||
|
|
b049db0162 | ||
|
|
a5dad549f8 | ||
|
|
496d27334a | ||
|
|
b434f6594b | ||
|
|
ad13692b5d | ||
|
|
b5aed111a0 | ||
|
|
e7c11c3163 | ||
|
|
c33e6cdc9d | ||
|
|
d51167550f | ||
|
|
ea97c8bc1b | ||
|
|
9c6aaed645 | ||
|
|
c43604d725 | ||
|
|
f36fea083b | ||
|
|
3afd4c6709 | ||
|
|
0aeb9086d6 | ||
|
|
c8c085b4e0 | ||
|
|
7374045065 | ||
|
|
927d6ceab8 | ||
|
|
ab0d69914f | ||
|
|
93af19e548 | ||
|
|
79a1194257 | ||
|
|
61634415a9 | ||
|
|
1bf0f654fb | ||
|
|
f05476f6db | ||
|
|
61e85ab501 | ||
|
|
c2bd096ef3 | ||
|
|
22f458606d | ||
|
|
969538e6c9 | ||
|
|
a0156ee946 | ||
|
|
4a658fc67e | ||
|
|
698bb78aeb | ||
|
|
4118efd0ca | ||
|
|
096e90c4c6 | ||
|
|
582453f0bd | ||
|
|
bad3bf4e79 | ||
|
|
7637e322ec | ||
|
|
2d70858887 | ||
|
|
53ac5d60d1 | ||
|
|
40e2812677 | ||
|
|
59d064f8c6 | ||
|
|
9267a9026f | ||
|
|
0cab094194 | ||
|
|
f35db2cd3b | ||
|
|
c503629bf9 | ||
|
|
10264c4ef6 | ||
|
|
e9877d78d8 | ||
|
|
8d19a0cc0a | ||
|
|
20f19b13f2 | ||
|
|
7513ac06bc | ||
|
|
5609b42dcd | ||
|
|
50ffc974db | ||
|
|
4418a840ec | ||
|
|
bb219203bc | ||
|
|
14b5e4137f | ||
|
|
4c3d524eff | ||
|
|
4c4f64323a | ||
|
|
8b46e535c5 | ||
|
|
7dd4b15804 | ||
|
|
cbb9961627 | ||
|
|
b567365460 | ||
|
|
9f76302e53 | ||
|
|
1dc97b4bf6 | ||
|
|
db897d50d6 | ||
|
|
27e689c771 | ||
|
|
5ce6de1e3b | ||
|
|
c6fcfcc6ea | ||
|
|
fafadfc6ec | ||
|
|
84aa0e17dd | ||
|
|
37c9a141d3 | ||
|
|
6833e01e1f |
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
# Author: fruit.com.cn
|
||||||
|
#
|
||||||
|
# 构建上下文里不需要的东西:产物、IDE、本地上传的媒资、日志。
|
||||||
|
# 注意别把 pom.xml / src / sql 排掉,镜像构建要用。
|
||||||
|
**/target/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
docs/
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Author: fruit.com.cn
|
||||||
|
#
|
||||||
|
# 环境变量样例:复制成 .env 后填真实值(.env 不要提交,已在 .gitignore 里)。
|
||||||
|
# 生产用 prod profile 时,下面标「必填」的项缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败。
|
||||||
|
# 详细说明见 DEPLOY.md。
|
||||||
|
|
||||||
|
# ── 数据库 ─────────────────────────────────────────────
|
||||||
|
# Docker Compose 单机部署:db 是 compose 里的服务名,保持默认即可;
|
||||||
|
# 若数据库在别的主机/用云托管,把 DB_URL 换成外部地址。
|
||||||
|
DB_NAME=fruitshorts
|
||||||
|
DB_URL=jdbc:postgresql://db:5432/fruitshorts
|
||||||
|
DB_USER=fruitshorts
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# ── 密钥(必填,各 32 位以上,且不能用代码里的 dev 默认值)──
|
||||||
|
# 生成:openssl rand -base64 48
|
||||||
|
JWT_ADMIN_SECRET=
|
||||||
|
JWT_APP_SECRET=
|
||||||
|
MEDIA_SIGNATURE_SECRET=
|
||||||
|
|
||||||
|
# ── 生产开关 ───────────────────────────────────────────
|
||||||
|
# prod profile 会强制 true(缺了签名则启动失败)
|
||||||
|
MEDIA_SIGNATURE_REQUIRED=true
|
||||||
|
# 后台前端来源,逗号分隔;生产不许用 *
|
||||||
|
CORS_ALLOWED_ORIGINS=https://admin.example.com
|
||||||
|
# 媒资根目录:容器里是挂载点
|
||||||
|
MEDIA_ROOT_DIR=/data/uploads
|
||||||
|
# 是否开放在线接口文档(**/doc.html 与 /v3/api-docs 一起开关**,没有"只留一个"的中间态)
|
||||||
|
# true = 前端联调能看到 /doc.html(测试/联调环境建议 true,10-19 前端联调需要它)
|
||||||
|
# false = 两个都关闭(生产若不希望暴露 UI 就设 false,机器可读契约也一并关闭)
|
||||||
|
SWAGGER_ENABLED=true
|
||||||
|
|
||||||
|
# ── 监控 ───────────────────────────────────────────────
|
||||||
|
# Grafana 管理员密码(必填):3000 端口对外开放,不允许用默认 admin/admin
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# ── 可选:Google Play 商店票据校验(不配则保持 disabled)──
|
||||||
|
STORE_VERIFIER=disabled
|
||||||
|
GOOGLE_PLAY_PACKAGE_NAME=
|
||||||
|
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=
|
||||||
@@ -16,3 +16,10 @@ logs/
|
|||||||
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
|
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
|
||||||
data/uploads/*
|
data/uploads/*
|
||||||
!data/uploads/.gitkeep
|
!data/uploads/.gitkeep
|
||||||
|
|
||||||
|
# 部署用的真实环境变量(含密钥),只提交 .env.example
|
||||||
|
.env
|
||||||
|
dump.rdb
|
||||||
|
|
||||||
|
# 看门狗飞书告警 Webhook,只存在于部署机
|
||||||
|
deploy/feishu-webhook.txt
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Author: fruit.com.cn
|
||||||
|
#
|
||||||
|
# DEPLOY-CHECKLIST —— 部署合规视角(specs 约束逐条结论 + 上线检查清单对照 + 风险/待确认)
|
||||||
|
#
|
||||||
|
# 这份文档回答两个问题:
|
||||||
|
# 1) `docs/04-部署架构`、`docs/08-备份恢复方案`、`docs/10-安全方案`、`docs/11-发布与回滚方案`、
|
||||||
|
# `docs/12-上线检查清单`、`docs/07-监控告警方案`、`GIT_WORKFLOW.md`、`CODING_STANDARDS.md`
|
||||||
|
# 这些 specs 里跟部署有关的要求,**我们现在满足到哪一步**;
|
||||||
|
# 2) 上线前**必须补什么**、谁来补。
|
||||||
|
#
|
||||||
|
# 操作层面的"怎么部署/怎么回滚命令"在 `DEPLOY.md`(本文件只做结论与对照,避免两份口径分叉)。
|
||||||
|
# 结论用三态:✅ 已满足 | ⚠️ 部分满足(能用但有差距)| ❌ 未满足(上线前必补)
|
||||||
|
|
||||||
|
## 一、环境映射(对应 `04-部署架构`§一)
|
||||||
|
|
||||||
|
`04-部署架构`§一把环境分成四级:
|
||||||
|
|
||||||
|
| 环境 | 用途 | 数据 | 访问 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| dev | 本地开发 | 本地 PostgreSQL | 内网 |
|
||||||
|
| test | 测试/联调 | 独立测试库 | 内网/VPN |
|
||||||
|
| pre | 预发(生产镜像演练) | 接近生产 | 受限 |
|
||||||
|
| prod | 生产 | 生产库 | 公网 |
|
||||||
|
|
||||||
|
**当前这台办公室主机 = `test` 环境**,依据三条:
|
||||||
|
|
||||||
|
1. 用途是"给前端联调 + 内部验收",不是对外生产(`04-部署架构`§一 test = 测试/联调);
|
||||||
|
2. 访问方式计划是内网/办公网(`admin-api` 只绑 `127.0.0.1`),而不是公网;
|
||||||
|
3. 用的是独立的开发库(`fruitshorts`,含种子数据),不是生产库。
|
||||||
|
|
||||||
|
规范要求与我们的对应:
|
||||||
|
|
||||||
|
- `docker-compose.yml` 用 **Docker Compose 单机编排** → 对应 §五「第一阶段(dev/test):Docker Compose 单机编排」,✅。
|
||||||
|
- 一台机器上合并 app/api/DB/Redis + worker 独立容器 → 对应 §三「精简方案:test 环境可将 app/api/DB/Redis 合并到 1 台 8C32G,转码 worker 独立 1 台」,✅(worker 是独立容器,但**与在线服务同机**:资源隔离靠容器限额,真正的分机留到生产)。
|
||||||
|
- **差距**:`pre` 环境(生产镜像演练)**尚未建立** —— 上线前建议在目标机用同一份 compose 跑一次"预发演练",即用生产镜像 + 接近生产的数据走一遍发布流程。状态:❌(流程未建立,不阻塞 test 联调)。
|
||||||
|
|
||||||
|
## 二、域名 / HTTPS / 反向代理(对应 `04-部署架构`§四 + `10-安全方案`§二)
|
||||||
|
|
||||||
|
**specs 原文**:`04-部署架构`§四「域名/SSL:管理端、客户端 API、媒资**分别域名**;**HTTPS 强制**」;
|
||||||
|
`10-安全方案`§二「传输安全:HTTPS 强制;密钥环境变量注入」。
|
||||||
|
|
||||||
|
**当前状态:❌ 未满足**。现在的形态是 `IP:端口 + HTTP`:
|
||||||
|
|
||||||
|
| 需要的域名 | 现状 | 上线前必须做 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 管理端(后台 Web + `/admin/**`) | 无域名;`admin-api` 绑 `127.0.0.1:8080`,靠内网/SSH 隧道访问 | 申请域名(如 `admin.<domain>`)+ 证书;nginx 反代 → `127.0.0.1:8080`;**并保持只允许内网/VPN 来源**(见 §三) |
|
||||||
|
| 客户端 API(`/app/**`) | 无域名;`app-api` 监听 `8081`(HTTP) | 申请域名(如 `api.<domain>`)+ 证书;nginx 反代 → `8081`,只暴露 `/app/**`;**强制 HTTP→HTTPS 跳转** |
|
||||||
|
| 媒资(`/files/**`) | 无域名;与 app-api 同一端口 | 申请域名(如 `media.<domain>`)或直接走 CDN 域名;**播放地址已带签名参数**,CDN 需按签名做缓存策略(`04-部署架构`§四) |
|
||||||
|
|
||||||
|
**测试环境(本机)可以先用内网 HTTP 联调**(`04-部署架构`§一 test 是内网/VPN 访问),
|
||||||
|
但**上线(pre/prod)前必须满足上述三条 HTTPS + 独立域名**,否则不满足 `12-上线检查清单`§三「传输安全」。
|
||||||
|
|
||||||
|
### nginx + 证书的建议做法(上线前照做)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /etc/nginx/conf.d/fruitshorts.conf(示意;域名与证书路径按实际替换)
|
||||||
|
server { # 客户端 API
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name api.example.com;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
|
||||||
|
client_max_body_size 20m; # 分片上传,与 admin-api 的 multipart 上限一致
|
||||||
|
location / { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme; }
|
||||||
|
}
|
||||||
|
server { # 管理端:只允许内网/VPN 网段
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name admin.example.com;
|
||||||
|
allow 10.0.0.0/8; allow 192.168.0.0/16; deny all; # 按实际内网段调整
|
||||||
|
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme; }
|
||||||
|
}
|
||||||
|
server { # HTTP 强制跳 HTTPS
|
||||||
|
listen 80;
|
||||||
|
server_name api.example.com admin.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
证书建议用 **Let's Encrypt(certbot)** 自动续期(`certbot --nginx -d api.example.com -d admin.example.com`),
|
||||||
|
或公司自有证书;无论哪种,**到期前必须能自动续**,否则接口会整体不可用。
|
||||||
|
反代配置文件需与本仓库一起纳入版本管理(放目标机的 `/etc/nginx/conf.d/`,另存一份到运维仓库)。
|
||||||
|
|
||||||
|
## 三、网络分区(对应 `04-部署架构`§四)
|
||||||
|
|
||||||
|
**specs 原文**:「网络分区:管理后台仅内网/VPN 可访问;客户端 API 公网。」
|
||||||
|
|
||||||
|
| 要求 | 现状 | 上线前必须做 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 管理后台仅内网/VPN | ✅ 已满足:`docker-compose.yml` 里 admin-api 端口映射为 `127.0.0.1:8080:8080`(只绑本机,公网访问不到) | 上公网后仍要保证:只经 nginx + `allow/deny` 内网段暴露(见 §二示例) |
|
||||||
|
| 客户端 API 公网 | ⚠️ 部分满足:`app-api` 监听 `0.0.0.0:8081`,同网段可访问;**但还没有反代/域名,也没限制只暴露 `/app/**`** | 反代只放行 `/app/**`(`/files/**` 走媒资域名/CDN);不要把 `/v3/api-docs`、`/healthz` 直接暴露到公网(`/healthz` 可留给内网监控) |
|
||||||
|
|
||||||
|
**注意**:`/doc.html` 与 `/v3/api-docs` 由 `SWAGGER_ENABLED` 控制(见 `DEPLOY.md`)。
|
||||||
|
**联调环境建议 `true`**(前端要看),**公网生产建议 `false`**(不暴露接口清单)。
|
||||||
|
|
||||||
|
## 四、发布与回滚规范(对应 `11-发布与回滚方案`§一/§四 + `GIT_WORKFLOW.md`§四)
|
||||||
|
|
||||||
|
**specs 原文**:
|
||||||
|
|
||||||
|
- `11-发布与回滚方案`§一:「语义化版本 `v<major>.<minor>.<patch>`;分支 `main`(生产)← `release/*` ← `develop` ← `feature/*`;每次 `release`/`hotfix` 合并 `main` 必须打 tag,tag 不可移动/删除。」
|
||||||
|
- `GIT_WORKFLOW.md`§四:「`major` 破坏性变更、`minor` 新功能、`patch` 修复;发布后 tag 不可移动、不可删除(错误版本发新 tag 修正,不覆盖旧 tag)。」
|
||||||
|
- `11-发布与回滚方案`§四:「回滚演练:**上线前演练回滚**,确保可执行。」「数据库迁移脚本必须可回滚(向前兼容,避免破坏性变更)。」
|
||||||
|
|
||||||
|
| 要求 | 现状 | 上线前必须做 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 语义化 tag `v<x.y.z>` | ⚠️ 部分满足:`DEPLOY.md` 里只是示例式地提到"每次发版打 tag",**没有命名规范** | 按 §四 规则打:首个上线版本 `v1.0.0`;之后功能 `v1.1.0`、修复 `v1.0.1`;**错误版本发新 tag,绝不移动/删除旧 tag** |
|
||||||
|
| release/hotfix 合并 main | ⚠️ 流程未走过:目前工作都在 `develop`,`main` 未动 | 上线时:`develop` → 建 `release/v1.0.0` → 在 test 环境验证 → 合并 `main` + 打 tag `v1.0.0`(**由用户操作,Codex 不碰 main**) |
|
||||||
|
| 回滚可执行 | ✅ 技术路径已写全(`DEPLOY.md`§七:切回上一个镜像 tag / git 回上一个 release tag + `up -d --build` + 重跑冒烟) | **上线前演练一次**(在 test 环境故意回滚到上一版本并跑 `tools/smoke.sh`),把演练记录留档 |
|
||||||
|
| 数据库迁移可回滚 | ⚠️ 部分满足:`sql/*.sql` 全是幂等增量(`ADD COLUMN IF NOT EXISTS`、`CREATE TABLE IF NOT EXISTS`),**没有破坏性变更**;`upsert` 语义的种子脚本也在 | 上线前确认:本次发布**不包含** `DROP/ALTER TYPE` 类破坏性语句(当前没有);如将来有,必须先写"回滚脚本 + 备份"再发 |
|
||||||
|
| 灰度/放量 | ❌ 未满足(单机 Compose 无灰度能力) | 上线首日按"先内测 → 再小流量(如有网关可切)";正式灰度需要 K8s/网关,属后续阶段(`04-部署架构`§五:生产上 K8s) |
|
||||||
|
|
||||||
|
## 五、备份与恢复(对应 `08-备份恢复方案`§二/§三)
|
||||||
|
|
||||||
|
**specs 目标**:全量每日 × 保持 30 天;**WAL 归档(实时)**;可选增量;**备份加密 + 异地存储**;
|
||||||
|
**RPO ≤ 5 分钟、RTO ≤ 30 分钟**;**每季度至少一次恢复演练**。
|
||||||
|
|
||||||
|
| 项 | specs 要求 | 现状 | 差距 / 上线前必须补 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 全量备份 | 每日 1 次,保留 30 天 | ⚠️ `DEPLOY.md`§七 给了**手动** `pg_dump` 命令;**没有定时任务、没有保留策略** | 目标机加 cron(或 compose 侧容器)每日 `pg_dump`,按日期命名保留 30 天;脚本可放 `tools/backup.sh`(本轮未写,属运维脚本) |
|
||||||
|
| WAL 归档 | 实时,用于 PITR | ❌ 未配置(PG 默认 `archive_mode=off`) | `postgresql.conf`:`wal_level=replica`、`archive_mode=on`、`archive_command` 推到异地;这是达成 **RPO≤5min** 的唯一途径 |
|
||||||
|
| 增量 | 可选 | ❌ 未做 | 有了 WAL 归档即可满足 RPO,增量备份可后补 |
|
||||||
|
| 加密 | 备份加密 | ❌ 未做 | `pg_dump` 管道给 `age`/`gpg`,或落到加密卷;密钥单独保管(与备份分开) |
|
||||||
|
| 异地 | 与生产不同可用区/账号 | ❌ 未做(本机 dump 还在同一台机器上) | 推到对象存储(另一账号/另一区);**"备份和数据库在同一台机器上"等于没备份** |
|
||||||
|
| 恢复演练 | 每季度 ≥ 1 次,记录归档 | ❌ 从未做过 | 首次上线前做一次"恢复到新库 + 起服务 + 冒烟",记录耗时(验 RTO)与丢失窗口(验 RPO) |
|
||||||
|
|
||||||
|
**结论:备份恢复这一项与 specs 差距最大,是上线前必须补的硬缺口(❌)。**
|
||||||
|
当前只有"能手动 dump",距离「RPO≤5min / RTO≤30min / 加密 / 异地 / 季度演练」还差整套机制。
|
||||||
|
建议排期:**上线前**至少做到「每日全量 + 异机保存 + 一次恢复演练」;**上线后两周内**补 WAL 归档与加密。
|
||||||
|
|
||||||
|
## 六、监控与告警现状(对应 `07-监控告警方案`§七)
|
||||||
|
|
||||||
|
**specs 原文**:`07-监控告警`§七「Prometheus/Loki/Grafana 尚未部署,**第一阶段先用日志 + `SlowRequestLogger` 兜底**」;
|
||||||
|
§六「告警渠道:飞书/钉钉/邮件 + 值班轮转」;§六「值班表与 Runbook 见 `docs/plans/`(上线前补)」。
|
||||||
|
|
||||||
|
| 项 | 现状 | 上线前必须补 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 指标(Prometheus) | ❌ 未部署;**项目也没引 actuator**(`/actuator/health` 一直是未映射,见 `DEPLOY.md`§五说明) | 按 §七 的既定路线部署;要暴露指标需先给两个入口加 `spring-boot-starter-actuator` + `micrometer-registry-prometheus`(**属代码改动,需立项**) |
|
||||||
|
| 日志聚合(Loki) | ❌ 未部署;现在日志在容器 stdout(`docker compose logs`) | 部署 Loki + Promtail 采集容器日志 |
|
||||||
|
| 大盘(Grafana) | ❌ 未部署 | 按 §五 大盘定义建面板 |
|
||||||
|
| 第一阶段兜底 | ✅ 已具备:应用结构化日志(转码耗时/失败原因、业务异常、`SlowRequestLogger` 慢请求告警都在代码里)+ 容器级 `healthcheck`(`/healthz`) | 至少把"容器 unhealthy"接到人的手机上(可用 `docker compose events` + 简单脚本/飞书机器人) |
|
||||||
|
| 告警渠道 + 值班 + Runbook | ❌ 未定 | 定渠道(飞书/钉钉/邮件)、排值班表、把 `DEPLOY.md`§九「故障速查」扩成 Runbook |
|
||||||
|
|
||||||
|
**结论:监控告警 ❌ 未满足**,但按 specs §七 的既定节奏,**第一阶段允许先用日志兜底** ——
|
||||||
|
前提是"告警能到达人"。上线前最低要求:**容器 unhealthy / 冒烟失败能被发现**(哪怕先是人工每天跑一次 `tools/smoke.sh`)。
|
||||||
|
|
||||||
|
## 七、密钥托管与轮换(对应 `10-安全方案`§五)
|
||||||
|
|
||||||
|
**specs 原文**:§五「密钥托管:生产密钥**集中托管**,**最小权限**」;`12-上线检查清单`§三「JWT/签名密钥走环境变量,未硬编码/入库」「生产密钥集中托管」。
|
||||||
|
|
||||||
|
| 要求 | 现状 | 上线前必须补 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 密钥走环境变量、未硬编码/入库 | ✅ 已满足:三个可运行件的密钥全部 `${ENV:...}` 注入;`.env` 已 gitignore;`.env.example` 只有占位;镜像里不含密钥 | 继续保持;**代码审查时禁止把密钥写回 yml** |
|
||||||
|
| 缺失即启动失败 | ✅ 已满足:`ProdSecretGuard` 在 `prod` profile 下校验三个密钥 + 签名开关 + DB_URL + CORS,缺失/仍是 dev 默认值/不足 32 位 → 退出码 1 | — |
|
||||||
|
| **集中托管** | ⚠️ 部分满足:现在是"目标机的 `.env` 文件"(文件权限 600),**没有密钥管理系统** | 短期:`.env` 权限 600、只有运维账号可读、**不进任何 git 仓库**、单独备份(加密);中期:迁到 K8s Secret / 云 KMS / Vault(`04-部署架构`§五 生产路线也是 K8s Secret) |
|
||||||
|
| 最小权限 | ⚠️ 部分满足:DB 用单一 `fruitshorts` 账号(读写全库) | 拆账号:应用用 `fruitshorts_app`(DML 权限、无 DDL);备份用只读账号;DDL 只留给运维 |
|
||||||
|
| 轮换 | ❌ 未做(也没有流程) | 定义轮换周期(建议 90 天或按事件):**先加新密钥 → 双密钥并行验证期 → 再下线旧密钥**;JWT 轮换会导致存量 token 失效,需选低峰并公告(`10-安全方案`§一 token 有效期 120min,影响窗口可控) |
|
||||||
|
|
||||||
|
## 八、上线检查清单对照表(逐条映射 `docs/12-上线检查清单.md`)
|
||||||
|
|
||||||
|
> 三态:✅ 已满足 | ⚠️ 部分满足 / 有条件满足 | ❌ 未满足(上线前必补)
|
||||||
|
> 「责任方」列区分:**Codex**(我能做的)/ **用户或运维**(需要账号、域名、外部资源)。
|
||||||
|
|
||||||
|
### 一、代码与门禁
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 三端门禁全绿 | ⚠️ | **后端 ✅**:`mvn test` 83 条全过;**前端(Flutter/Web)不在本工程范围**,由另一同事的 AI 负责,需其提供门禁结果 | 用户/前端 |
|
||||||
|
| 接口契约 `api-phase1.md` 已同步 | ✅ | 本轮 B1-1/B1-2 的 verify / reconcile 改动都已同步进契约 | Codex |
|
||||||
|
| 作者标识齐全 | ✅ | 远端已做全量补齐(`35be37c`),本轮新增文件也都带 `// Author: fruit.com.cn` | Codex |
|
||||||
|
| 代码规模红线、无关键路径 TODO | ⚠️ | 未做静态检查工具(后端无 checkstyle/pmd),**存量未逐文件核对**;无 TODO 扫描证据 | Codex(需立项) |
|
||||||
|
|
||||||
|
### 二、功能与测试
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 核心用例通过、P0 清零 | ⚠️ | 后端 83 条(含真实 ffmpeg smoke)全过;**前端与端到端用例不在本工程** | Codex/用户 |
|
||||||
|
| 支付闭环端到端 | ⚠️ | 票据校验/幂等/退款回收/对账**已实现并有单测**;**真实 Google Play 未联调**(缺 packageName + service account JSON,属付费凭据) | 用户(凭据)+ Codex(联调) |
|
||||||
|
| 播放长稳 + 弱网测试 | ❌ | 未做(需真机 + 网络模拟) | 前端/用户 |
|
||||||
|
| 多端(iOS/Android)回归 | ❌ | 未做(Android 无可用模拟器,iOS 有真机) | 前端/用户 |
|
||||||
|
|
||||||
|
### 三、安全
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 播放签名 `signature-required=true`(生产必开) | ✅ | `application-prod.yml` 写死 `true`,`ProdSecretGuard` 再校验一次,缺了直接拒启 | Codex |
|
||||||
|
| JWT/签名密钥走环境变量,未硬编码/入库 | ✅ | 三件全 `${ENV:...}`;`.env` gitignore;镜像不含密钥 | Codex |
|
||||||
|
| 越权/注入/敏感数据专项测试 | ⚠️ | **越权**:创作者中心有专项用例(B 批);**注入/SQL 注入**:全部走 MyBatis-Plus 参数绑定,无字符串拼接 SQL(人工核对),**无自动化用例**;敏感数据脱敏有实现(`AppIdentifier.mask`)但无专项测试 | Codex(需立项) |
|
||||||
|
| 生产密钥集中托管 | ⚠️ | 见 §七:现为 `.env` 文件权限隔离,未接 KMS/Vault | 用户/运维 |
|
||||||
|
|
||||||
|
### 四、基础设施
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 数据库分区已生效 | ✅ | `watch_history`/`playback_ticket`/`order_event` 均为声明式月分区,`partition-maintenance` 启动时+每日补齐。**实测证据**:`select tableoid::regclass, count(*) from playback_ticket group by 1` → `playback_ticket_202610 \| 1`(落月分区,不是 `_default`);`select count(*) from watch_history_default` → `0` | Codex |
|
||||||
|
| Redis/缓存(如接入)配置正确 | ⚠️ | compose 已备 Redis 容器,**代码尚未接入(C2 待做)**;当前不依赖 Redis | Codex(C2) |
|
||||||
|
| 媒资已迁 OSS/S3 + CDN(生产) | ❌ | **仍是本地磁盘 `./data/uploads`,与在线服务同机**;`MediaStorageService` 已把存储收敛到一处,切换 OSS 只需改它 + 转码产物上传路径 | 用户(账号)+ Codex |
|
||||||
|
| 备份恢复已配置并演练 | ❌ | 见 §五,差距最大 | 用户/运维 + Codex(脚本) |
|
||||||
|
| 监控告警已接入、告警可达 | ❌ | 见 §六 | 用户/运维 |
|
||||||
|
|
||||||
|
### 五、商店与合规
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 应用名称/截图/文案/隐私标签一致 | ❌ | 未办(客户端上架材料) | 用户 |
|
||||||
|
| App Privacy / Data Safety / SDK 清单 | ❌ | 未办 | 用户 |
|
||||||
|
| 隐私政策、用户协议已上线 | ❌ | 未办(后端有 `app_user_consent` 落库能力,文案与页面需法务/前端) | 用户 |
|
||||||
|
| 版权/素材/商标授权台账 | ❌ | 未办 | 用户 |
|
||||||
|
| 商店账号/证书/包名有效 | ❌ | 未办;**并且包名仍不一致**(Android `com.stormg.lumadrama` / iOS `com.stormg.lumadramv`,见 C 端回执) | 用户(建议上架前统一) |
|
||||||
|
|
||||||
|
### 六、发布与回滚
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 版本号 + tag 冻结(`v1.0.0`) | ❌ | 尚未打 tag(规范见 §四) | 用户(合并 main 时打) |
|
||||||
|
| 回滚方案已演练 | ❌ | 方案已写(`DEPLOY.md`§七),**未演练** | 用户/运维 |
|
||||||
|
| 值班表 + 告警 Runbook 就绪 | ⚠️ | `DEPLOY.md`§九「故障速查」可作为 Runbook 初稿;**值班表未定** | 用户 |
|
||||||
|
| 灰度/放量计划明确 | ❌ | 单机 Compose 无灰度能力;生产上 K8s 后再做 | 用户 |
|
||||||
|
|
||||||
|
### 七、观察窗口(上线后)
|
||||||
|
|
||||||
|
| 检查项 | 状态 | 依据 / 缺口 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 核心链路监控 | ❌ | 监控未接;**上线首日建议人工每 30 分钟跑一次 `tools/smoke.sh` + 看 `docker compose logs`** 作为过渡 | 用户/运维 |
|
||||||
|
| 支付/订单对账数据回看 | ⚠️ | 对账能力已在(`/admin/reconcile/run`,`storeAmountSum` 无数据源时返回 null + notes),**真实商店数据要等凭据** | Codex/用户 |
|
||||||
|
| 崩溃率/错误率观察 | ❌ | 未接监控;容器日志可人工查 | 用户/运维 |
|
||||||
|
| 问题复盘归档 | ⚠️ | 本工程有 PROGRESS/回执留痕习惯,可沿用 | Codex/用户 |
|
||||||
|
|
||||||
|
## 九、风险 / 待确认(`CODING_STANDARDS`§7 要求:技术决策必须留痕)
|
||||||
|
|
||||||
|
| # | 风险 / 待确认 | 影响 | 建议与责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| R1 | **媒资仍在本机磁盘**(`./data/uploads`),未迁 OSS/CDN | 单机磁盘满/机器损坏即媒资全丢;播放无法借 CDN 扛量 | `12-上线检查清单`§四把"迁 OSS/CDN"列为上线项,**属上线前必补**;`MediaStorageService` 已是唯一替换点,切换工作量可控(Codex 可做,需 OSS 账号) |
|
||||||
|
| R2 | **单点无高可用**:一台办公室主机跑全栈 | 机器/网络故障 = 服务全停;无灰度能力 | test 阶段可接受;**生产必须按 `04-部署架构`§三 生产规格(应用 2+、DB 主备)+ §五 上 K8s** |
|
||||||
|
| R3 | **备份未演练、无 WAL 归档、备份与库同机** | 真出事时可能既恢复不了也丢得多(RPO 远大于 5 分钟) | 上线前至少"每日全量 + 异机保存 + 一次恢复演练";两周内补 WAL 归档与加密(见 §五) |
|
||||||
|
| R4 | **监控与告警未接** | 故障靠人发现;上线首日风险最高的窗口无自动告警 | 短期用"人工冒烟 + 容器 healthcheck";随后按 `07-监控告警方案`部署(需先给服务加 actuator,属代码改动) |
|
||||||
|
| R5 | **商店合规未办**(隐私政策/Data Safety/SDK 清单/版权台账/包名) | 无法上架;印尼还涉及 Kominfo PSE 注册(未注册可能被整体封锁,见 `docs/08`/PRD 合规节) | 全部为用户侧工作;其中"包名统一"建议在上架前完成(一旦上架不可改) |
|
||||||
|
| R6 | **真实 Google Play 联调未做**(缺 packageName + service account JSON) | 支付闭环在真机/商店侧未验证;`STORE_VERIFIER=disabled` 时对外明确返回"尚未配置",不会产生假订单 | 用户提供凭据后,Codex 用沙盒测试购买联调(`STORE_VERIFIER=google-play`) |
|
||||||
|
| R7 | **HTTPS/域名未就绪**,当前 IP+HTTP | 不满足 `04-部署架构`§四与 `10-安全方案`§二;公网明文传输不可接受 | 上线前按 §二 配域名 + 证书 + nginx(用户/运维) |
|
||||||
|
| R8 | **DB 账号未做最小权限**(单账号读写全库) | 应用被攻破即拥有 DDL 能力 | 拆 `fruitshorts_app`(仅 DML)与备份只读账号(见 §七) |
|
||||||
|
| R9 | **`/swagger-ui/**` 静态资源在 `SWAGGER_ENABLED=false` 时仍可访问** | 不泄露接口信息(spec 与 config 都已 404),但严格合规环境下应彻底关闭 | 可选加固:`spring.web.resources` 排除 webjar;需你点头再改(Codex) |
|
||||||
|
| R10 | 转码 worker 与在线服务**同机**(虽为独立容器) | 大批量转码会抢 CPU,影响播放稳定性 | `04-部署架构`§三 的完整方案是 worker 独立 1 台;上线前如有第二台机器就拆开,否则给 worker 容器加 CPU 限额(Codex 可改 compose) |
|
||||||
|
|
||||||
|
## 十、上线前必补清单(按优先级)
|
||||||
|
|
||||||
|
| 优先级 | 事项 | 依赖 | 责任方 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P0 | HTTPS + 独立域名 + nginx 反代(§二) | 域名、证书 | 用户/运维 |
|
||||||
|
| P0 | 每日全量备份 + 异机保存 + 一次恢复演练(§五) | 目标机 + 异地存储 | 用户/运维(Codex 可提供脚本) |
|
||||||
|
| P0 | 媒资迁 OSS/CDN(R1) | 云账号 | 用户 + Codex |
|
||||||
|
| P1 | tag 规范落地 + 回滚演练(§四) | — | 用户(合并 main 时)/ Codex 配合 |
|
||||||
|
| P1 | 监控告警最低可用(§六:容器 unhealthy 能到人) | 通知渠道 | 用户/运维 |
|
||||||
|
| P1 | 真实 Google Play 联调(R6) | 付费凭据 | 用户 → Codex |
|
||||||
|
| P2 | DB 最小权限账号(R8)、密钥集中托管(§七) | 运维 | 用户/运维 |
|
||||||
|
| P2 | K8s 生产化(R2)、灰度能力、WAL 归档与加密 | 生产资源 | 后续阶段 |
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# FruitShorts 后端部署与回滚(DEPLOY)
|
||||||
|
|
||||||
|
> 依据:`docs/04-部署架构.md`(第一阶段 **Docker Compose 单机**、镜像即交付物;生产才上 K8s)、
|
||||||
|
> `docs/11-发布与回滚方案.md`、`docs/12-上线检查清单.md`(**播放签名生产必开**、密钥走环境变量)。
|
||||||
|
> 目标机形态:**办公室一台主机**(§三 精简方案:app/api/DB/Redis 合并 1 台 8C32G,转码 worker 独立进程)。
|
||||||
|
|
||||||
|
> **本文只讲"怎么部署/怎么回滚"**。要回答"我们离 specs 还差什么、上线前必补什么",
|
||||||
|
> 看 **[DEPLOY-CHECKLIST.md](./DEPLOY-CHECKLIST.md)**(环境映射、域名/HTTPS、备份恢复、监控、发布与回滚规范、
|
||||||
|
> `docs/12-上线检查清单` 逐条对照表、风险/待确认)。
|
||||||
|
|
||||||
|
**这台办公室主机属于 `test` 环境**(`04-部署架构`§一:测试/联调、独立测试库、内网/VPN 访问);
|
||||||
|
`pre`/`prod` 的规格与前置条件见 `DEPLOY-CHECKLIST.md` §一。
|
||||||
|
|
||||||
|
## 一、两条部署路径(先选一条)
|
||||||
|
|
||||||
|
| 路径 | 适用 | 命令主线 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A. Docker Compose(推荐,文档口径)** | 目标主机装了 Docker + Compose;一条命令起全栈(含 PG 18 与 Redis) | 见 §三 |
|
||||||
|
| **B. 裸 jar(兜底)** | 主机没有 Docker,或只想起单个进程调试 | 见 §四 |
|
||||||
|
|
||||||
|
> 两条路径的**配置完全相同**(同一批环境变量),只是"谁来跑进程"不同。
|
||||||
|
|
||||||
|
## 二、环境变量清单
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env` 填值(`.env` 已在 `.gitignore`,**不要提交**)。
|
||||||
|
使用 `--spring.profiles.active=prod`(Compose 里已默认)时,下表标 🔴 的项**缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败**(fail-fast,见 §五)。
|
||||||
|
|
||||||
|
| 变量 | 必填 | 缺失后果 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `DB_URL` | ✅ | 用默认 `jdbc:postgresql://127.0.0.1:5432/fruitshorts`,连不上就启动失败 | Compose 里默认指向 `db` 服务 |
|
||||||
|
| `DB_USER` / `DB_PASSWORD` | ✅ | 连库失败 | Compose 的 db 容器用同一组值创建库 |
|
||||||
|
| `DB_NAME` | Compose 必填 | 默认 `fruitshorts` | 只在 compose 里用到 |
|
||||||
|
| `JWT_ADMIN_SECRET` | 🔴 | **prod 拒绝启动** | 管理端令牌密钥,≥32 位,生成:`openssl rand -base64 48` |
|
||||||
|
| `JWT_APP_SECRET` | 🔴 | **prod 拒绝启动** | 客户端令牌密钥。**过去这个键没外置,一直用代码里的 dev 默认值**,务必注入 |
|
||||||
|
| `MEDIA_SIGNATURE_SECRET` | 🔴 | **prod 拒绝启动** | 播放地址签名密钥(`docs/12-上线检查清单`) |
|
||||||
|
| `MEDIA_SIGNATURE_REQUIRED` | 🔴 | prod 下不是 `true` 就拒绝启动 | 生产必须开;本地调试保持 false |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | 🔴 | prod 下为空则拒绝启动 | 逗号分隔的正式来源,**生产不许用 `*`** |
|
||||||
|
| `MEDIA_ROOT_DIR` | 建议 | 默认 `./data/uploads`,容器里必须为 `/data/uploads` | 媒资根目录(挂载卷) |
|
||||||
|
| `SERVER_PORT` | Compose 已设 | 默认 8081/8080 | app-api 8081 / admin-api 8080 |
|
||||||
|
| `SWAGGER_ENABLED` | 可选 | 默认 false(**测试/联调请在 `.env` 里设 `true`**,前端要看 `/doc.html`) | **一次开关两个**:`true` = `/doc.html` + `/v3/api-docs` 都开;`false` = 两个都关(生产不想暴露 UI 就设 false,机器可读契约也一并关闭) |
|
||||||
|
| `STORE_VERIFIER` / `GOOGLE_PLAY_*` | 可选 | 默认 `disabled` | 商店票据校验,没凭据就保持 disabled(对外返回"尚未配置") |
|
||||||
|
|
||||||
|
**密钥怎么生成**:`openssl rand -base64 48`(每个密钥单独生成,别复用)。
|
||||||
|
|
||||||
|
## 三、路径 A:办公室单机 + Docker Compose(主线)
|
||||||
|
|
||||||
|
目标主机前置:**Docker + Docker Compose**(`docker compose version` 有输出即可),建议 8C32G。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0) 取代码(或拿打包好的目录)并进入 FruitServer
|
||||||
|
cd FruitServer
|
||||||
|
|
||||||
|
# 1) 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
openssl rand -base64 48 # 生成三个密钥,分别填进 JWT_ADMIN_SECRET / JWT_APP_SECRET / MEDIA_SIGNATURE_SECRET
|
||||||
|
vi .env # 还要填 DB_PASSWORD、CORS_ALLOWED_ORIGINS
|
||||||
|
|
||||||
|
# 2) 构建并启动(首次会自动建表 + 造种子数据)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 3) 看状态与日志
|
||||||
|
docker compose ps # 预期:db/redis healthy,app-api/admin-api healthy,transcode-worker running
|
||||||
|
docker compose logs -f app-api # 预期:出现 "生产配置校验通过" 与 "Started AppApiApplication"
|
||||||
|
|
||||||
|
# 4) 冒烟
|
||||||
|
tools/smoke.sh # 预期:全部 PASS,退出码 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**端口与访问**:app-api `8081`(对客户端开放)、admin-api **只绑 `127.0.0.1:8080`**(管理端仅内网,公网通过 SSH 隧道或反代访问)、PG/Redis 不对宿主暴露。
|
||||||
|
|
||||||
|
**数据库初始化**:`sql/*.sql` 已挂进 `docker-entrypoint-initdb.d`(schema → schema-phase1 → partition → data → data-phase1),
|
||||||
|
**只在数据卷为空时执行**。改 SQL 后要重来:`docker compose down -v`(**会删数据**)再 `up`。
|
||||||
|
|
||||||
|
**日常操作**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps # 状态
|
||||||
|
docker compose logs -f --tail=200 # 全部日志
|
||||||
|
docker compose restart app-api # 重启单个服务
|
||||||
|
docker compose up -d --build app-api # 只重建单个镜像
|
||||||
|
docker compose down # 停(保留数据卷)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、路径 B:裸 jar(兜底)
|
||||||
|
|
||||||
|
目标主机前置:**JDK 21**;转码进程额外需要 **ffmpeg/ffprobe**(`brew install ffmpeg` 或 `apt install ffmpeg`)。
|
||||||
|
数据库:自己装 PostgreSQL 18 并导入 `sql/*.sql`(顺序同上)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd FruitServer
|
||||||
|
mvn -pl fruitshorts-app-api,fruitshorts-admin-api,fruitshorts-transcode-worker -am package -DskipTests
|
||||||
|
|
||||||
|
# 导出环境变量(示例;密钥务必换真实值)
|
||||||
|
export DB_URL=jdbc:postgresql://127.0.0.1:5432/fruitshorts DB_USER=fruitshorts DB_PASSWORD='***'
|
||||||
|
export JWT_ADMIN_SECRET="$(openssl rand -base64 48)" # 生产请固定保存,不要每次重新生成
|
||||||
|
export JWT_APP_SECRET="$(openssl rand -base64 48)"
|
||||||
|
export MEDIA_SIGNATURE_SECRET="$(openssl rand -base64 48)"
|
||||||
|
export MEDIA_SIGNATURE_REQUIRED=true CORS_ALLOWED_ORIGINS='https://admin.example.com'
|
||||||
|
export MEDIA_ROOT_DIR="$PWD/data/uploads"
|
||||||
|
|
||||||
|
# 起三个进程(生产用 prod profile;本地调试去掉 --spring.profiles.active=prod 即可)
|
||||||
|
nohup java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar \
|
||||||
|
--spring.profiles.active=prod > /tmp/admin.log 2>&1 &
|
||||||
|
nohup java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar \
|
||||||
|
--spring.profiles.active=prod > /tmp/app.log 2>&1 &
|
||||||
|
nohup java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar \
|
||||||
|
--spring.profiles.active=prod > /tmp/worker.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **重新打包必须重启进程**:`spring-boot:repackage` 会原地替换 jar,运行中的进程再去加载"没加载过的类"
|
||||||
|
> 会抛 `NoClassDefFoundError`,表现为**请求挂住**(S1 记录过这个坑)。顺序永远是"改完 → 打包 → 重启 → 验证"。
|
||||||
|
|
||||||
|
## 五、prod fail-fast 是什么、长什么样
|
||||||
|
|
||||||
|
`prod` profile 下 `ProdSecretGuard` 会校验:JWT 双密钥 + 媒体签名密钥(非空、不是 dev 默认值、≥32 位)、
|
||||||
|
`signature-required=true`、`DB_URL` 非空、CORS 非空。任一不满足 → **打印全部问题并终止启动**:
|
||||||
|
|
||||||
|
```
|
||||||
|
ERROR c.f.framework.config.ProdSecretGuard : 生产配置校验未通过,拒绝启动(prod profile):
|
||||||
|
- fruitshorts.jwt.admin-secret(环境变量 JWT_ADMIN_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
|
||||||
|
- fruitshorts.jwt.app-secret(环境变量 JWT_APP_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
|
||||||
|
- fruitshorts.media.signature-secret(环境变量 MEDIA_SIGNATURE_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
|
||||||
|
- fruitshorts.cors.allowed-origins 为空(环境变量 CORS_ALLOWED_ORIGINS)—— 生产不允许用通配 *,必须显式列出来源
|
||||||
|
请按 FruitServer/DEPLOY.md 的环境变量清单补齐后重启。
|
||||||
|
```
|
||||||
|
|
||||||
|
exit code = **1**(部署脚本可以直接判失败)。**没有 prod profile 时这套校验不生效**,本地开发照旧开箱即用。
|
||||||
|
|
||||||
|
## 六、发布后验证(每次发版都做)
|
||||||
|
|
||||||
|
1. `docker compose ps`:三个应用容器 `Up (healthy)`(worker 是 `Up`)。
|
||||||
|
2. `tools/smoke.sh`:全 PASS、退出码 0。它检查 `/healthz`、`/app/categories`、`/app/home/feed`、
|
||||||
|
两个 `/v3/api-docs`、`/doc.html`,以及未授权路径是否按统一信封拒绝。
|
||||||
|
**关于在线文档那三项**:脚本会按 `SWAGGER_ENABLED` 自适应 —— `true` 时断言 200;
|
||||||
|
`false` 时打印 **SKIP**(不算失败),因为 `/doc.html` 与 `/v3/api-docs` 都被配置关掉了。
|
||||||
|
没设这个变量时脚本自动探测("200 且含 openapi"算开,"统一信封 code=404"算关,其它响应→FAIL)。
|
||||||
|
联调环境想强制要求文档在线:`SWAGGER_REQUIRED=true tools/smoke.sh`(不满足直接失败)。
|
||||||
|
3. `curl -s http://127.0.0.1:8081/healthz`:(从容器里或宿主机)应返回 `{"code":0,...,"status":"UP"}`。
|
||||||
|
4. 业务抽查:`curl -s http://127.0.0.1:8081/app/home/feed | head -c 200`(有内容);
|
||||||
|
后台 `http://127.0.0.1:8080/doc.html`(**需要 `.env` 里 `SWAGGER_ENABLED=true`**,联调环境请打开)。
|
||||||
|
5. 登录一个后台账号,确认能进后台首页(连通数据库与权限体系)。
|
||||||
|
|
||||||
|
## 七、回滚
|
||||||
|
|
||||||
|
**镜像/代码回滚**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 用上一个版本的 tag(每次发版都打 tag:fruitshorts/app-api:2026-10-16-1)
|
||||||
|
docker compose stop app-api admin-api transcode-worker
|
||||||
|
docker tag fruitshorts/app-api:2026-10-15-2 fruitshorts/app-api:latest
|
||||||
|
docker compose up -d app-api admin-api transcode-worker
|
||||||
|
tools/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
或用 git 回到上一个 tag 重新 `up -d --build`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch --tags && git checkout <上一个 release tag>
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据库回滚(谨慎)**:schema 脚本是幂等增量(`ADD COLUMN IF NOT EXISTS` 等),**一般不需要回滚表结构**;
|
||||||
|
真要回滚数据用备份:`docker compose exec -T db pg_dump -U fruitshorts fruitshorts > backup.sql`。
|
||||||
|
**发布前先备份**是硬要求:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db pg_dump -U "${DB_USER:-fruitshorts}" "${DB_NAME:-fruitshorts}" > backup-$(date +%F-%H%M).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**回滚后必须重跑** `tools/smoke.sh`,并确认转码队列状态正常(后台「数据看板 → 转码看板」)。
|
||||||
|
|
||||||
|
## 八、未在本机验证的部分(如实说明)
|
||||||
|
|
||||||
|
本机**没有 Docker / colima / podman**(`docker` 命令不存在),所以:
|
||||||
|
|
||||||
|
- `Dockerfile`、`.dockerignore`、`docker-compose.yml` **未经本机构建与运行验证**(只做了语法与配置项静态检查)。
|
||||||
|
- 本机真实验证过的是:**裸 jar 路径**(两个入口 + worker 起停、`/healthz`、冒烟脚本)、
|
||||||
|
**prod fail-fast**(缺密钥时退出码 1 + 明确报错)、配置外置(`DB_URL` 等改动后仍能起)。
|
||||||
|
- 目标主机上有 Docker 之后,请**先跑 §三 的 1)–4) 步**确认;如报错把 `docker compose logs` 贴回来。
|
||||||
|
|
||||||
|
## 八之二、上线前必须补的(治理类,详见 DEPLOY-CHECKLIST.md)
|
||||||
|
|
||||||
|
以下四条**不是**"部署命令"层面的问题,但 `docs/12-上线检查清单` 把它们列为上线条件,此处给出醒目提示:
|
||||||
|
|
||||||
|
1. **HTTPS + 独立域名 + 反向代理**(管理端/客户端 API/媒资分域名,`04-部署架构`§四 + `10-安全方案`§二)—— 现在只有 IP:HTTP;
|
||||||
|
2. **备份恢复**:specs 要求每日全量×30 天 + WAL 归档 + 加密 + 异地 + **RPO≤5min / RTO≤30min** + 季度演练,
|
||||||
|
现在只有手动 `pg_dump`,且**备份与数据库在同一台机器上**(等于没备份);
|
||||||
|
3. **监控告警**:Prometheus/Grafana/Loki 未部署;第一阶段按 specs 允许用日志兜底,但"容器 unhealthy 要能通知到人";
|
||||||
|
4. **商店与合规**:隐私政策/Data Safety/SDK 清单/版权台账/包名统一,均未办(印尼还涉及 Kominfo PSE 注册)。
|
||||||
|
|
||||||
|
**逐条状态与"上线前必补清单(按优先级)"见 [DEPLOY-CHECKLIST.md](./DEPLOY-CHECKLIST.md)。**
|
||||||
|
|
||||||
|
## 九、故障速查
|
||||||
|
|
||||||
|
| 现象 | 先看 | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 容器起不来,日志有"生产配置校验未通过" | §五 | 补 `.env` 里对应变量;密钥用 `openssl rand -base64 48` |
|
||||||
|
| `/healthz` 超时(不是快速 404/200) | 是不是刚重新打包没重启? | 重启进程(§四 的注意事项) |
|
||||||
|
| `app-api` healthy 但 `/app/*` 报连库失败 | `docker compose logs db` | 确认 `DB_URL` 指向 `db:5432`,`DB_PASSWORD` 与 db 容器一致 |
|
||||||
|
| 转码任务一直 `QUEUED` | worker 日志 | 确认 `transcode-worker` 在跑、`MEDIA_ROOT_DIR` 与 API 相同、镜像里装了 ffmpeg |
|
||||||
|
|
||||||
|
## 十、本地开发(不上 Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests
|
||||||
|
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar # 不带 prod profile,开箱即用
|
||||||
|
tools/smoke.sh # 本机一键冒烟
|
||||||
|
```
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
# Author: fruit.com.cn
|
||||||
|
#
|
||||||
|
# 多阶段构建:maven 构建 fat jar -> JRE 运行(非 root、tini 做 1 号进程)。
|
||||||
|
# 三个可运行件共用一份 Dockerfile,用 MODULE 构建参数区分。
|
||||||
|
#
|
||||||
|
# 构建(在 FruitServer 目录下执行):
|
||||||
|
# docker build -t fruitshorts/app-api:latest --build-arg MODULE=fruitshorts-app-api .
|
||||||
|
# docker build -t fruitshorts/admin-api:latest --build-arg MODULE=fruitshorts-admin-api .
|
||||||
|
# docker build -t fruitshorts/transcode-worker:latest --build-arg MODULE=fruitshorts-transcode-worker \
|
||||||
|
# --build-arg INSTALL_FFMPEG=true .
|
||||||
|
#
|
||||||
|
# 注意:本机当前没有 Docker(见 DEPLOY.md),这份 Dockerfile 未经本机构建验证。
|
||||||
|
|
||||||
|
# ---------- 1. 构建阶段 ----------
|
||||||
|
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Maven Central 在国内网络下拉依赖不稳定(构建时曾出现 maven-filtering:3.3.1 解析失败),
|
||||||
|
# 构建阶段固定走阿里云 central 镜像;其它仓库仍直连。
|
||||||
|
RUN mkdir -p /root/.m2 \
|
||||||
|
&& printf '%s\n' \
|
||||||
|
'<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">' \
|
||||||
|
' <mirrors>' \
|
||||||
|
' <mirror>' \
|
||||||
|
' <id>aliyun-central</id>' \
|
||||||
|
' <mirrorOf>central</mirrorOf>' \
|
||||||
|
' <url>https://maven.aliyun.com/repository/central</url>' \
|
||||||
|
' </mirror>' \
|
||||||
|
' </mirrors>' \
|
||||||
|
'</settings>' > /root/.m2/settings.xml
|
||||||
|
|
||||||
|
# 先只拷各模块 pom 再拉依赖:依赖层可复用,改业务代码不会重新下载依赖
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY fruitshorts-common/pom.xml fruitshorts-common/
|
||||||
|
COPY fruitshorts-framework/pom.xml fruitshorts-framework/
|
||||||
|
COPY fruitshorts-module-system/pom.xml fruitshorts-module-system/
|
||||||
|
COPY fruitshorts-module-content/pom.xml fruitshorts-module-content/
|
||||||
|
COPY fruitshorts-module-media/pom.xml fruitshorts-module-media/
|
||||||
|
COPY fruitshorts-module-user/pom.xml fruitshorts-module-user/
|
||||||
|
COPY fruitshorts-module-playback/pom.xml fruitshorts-module-playback/
|
||||||
|
COPY fruitshorts-module-interaction/pom.xml fruitshorts-module-interaction/
|
||||||
|
COPY fruitshorts-module-commerce/pom.xml fruitshorts-module-commerce/
|
||||||
|
COPY fruitshorts-module-creator/pom.xml fruitshorts-module-creator/
|
||||||
|
COPY fruitshorts-admin-api/pom.xml fruitshorts-admin-api/
|
||||||
|
COPY fruitshorts-app-api/pom.xml fruitshorts-app-api/
|
||||||
|
COPY fruitshorts-transcode-worker/pom.xml fruitshorts-transcode-worker/
|
||||||
|
RUN mvn -B -q -DskipTests dependency:go-offline
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG MODULE=fruitshorts-app-api
|
||||||
|
RUN mvn -B -DskipTests -pl ${MODULE} -am package \
|
||||||
|
&& cp ${MODULE}/target/*.jar /build/app.jar
|
||||||
|
|
||||||
|
# ---------- 2. 运行阶段 ----------
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
|
||||||
|
# ffmpeg 只有转码 worker 需要,用构建参数控制,避免把 80MB 装进两个 API 镜像
|
||||||
|
ARG INSTALL_FFMPEG=false
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tini curl \
|
||||||
|
&& if [ "$INSTALL_FFMPEG" = "true" ]; then \
|
||||||
|
apt-get install -y --no-install-recommends ffmpeg; \
|
||||||
|
fi \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 非 root 运行;/data/uploads 预留给媒资挂载卷
|
||||||
|
RUN groupadd -r fruitshorts \
|
||||||
|
&& useradd -r -g fruitshorts -d /app -s /sbin/nologin fruitshorts \
|
||||||
|
&& mkdir -p /app /data/uploads \
|
||||||
|
&& chown -R fruitshorts:fruitshorts /app /data/uploads
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build --chown=fruitshorts:fruitshorts /build/app.jar /app/app.jar
|
||||||
|
|
||||||
|
USER fruitshorts
|
||||||
|
|
||||||
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -Duser.timezone=Asia/Shanghai" \
|
||||||
|
SPRING_PROFILES_ACTIVE=prod \
|
||||||
|
MEDIA_ROOT_DIR=/data/uploads
|
||||||
|
|
||||||
|
# 健康检查打 /healthz(不依赖 actuator,见 HealthController)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}/healthz || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
CMD ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 |
|
| [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 |
|
||||||
| [docs/backend-design-phase1.md](docs/backend-design-phase1.md) | 后端设计:1 万并发观看的容量推导、成本、部署 |
|
| [docs/backend-design-phase1.md](docs/backend-design-phase1.md) | 后端设计:1 万并发观看的容量推导、成本、部署 |
|
||||||
| [docs/api-phase1.md](docs/api-phase1.md) | **接口清单:后台管理 + 客户端,标注已实现 / 下一批** |
|
| [docs/api-phase1.md](docs/api-phase1.md) | **接口契约:后台管理 + 客户端;文末含「契约与实现状态对照」(逐模块标注已实现 / 未完成)** |
|
||||||
| [docs/architecture-design.md](docs/architecture-design.md) | 早期架构设计文档 |
|
| [docs/architecture-design.md](docs/architecture-design.md) | 早期架构设计文档 |
|
||||||
|
|
||||||
| 项 | 版本 |
|
| 项 | 版本 |
|
||||||
@@ -44,6 +44,7 @@ FruitServer/
|
|||||||
| JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` |
|
| JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` |
|
||||||
| Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` |
|
| Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` |
|
||||||
| PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 |
|
| PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 |
|
||||||
|
| ffmpeg | 9.0.2(Homebrew 当前稳定版) | `brew install ffmpeg`;**只有转码 worker 需要**,两个在线入口(8080/8081)不依赖它 |
|
||||||
|
|
||||||
> `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`,
|
> `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`,
|
||||||
> 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。
|
> 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。
|
||||||
@@ -51,8 +52,13 @@ FruitServer/
|
|||||||
```sh
|
```sh
|
||||||
java -version # openjdk 21.0.12.1
|
java -version # openjdk 21.0.12.1
|
||||||
mvn -v # Maven 3.10.0 / Java 21.0.12.1
|
mvn -v # Maven 3.10.0 / Java 21.0.12.1
|
||||||
|
ffmpeg -version # 转码 worker 用;没装也能跑在线服务,只是转码任务会一直排队
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **ffmpeg 只被 `fruitshorts-transcode-worker` 使用**:`backend-design-phase1.md` §8.5 要求转码与在线服务隔离,
|
||||||
|
> 所以 app-api / admin-api 里 `fruitshorts.media.transcode.worker-enabled` 默认 false,转码要单独起 worker 进程
|
||||||
|
> (`java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar`)。
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。
|
- 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。
|
||||||
@@ -185,18 +191,43 @@ java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
|
|||||||
|
|
||||||
## 第一阶段实现进度
|
## 第一阶段实现进度
|
||||||
|
|
||||||
|
> 最后更新:2026-10-16(后端 `develop` 分支 `4418a84`)。**未完成清单见文末「尚未完成」。**
|
||||||
|
|
||||||
| 模块 | 状态 | 说明 |
|
| 模块 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 |
|
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护;**雪花主键(S0)**、**Swagger 在线文档 `/doc.html`(S1)** |
|
||||||
| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 |
|
| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 |
|
||||||
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 |
|
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;内容分级、可播区域、版权窗口、标签字段 |
|
||||||
| 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker |
|
| 04 播放器与播放控制 | 已实现 | 播放票据 + 签名地址 + 进度;多码率 HLS 由转码 worker 产出(见 10) |
|
||||||
| 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 |
|
| 05 记录、收藏与下载 | 已实现 | 进度、观看流水、收藏、离线下载授权与任务 |
|
||||||
| 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 |
|
| 06 评论、点赞与分享 | 已实现 | 评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** |
|
||||||
| 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 |
|
| 07 创作者中心 | 已实现 | **在后台管理(Web 端 `ReelShortWeb`)**:工作台、内容编辑、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;不含提现/结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
|
||||||
| 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 |
|
| 08 平台运营后台 | 已实现 | 用户管理、内容治理、订单与权益、创作者与内容审核、数据看板 + 四个后台页面 |
|
||||||
| 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 |
|
| 09 分集收费与支付 | 部分 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,文档口径为"预留") |
|
||||||
| 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 |
|
| 10 质量、数据与上线 | 部分 | **转码 worker(C1)已实现**;**Redis 缓存与限流(C2)、埋点(C3)、压测未做** |
|
||||||
|
|
||||||
|
### 已完成批次(按提交顺序)
|
||||||
|
|
||||||
|
| 批次 | 内容 | 提交 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S0 | 主键改雪花(58 表 / 52 实体 / 存量库幂等迁移脚本) | `37c9a14` `84aa0e1` |
|
||||||
|
| S1 | 接口文档在线化(39 个 Controller / 172 个接口中文注解 + `/doc.html`) | `c6fcfcc` |
|
||||||
|
| 契约补遗 | 补全接口契约遗漏(系统管理 / 内容 / watch-history / creators 账号 / media 详情) | `efe8af6` |
|
||||||
|
| M2 | 后端契约测试补齐(**0 → 37 条**) | `fafadfc` |
|
||||||
|
| B1-1 | Google Play 票据校验闭环(校验→建单→发权益) | `db897d5` |
|
||||||
|
| B1-2 | 恢复购买 / 退款拒付回收 / 对账真实比对 + Apple 端口预留 | `1dc97b4` |
|
||||||
|
| C1 | 转码 worker(独立进程消费 `transcode_task`,四档 HLS + 封面) | `9f76302` |
|
||||||
|
| D1 | 部署就绪(配置外置 + prod fail-fast + 自包含 Compose + 冒烟/回滚) | `b567365` |
|
||||||
|
| D1-fix | 冒烟脚本与 prod Swagger 开关解耦 | `cbb9961` |
|
||||||
|
| D2 | 部署文档按 specs 约束补全(新增 `DEPLOY-CHECKLIST.md`) | `7dd4b15` |
|
||||||
|
| 部署修复 | PG18 数据卷挂载路径 + worker 的 prod 密钥/CORS 映射(PR #1) | `8b46e53` `4c4f643` |
|
||||||
|
| E1 | 补 5 个 C 端接口(`/app/creator/apply`、`/app/config`、`/app/notices`) | `14b5e41` |
|
||||||
|
| 契约修正 | 移除 `/app/creator/works`、`/app/creator/stats` 的 C 端定义 | `bb21920` |
|
||||||
|
| E1-rollback | 撤除上述两条接口对应代码(创作者中心归 Web 端) | `4418a84` |
|
||||||
|
|
||||||
|
**门禁现状**:`mvn test` **91 条全过**(app-api 49 / commerce 27 / worker 12 / admin-api 3)。
|
||||||
|
|
||||||
|
> 客户端仓库 `ReelShort`(Flutter)另有已完成批次:网络层换 `dio`(`1853e18`)、客户端改名 `nontonki → FruitShorts` + 印尼语 `id-ID` 文案(`61454e3`)。
|
||||||
|
|
||||||
## 默认账号
|
## 默认账号
|
||||||
|
|
||||||
@@ -393,15 +424,54 @@ public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ...
|
|||||||
6. 表格状态列改用接口返回的 `status` / `statusText`,
|
6. 表格状态列改用接口返回的 `status` / `statusText`,
|
||||||
大数值用 `src/utils/format.js` 的 `formatCount` 格式化。
|
大数值用 `src/utils/format.js` 的 `formatCount` 格式化。
|
||||||
|
|
||||||
## 尚未实现
|
## 尚未完成
|
||||||
|
|
||||||
评论点赞分享、订单与钱包权益(IAP / Google Play Billing 票据校验、对账、退款回收)、
|
> 最后更新:2026-10-16。完成后请同步本表与 [docs/api-phase1.md](docs/api-phase1.md)。
|
||||||
创作者中心、内容治理与经营看板、转码 worker(消费 `transcode_task`,产出多码率 HLS)、
|
|
||||||
Redis 缓存与限流、离线下载授权、埋点与数据看板。
|
|
||||||
|
|
||||||
这些模块的表结构、接口契约、状态机与权限点都已经冻结,见
|
### 一、后端研发(剩余)
|
||||||
[docs/api-phase1.md](docs/api-phase1.md) 里标注「下一批」的部分 —— 接下来是照契约填代码,
|
|
||||||
不会再有结构性改动。
|
| # | 项 | 内容 | 依据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | **C2 Redis 缓存与限流** | 首页/发现/分类等热点接口缓存(Cache-Aside)+ 验证码/注册/登录限流;生产 Redis 规格 `4C16G 主从 2~3 哨兵` | 任务清单 C2、`17-风险评估` R-10、`10-安全方案` §三 |
|
||||||
|
| 2 | **C3 埋点** | 播放/支付/转化事件 + 数据字典对齐(含 06 的分享埋点) | 任务清单 C3、`api-phase1` §3 |
|
||||||
|
| 3 | **Apple IAP 票据校验** | 目前只留端口与枚举,未做真实校验(印尼 iOS 占比低,文档口径为"预留") | `api-phase1` §1.8 |
|
||||||
|
| 4 | **压测** | 目标 `P95 < 50ms`(预热后);容量口径文档里有两套("1 万并发"与"2 万并发 + 3000 QPS"),需定稿 | `09-测试计划` §五、`13/14-容量` |
|
||||||
|
| 5 | **对账数据源** | 结算金额/手续费/税只能从 Play Console 财务报告取(需报表权限);当前 `storeAmountSum` 恒为 `null` + `notes` 说明 | B1-2 待确认 |
|
||||||
|
| 6 | **退款口径完善** | 部分退款按比例回收、退款实时性(当前由每日对账 + 手动驱动,未接 RTDN 推送) | B1-2 待确认 |
|
||||||
|
|
||||||
|
### 二、上线前必补(P0 / Go-No-Go:非研发,但阻塞上线)
|
||||||
|
|
||||||
|
| # | 项 | 当前状态 | 目标 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 生产密钥集中托管 + 轮换 | 仅 `.env`(权限 600),无托管、无轮换流程 | 密钥集中托管、最小权限、可轮换(`10-安全方案` §五) |
|
||||||
|
| 2 | 安全专项测试 | 未做 | 越权/注入/敏感数据专项,高危上线前清零(`12-上线检查清单` §三) |
|
||||||
|
| 3 | 备份与恢复演练 | 仅手动 `pg_dump`,且与数据库**同机** | **RPO ≤ 5min / RTO ≤ 30min**;全量 + WAL 归档 + 加密 + 异地 + 季度演练(`08-备份恢复`) |
|
||||||
|
| 4 | 监控告警接入 | 未部署,仅日志 + `SlowRequestLogger` 兜底 | Prometheus / Grafana / Loki + P0/P1 告警规则(`07-监控告警`;**需先加 actuator 指标端点**) |
|
||||||
|
| 5 | HTTPS + 域名 + 反代 | 无 | 管理端 / 客户端 API / 媒资**分别域名**,HTTPS 强制(`04-部署架构` §四、`10-安全方案` §二) |
|
||||||
|
| 6 | 媒资迁 OSS/S3 + CDN | 本地磁盘 `./data/uploads` | 生产必须换 OSS/S3 + CDN(`08-备份恢复` §六、`12-上线检查清单` §四) |
|
||||||
|
|
||||||
|
### 三、基础设施
|
||||||
|
|
||||||
|
- **CI/CD 流水线**:当前手动跑门禁,未接流水线(`06-CI-CD方案`)
|
||||||
|
- **多实例 / 高可用**:当前单点部署;文档要求应用 `8C16G × 3~4`、转码 worker `8C16G × 2~3`,生产用 K8s(`14-生产资源规划`)
|
||||||
|
- **短信 / 邮件真实通道**:开发走 log,生产待接通道(`05-中间件清单`)
|
||||||
|
- **分区保留策略**:`partition.retention-enabled=false`,待合规确认后打开(`03-数据架构`)
|
||||||
|
- **`watch_history` 异步落库**:Kafka 异步通道为 Phase 2 预留(`13-容量与扩展规划` §四)
|
||||||
|
- 备份存储位置与成本待定(`08-备份恢复` §六)
|
||||||
|
|
||||||
|
### 四、不在后端范围(另有分工)
|
||||||
|
|
||||||
|
- **客户端 Flutter(`ReelShort`)**:A2~A6 与后端联调、B2 Google Play Billing 购买流程、秒开基线(需低端安卓设备);网络层 `dio`、改名与印尼语文案已完成
|
||||||
|
- **Web 后台(`ReelShortWeb`)**:由另一位同事侧负责
|
||||||
|
|
||||||
|
### 五、文档待同步(供文档维护人)
|
||||||
|
|
||||||
|
`docs/api-phase1.md` 与 specs `docs/` 下仍有 20+ 处**过期陈述**(例:`api-phase1` §3 仍把 09 支付标"下一批";specs `17-风险评估` 的 R-01/R-03/R-04/R-09 仍标"未开始")。
|
||||||
|
**遇到冲突以本表与本仓库 `api-phase1.md` 正文为准。**
|
||||||
|
|
||||||
|
> **已作废的方案**:曾提议新建 `fruitshorts-module-analytics` 收敛"两份创作者看板"。
|
||||||
|
> 因 2026-10-16 口径变更(**创作者中心由 Web 端承载**)撤除了 App 侧 `/app/creator/works`、`/app/creator/stats`,
|
||||||
|
> **两份看板问题已从根上消失,该方案不再需要**。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Author: fruit.com.cn
|
||||||
|
# blackbox-exporter 探针模块:只探测 HTTP 2xx,跟随重定向
|
||||||
|
modules:
|
||||||
|
http_2xx:
|
||||||
|
prober: http
|
||||||
|
timeout: 5s
|
||||||
|
http:
|
||||||
|
preferred_ip_protocol: ip4
|
||||||
|
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
|
||||||
|
valid_status_codes: [200]
|
||||||
|
follow_redirects: true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-16"?>
|
||||||
|
<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||||
|
<RegistrationInfo>
|
||||||
|
<Description>FruitShorts backend watchdog: health check every 2 minutes and restart unhealthy services.</Description>
|
||||||
|
</RegistrationInfo>
|
||||||
|
<Principals>
|
||||||
|
<Principal id="Author">
|
||||||
|
<UserId>S-1-5-18</UserId>
|
||||||
|
<RunLevel>HighestAvailable</RunLevel>
|
||||||
|
</Principal>
|
||||||
|
</Principals>
|
||||||
|
<Settings>
|
||||||
|
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||||
|
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||||
|
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||||
|
<StartWhenAvailable>true</StartWhenAvailable>
|
||||||
|
<ExecutionTimeLimit>PT10M</ExecutionTimeLimit>
|
||||||
|
</Settings>
|
||||||
|
<Triggers>
|
||||||
|
<!-- BootTrigger 上挂 <Repetition> 在 Windows 计划任务里不生效(导入后 Next Run Time 为 N/A,
|
||||||
|
实际只在开机跑一次)。改成 TimeTrigger + 无限重复,配合 StartWhenAvailable
|
||||||
|
保证开机后若错过也会立即补跑;这样才真正做到每 2 分钟巡检一次。 -->
|
||||||
|
<TimeTrigger>
|
||||||
|
<StartBoundary>2026-01-01T00:00:00</StartBoundary>
|
||||||
|
<Repetition>
|
||||||
|
<Interval>PT2M</Interval>
|
||||||
|
<Duration>P3650D</Duration>
|
||||||
|
<StopAtDurationEnd>false</StopAtDurationEnd>
|
||||||
|
</Repetition>
|
||||||
|
</TimeTrigger>
|
||||||
|
</Triggers>
|
||||||
|
<Actions Context="Author">
|
||||||
|
<Exec>
|
||||||
|
<Command>powershell.exe</Command>
|
||||||
|
<Arguments>-NoProfile -ExecutionPolicy Bypass -File "C:\Users\Administrator\Desktop\ReelShorts\FruitServer\deploy\watchdog.ps1" -ComposeDir "C:\Users\Administrator\Desktop\ReelShorts\FruitServer" -LogDir "C:\Users\Administrator\Desktop\ReelShorts\FruitServer\logs"</Arguments>
|
||||||
|
</Exec>
|
||||||
|
</Actions>
|
||||||
|
</Task>
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Author: fruit.com.cn
|
|
||||||
# 监控组件编排:Prometheus + Grafana(Loki 日志后续接入)
|
|
||||||
services:
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus:latest
|
|
||||||
container_name: fruitshorts-prometheus
|
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
volumes:
|
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
|
||||||
- prometheus-data:/prometheus
|
|
||||||
command:
|
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: fruitshorts-grafana
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- grafana-data:/var/lib/grafana
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
prometheus-data:
|
|
||||||
grafana-data:
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Author: fruit.com.cn
|
|
||||||
# Prometheus 抓取配置:抓取后端双服务的 actuator 指标
|
|
||||||
global:
|
|
||||||
scrape_interval: 15s
|
|
||||||
evaluation_interval: 15s
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: "fruitshorts-app-api"
|
|
||||||
metrics_path: "/actuator/prometheus"
|
|
||||||
static_configs:
|
|
||||||
- targets: ["host.docker.internal:8081"]
|
|
||||||
|
|
||||||
- job_name: "fruitshorts-admin-api"
|
|
||||||
metrics_path: "/actuator/prometheus"
|
|
||||||
static_configs:
|
|
||||||
- targets: ["host.docker.internal:8080"]
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Author: fruit.com.cn
|
||||||
|
#
|
||||||
|
# 服务看门狗:每 2 分钟检查一次,挂了自动拉起,连续失败发飞书告警。
|
||||||
|
# 注册为 Windows 计划任务后无人值守运行。
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$ComposeDir = "C:\FruitServer",
|
||||||
|
[string]$LogDir = "C:\FruitServer\logs",
|
||||||
|
[string]$FeishuWebhook = "",
|
||||||
|
[int]$MaxRestarts = 3,
|
||||||
|
[string]$FrontendTask = "FruitShorts-Web-3005"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
$ComposeFile = Join-Path $ComposeDir "docker-compose.yml"
|
||||||
|
$LogFile = Join-Path $LogDir "watchdog-$(Get-Date -Format 'yyyy-MM-dd').log"
|
||||||
|
# 连续失败计数落盘文件:计划任务每 2 分钟起一个新进程,计数只放进程内永远累积不到
|
||||||
|
# $MaxRestarts,告警会静默哑火。
|
||||||
|
$StateFile = Join-Path $LogDir "watchdog-state.json"
|
||||||
|
|
||||||
|
function Write-Log($msg) {
|
||||||
|
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg"
|
||||||
|
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-Alert($title, $msg) {
|
||||||
|
if (-not $FeishuWebhook) { return }
|
||||||
|
$body = @{
|
||||||
|
msg_type = "text"
|
||||||
|
content = @{ text = "[$title] $msg" }
|
||||||
|
} | ConvertTo-Json -Depth 3
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $body -ContentType "application/json" -TimeoutSec 10
|
||||||
|
} catch {
|
||||||
|
Write-Log "飞书告警发送失败: $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 连续失败计数的读写:JSON 落盘,跨进程保留
|
||||||
|
function Import-FailCounts {
|
||||||
|
$counts = @{}
|
||||||
|
if (-not (Test-Path $StateFile)) { return $counts }
|
||||||
|
try {
|
||||||
|
$saved = Get-Content -Path $StateFile -Raw | ConvertFrom-Json
|
||||||
|
foreach ($prop in $saved.PSObject.Properties) {
|
||||||
|
$counts[$prop.Name] = [int]$prop.Value
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "看门狗计数文件解析失败,各服务按 0 重新计数: $_"
|
||||||
|
}
|
||||||
|
return $counts
|
||||||
|
}
|
||||||
|
|
||||||
|
function Export-FailCounts($counts) {
|
||||||
|
try {
|
||||||
|
$counts | ConvertTo-Json | Set-Content -Path $StateFile -Encoding UTF8
|
||||||
|
} catch {
|
||||||
|
Write-Log "看门狗计数文件写入失败: $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Docker {
|
||||||
|
docker info --format "{{.ServerVersion}}" 2>$null | Out-Null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Http([string]$url) {
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri $url -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop
|
||||||
|
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400)
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Container([string]$container) {
|
||||||
|
$status = docker inspect -f "{{.State.Status}}" $container 2>$null
|
||||||
|
return ($status -eq "running")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Compose([string[]]$composeArgs) {
|
||||||
|
& docker compose -f $ComposeFile @composeArgs 2>&1 | Out-Null
|
||||||
|
return $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
# 确保日志目录存在
|
||||||
|
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||||
|
|
||||||
|
# 告警 Webhook:命令行参数 > 环境变量 > deploy 目录下的 feishu-webhook.txt。
|
||||||
|
# 计划任务注册命令里不写密钥(密钥不该进版本库),由部署机本地放文件或配环境变量。
|
||||||
|
if (-not $FeishuWebhook) { $FeishuWebhook = $env:FRUITSHORTS_FEISHU_WEBHOOK }
|
||||||
|
if (-not $FeishuWebhook) {
|
||||||
|
$webhookFile = Join-Path $PSScriptRoot "feishu-webhook.txt"
|
||||||
|
if (Test-Path $webhookFile) {
|
||||||
|
$FeishuWebhook = (Get-Content -Path $webhookFile -Raw -ErrorAction SilentlyContinue)
|
||||||
|
if ($FeishuWebhook) { $FeishuWebhook = $FeishuWebhook.Trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $FeishuWebhook) {
|
||||||
|
Write-Log "未配置飞书 Webhook(参数/环境变量/feishu-webhook.txt 均为空),告警只写本地日志"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Docker Desktop 登录后偶尔尚未就绪,先等待并尝试拉起。
|
||||||
|
if (-not (Test-Docker)) {
|
||||||
|
Write-Log "Docker 未就绪,尝试启动 Docker Desktop"
|
||||||
|
$dockerExe = "C:\Program Files\Docker\Docker\Docker Desktop.exe"
|
||||||
|
if (Test-Path $dockerExe) {
|
||||||
|
Start-Process -FilePath $dockerExe
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt 18; $i++) {
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
if (Test-Docker) { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Docker)) {
|
||||||
|
Write-Log "Docker 在 90 秒内未就绪,跳过本轮检查"
|
||||||
|
Send-Alert "服务异常" "Docker Desktop 无法启动,请人工检查桌面机。"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查定义:名称 → 检查方式
|
||||||
|
$checks = @(
|
||||||
|
@{ Name = "app-api"; Type = "http"; Url = "http://127.0.0.1:8081/healthz"; Service = "app-api"; Container = "fruitshorts-app-api" },
|
||||||
|
@{ Name = "admin-api"; Type = "http"; Url = "http://127.0.0.1:8080/healthz"; Service = "admin-api"; Container = "fruitshorts-admin-api" },
|
||||||
|
@{ Name = "frontend"; Type = "frontend"; Url = "http://127.0.0.1:3005/dist-dev/" },
|
||||||
|
@{ Name = "Grafana"; Type = "http"; Url = "http://127.0.0.1:3000/api/health"; Service = "grafana"; Container = "fruitshorts-grafana" },
|
||||||
|
@{ Name = "PostgreSQL"; Type = "docker"; Service = "db"; Container = "fruitshorts-db" },
|
||||||
|
@{ Name = "Redis"; Type = "docker"; Service = "redis"; Container = "fruitshorts-redis" },
|
||||||
|
@{ Name = "Prometheus"; Type = "http"; Url = "http://127.0.0.1:9090/-/healthy"; Service = "prometheus"; Container = "fruitshorts-prometheus" }
|
||||||
|
)
|
||||||
|
|
||||||
|
# 跟踪每个服务的连续失败次数(跨进程从状态文件恢复)
|
||||||
|
$failCounts = Import-FailCounts
|
||||||
|
|
||||||
|
foreach ($check in $checks) {
|
||||||
|
$name = $check.Name
|
||||||
|
$healthy = $false
|
||||||
|
|
||||||
|
if ($check.Type -eq "http") {
|
||||||
|
$healthy = Test-Http $check.Url
|
||||||
|
} elseif ($check.Type -eq "frontend") {
|
||||||
|
$healthy = Test-Http $check.Url
|
||||||
|
} else {
|
||||||
|
$healthy = Test-Container $check.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($healthy) {
|
||||||
|
if ($failCounts[$name] -gt 0) {
|
||||||
|
Write-Log "$name 恢复正常(此前连续失败 $($failCounts[$name]) 次)"
|
||||||
|
Send-Alert "服务恢复" "$name 已恢复正常"
|
||||||
|
}
|
||||||
|
$failCounts[$name] = 0
|
||||||
|
} else {
|
||||||
|
if ($null -eq $failCounts[$name]) {
|
||||||
|
$failCounts[$name] = 0
|
||||||
|
}
|
||||||
|
$failCounts[$name] = $failCounts[$name] + 1
|
||||||
|
$count = $failCounts[$name]
|
||||||
|
Write-Log "$name 不健康(第 $count 次)"
|
||||||
|
|
||||||
|
if ($check.Type -eq "frontend") {
|
||||||
|
Write-Log "$name 重启计划任务 $FrontendTask"
|
||||||
|
Stop-ScheduledTask -TaskName $FrontendTask -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Start-ScheduledTask -TaskName $FrontendTask -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 6
|
||||||
|
$healthy = Test-Http $check.Url
|
||||||
|
} elseif ($check.Type -eq "http") {
|
||||||
|
Write-Log "$name 重启 Compose 服务 $($check.Service)"
|
||||||
|
Invoke-Compose @("restart", $check.Service) | Out-Null
|
||||||
|
Start-Sleep -Seconds 12
|
||||||
|
$healthy = Test-Http $check.Url
|
||||||
|
|
||||||
|
if (-not $healthy) {
|
||||||
|
Write-Log "$name 重启后仍不健康,执行 compose up -d"
|
||||||
|
Invoke-Compose @("up", "-d", $check.Service) | Out-Null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Log "$name 拉起容器 $($check.Container)"
|
||||||
|
docker start $check.Container 2>&1 | Out-Null
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
$healthy = Test-Container $check.Container
|
||||||
|
|
||||||
|
if (-not $healthy) {
|
||||||
|
Write-Log "$name 启动失败,执行 compose up -d"
|
||||||
|
Invoke-Compose @("up", "-d", $check.Service) | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 连续失败达到上限 → 告警。计数会跨进程累积,所以只在首次达阈值和
|
||||||
|
# 之后每 30 轮(约 1 小时)提醒一次,避免每 2 分钟刷屏。
|
||||||
|
if (-not $healthy -and $count -ge $MaxRestarts -and
|
||||||
|
($count -eq $MaxRestarts -or $count % 30 -eq 0)) {
|
||||||
|
Send-Alert "服务异常" "$name 已连续 $count 次不健康,已尝试重启。请人工介入检查。"
|
||||||
|
Write-Log "$name 连续 $count 次失败,已发告警"
|
||||||
|
} elseif ($healthy) {
|
||||||
|
Write-Log "$name 已自动恢复"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 磁盘检查:超过 85% 告警
|
||||||
|
$drive = (Get-PSDrive C -ErrorAction SilentlyContinue)
|
||||||
|
if ($drive) {
|
||||||
|
$pct = [math]::Round(($drive.Used / ($drive.Used + $drive.Free)) * 100)
|
||||||
|
if ($pct -gt 85) {
|
||||||
|
Write-Log "磁盘使用率 ${pct}%(超过 85%)"
|
||||||
|
Send-Alert "磁盘告警" "C 盘使用率 ${pct}%,请清理日志或扩容"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 本轮结果落盘,供下一轮(2 分钟后的新进程)继续累加
|
||||||
|
Export-FailCounts $failCounts
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# Author: fruit.com.cn
|
||||||
|
#
|
||||||
|
# 办公室单机部署(docs/04-部署架构.md §三 精简方案 + §五 第一阶段用 Compose):
|
||||||
|
# app-api(8081) + admin-api(8080,只绑本机) + transcode-worker + PostgreSQL 18 + Redis
|
||||||
|
# 转码 worker 与在线服务**同机但独立进程/容器**(§8.5 隔离要求:不抢在线服务资源)。
|
||||||
|
#
|
||||||
|
# 用法(目标主机需要 Docker + Compose):
|
||||||
|
# cp .env.example .env # 填密钥与数据库密码
|
||||||
|
# docker compose up -d --build
|
||||||
|
# tools/smoke.sh
|
||||||
|
#
|
||||||
|
# 本文件在本机**未验证**(本机没有 Docker),见 DEPLOY.md「未在本机验证的部分」。
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ─────────── 基础设施 ───────────
|
||||||
|
db:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
container_name: fruitshorts-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME:-fruitshorts}
|
||||||
|
POSTGRES_USER: ${DB_USER:-fruitshorts}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?数据库密码必填,见 .env.example}
|
||||||
|
# 中文与印尼语内容需要 UTF-8(镜像默认已是 UTF8,这里显式一次)
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
|
volumes:
|
||||||
|
# PostgreSQL 18 起镜像把数据放在 /var/lib/postgresql/<major>/,卷必须挂到父目录
|
||||||
|
- db-data:/var/lib/postgresql
|
||||||
|
# 首次启动自动建表 + 造种子数据;顺序即文件名顺序(脚本本身幂等,可重复执行)
|
||||||
|
- ./sql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||||
|
- ./sql/schema-phase1.sql:/docker-entrypoint-initdb.d/02-schema-phase1.sql:ro
|
||||||
|
- ./sql/partition-maintenance.sql:/docker-entrypoint-initdb.d/03-partition-maintenance.sql:ro
|
||||||
|
- ./sql/data.sql:/docker-entrypoint-initdb.d/04-data.sql:ro
|
||||||
|
- ./sql/data-phase1.sql:/docker-entrypoint-initdb.d/05-data-phase1.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fruitshorts} -d ${DB_NAME:-fruitshorts}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
# 不映射端口:只有同网络的容器需要访问;本机要连库时再放开 5432
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
# 第一阶段还没有代码依赖 Redis(C2 才接缓存与限流),先把基础设施就位,
|
||||||
|
# 免得上线时再改编排。要持久化就留 volume,纯缓存场景可去掉。
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: fruitshorts-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
|
||||||
|
# ─────────── 应用 ───────────
|
||||||
|
app-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
MODULE: fruitshorts-app-api
|
||||||
|
image: fruitshorts/app-api:latest
|
||||||
|
container_name: fruitshorts-app-api
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: "8081"
|
||||||
|
# 默认连编排里的 db;要连外部库就在 .env 里覆盖 DB_URL。
|
||||||
|
# reWriteBatchedInserts=true:pgjdbc 把 JDBC 批内多条 INSERT 重写成多值
|
||||||
|
# INSERT,观看进度缓冲落库(watch:pg:dirty 消费任务)依赖它跑满吞吐。
|
||||||
|
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}?reWriteBatchedInserts=true}
|
||||||
|
# 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis
|
||||||
|
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||||
|
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
volumes:
|
||||||
|
# 媒资落盘:三个服务共享同一个卷
|
||||||
|
- ./data/uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081/healthz || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 40s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
admin-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
MODULE: fruitshorts-admin-api
|
||||||
|
image: fruitshorts/admin-api:latest
|
||||||
|
container_name: fruitshorts-admin-api
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: "8080"
|
||||||
|
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB"
|
||||||
|
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB"
|
||||||
|
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005"
|
||||||
|
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||||
|
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
|
||||||
|
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
|
||||||
|
FRUITSHORTS_MEDIA_PROTECTED_PREFIXES: ""
|
||||||
|
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||||
|
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||||
|
ports:
|
||||||
|
# docs/12-上线检查清单「管理端仅内网」:只对局域网开放;不在路由器做端口转发,公网访问不到
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data/uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 40s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
transcode-worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
MODULE: fruitshorts-transcode-worker
|
||||||
|
# worker 镜像里装 ffmpeg,两个 API 镜像不装(省 80MB)
|
||||||
|
INSTALL_FFMPEG: "true"
|
||||||
|
image: fruitshorts/transcode-worker:latest
|
||||||
|
container_name: fruitshorts-transcode-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||||
|
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||||
|
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||||
|
volumes:
|
||||||
|
# 与两个入口同一个媒资根,否则找不到源文件
|
||||||
|
- ./data/uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
# worker 不监听端口(server.port=0),用进程存活判断
|
||||||
|
test: ["CMD-SHELL", "pgrep -f app.jar >/dev/null || exit 1"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 40s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ─────────── 监控 ───────────
|
||||||
|
# 编排统一在这里:prometheus(应用指标 + 容器指标 + 黑盒探活)+ grafana(自动加载看板)
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: fruitshorts-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
command:
|
||||||
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
|
# 15 天保留期,避免磁盘无限增长
|
||||||
|
- "--storage.tsdb.retention.time=15d"
|
||||||
|
- "--web.enable-lifecycle"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/healthy >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: fruitshorts-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
# 数据源与看板全部来自仓库,换机器不需要手工配
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?Grafana 管理员密码必填,见 .env.example}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
# 新用户默认界面语言:简体中文
|
||||||
|
GF_USERS_LANGUAGE: "zh-Hans"
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
- loki
|
||||||
|
|
||||||
|
# ─────────── 日志(Loki + Promtail)───────────
|
||||||
|
# Loki:日志存储与查询引擎;Promtail:采集容器 stdout/stderr 推给 Loki。
|
||||||
|
# Grafana 里通过 Loki 数据源可直接搜索/过滤各容器的请求日志。
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:latest
|
||||||
|
container_name: fruitshorts-loki
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
|
volumes:
|
||||||
|
- ./loki-config.yml:/etc/loki/local-config.yaml:ro
|
||||||
|
- loki-data:/loki
|
||||||
|
expose:
|
||||||
|
- "3100"
|
||||||
|
# 镜像为 distroless(无 shell/wget),无法在容器内做 HTTP 探活;
|
||||||
|
# 靠 restart: unless-stopped 自动拉起,Grafana 数据源状态可反映可用性。
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:latest
|
||||||
|
container_name: fruitshorts-promtail
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -config.file=/etc/promtail/config.yml
|
||||||
|
volumes:
|
||||||
|
- ./promtail-config.yml:/etc/promtail/config.yml:ro
|
||||||
|
# 读取容器日志需要 Docker socket
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
|
||||||
|
# 黑盒探活:从外部探测接口可用性与耗时
|
||||||
|
blackbox:
|
||||||
|
image: prom/blackbox-exporter:latest
|
||||||
|
container_name: fruitshorts-blackbox
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||||
|
|
||||||
|
# 容器资源指标(CPU / 内存 / 网络 / 重启),供 Prometheus 抓取
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:latest
|
||||||
|
container_name: fruitshorts-cadvisor
|
||||||
|
restart: unless-stopped
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# 命名卷:跟着 compose 项目走,`docker compose down -v` 才会删
|
||||||
|
db-data:
|
||||||
|
redis-data:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
|
loki-data:
|
||||||
+900
-44
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
|||||||
|
# I1 后台管理端:商品(金币包)配置 + 系统公告管理 —— 计划(2026-10-16)
|
||||||
|
|
||||||
|
> 依据:`FruitServer/docs/plans/INBOX.md` 17:30 条目。用户口径「现在做后台管理端,规范还是按照 specs」。
|
||||||
|
> **本轮=后端接口 + ReelShortWeb 页面一起做**。指令通道已迁到本仓库(`FruitServer/docs/plans/`),
|
||||||
|
> 留痕写 `FruitServer/docs/plans/PROGRESS.md`(本轮新建);**specs 只读、不再写入**。
|
||||||
|
|
||||||
|
## 一、两个缺口(PM 已勘察,直接用)
|
||||||
|
|
||||||
|
1. **商品/金币包配置后台无入口**:`product` 表 16 字段已建、3 行种子、C 端 verify 依赖 `store_product_id` 对齐商店商品,但 admin-api 没有任何商品 CRUD。
|
||||||
|
2. **公告有读没写**:`ops_notice` 表 0 行,E1 交付了 C 端读,后台没有写入口。
|
||||||
|
|
||||||
|
## 二、勘察补充(影响做法,已实测)
|
||||||
|
|
||||||
|
1. **前端挂载机制**(与条目描述略有出入,已核实):`src/utils/routerHelper.ts` 用
|
||||||
|
`import.meta.glob('../views/**/*.{vue,tsx}')` + 后端菜单的 `component` 字符串**动态解析**;
|
||||||
|
`src/router/index.ts` 的 `asyncRouterMap` 只静态声明了 dashboard/content/user/system 等早期分组,
|
||||||
|
commerce/governance/board 这些都没有静态登记。→ 所以新增页面**只需**:① 新建 `.vue`;
|
||||||
|
② `sql/data-phase1.sql` 里加菜单且 `component` 与视图路径一致。**静态路由不必改**(改了也无害)。
|
||||||
|
真正的坑是 `route_name` 唯一(曾因 `CreatorCenter` 重名导致 404),本轮命名会避开。
|
||||||
|
2. **id 保留段**:权限点已用到 1155、菜单已用到 30 → 权限点用 **1160-1163**、菜单用 **31/32/33**。
|
||||||
|
3. **前端既有约定**:api 封装在 `src/api/admin/*`(`import request from '@/axios'` + `PageResult`),
|
||||||
|
页面用 `useRbac().can(code)` 控权、`dateCell` 格式化日期、`PageHeading` 标题;金额/金币这类大数值本项目
|
||||||
|
没有 `formatCount`,实际用的是 `src/utils/format.js`,**我会用其中的数值格式化工具或 `toLocaleString`**,
|
||||||
|
并在回执说明(不新造 util)。
|
||||||
|
|
||||||
|
## 三、口径决定(需在回执与契约里写清)
|
||||||
|
|
||||||
|
**VIP 商品**:**允许创建、不允许启用**(`PUT /{id}/status` 把 `type=VIP` 置为启用 → `400`)。
|
||||||
|
理由:运营可以先把 VIP 商品与商店 product id 录好,等 B2 订阅上线后一键启用;直接拒绝创建会让上线日重新补数据。
|
||||||
|
B1-1 已定「VIP 订阅本轮不做」,所以**启用**才是会产生误导的动作,拦在这里。
|
||||||
|
|
||||||
|
## 四、后端做法
|
||||||
|
|
||||||
|
1. **契约先行**:`docs/api-phase1.md` 新增一节(放在 §2.6 订单与权益之后的新编号),写清两个模块的
|
||||||
|
接口、字段、校验、错误码、权限点。
|
||||||
|
2. **商品**(module-commerce 加 service/VO,admin-api 加 controller):
|
||||||
|
`GET /admin/products/page`(type/status/platform/keyword + 分页)、`GET /{id}`、`POST`、`PUT /{id}`、
|
||||||
|
`PUT /{id}/status`、`DELETE /{id}`;字段按表结构;校验:`code` 全局唯一、`store_product_id` 同 `platform` 唯一、
|
||||||
|
`type=COIN → coin_amount>0`、`type=VIP → vip_days>0`、**VIP 不可启用**。响应带 `statusText/typeText`(前端不写死中文)。
|
||||||
|
3. **公告**(module-system 加 `OpsNoticeService` + VO,admin-api 加 controller):
|
||||||
|
`GET /admin/notices/page`、`GET /{id}`、`POST`、`PUT /{id}`、`PUT /{id}/status`、`DELETE /{id}`。
|
||||||
|
**筛选逻辑收敛到 `OpsNoticeService.pageForApp()` 一处**,C 端 `AppNoticeService` 改为委托它
|
||||||
|
(改我自己 E1 写的文件),这样"后台发布 → C 端可见 / 过期或下架 → 不可见"用的是同一段条件,不会漂移。
|
||||||
|
4. **菜单与权限点**:`sql/data-phase1.sql` 幂等追加(权限点 1160-1163、菜单 31/32/33 与超管授权)。
|
||||||
|
商品挂「订单与权益」(id 22) 下;公告新建「运营管理」(id 32) 分组 + 子菜单 (33)。
|
||||||
|
5. **Swagger**:两个新控制器按 S1 规范加 `@Tag`/`@Operation`,并把分组补进 admin-api 的 `@OpenAPIDefinition`。
|
||||||
|
|
||||||
|
## 五、前端做法(ReelShortWeb,develop 分支)
|
||||||
|
|
||||||
|
- `src/api/admin/product.ts`、`src/api/admin/notice.ts`(按 `commerce.ts` 的写法)
|
||||||
|
- `src/views/commerce/ProductManagementView.vue`(列表/筛选/新建编辑弹窗/上下架/删除)
|
||||||
|
- `src/views/operation/NoticeManagementView.vue`(列表/新建编辑/发布下架/删除)
|
||||||
|
- `sql/data-phase1.sql` 的菜单 `component` 写 `views/commerce/ProductManagementView`、`views/operation/NoticeManagementView`
|
||||||
|
- **不改既有页面**;`route_name` 用 `commerce-products` / `operation-notices`(先 grep 确认不重名)
|
||||||
|
|
||||||
|
## 六、门禁
|
||||||
|
|
||||||
|
- 后端:`mvn test` 存量 **91 条不得跑红** + 新增测试(商品三类校验、公告可见性委托、
|
||||||
|
两个控制器的接入层契约、权限点注解检查)
|
||||||
|
- 前端:`pnpm build:pro` 通过 + eslint 零错误
|
||||||
|
- 端到端:后台登录 → 菜单出现两个新页面 → 建商品 → 用该商品在 C 端 `verify` 能对上
|
||||||
|
(`store_product_id` 命中,不再是 `200002 该商品未在后台配置`);公告发布 → C 端 `/app/notices` 可见 → 下架/过期 → 不可见
|
||||||
|
|
||||||
|
## 七、风险 / 待确认
|
||||||
|
|
||||||
|
1. **不新增静态路由**是否与条目"两边都要加"冲突:我按实测的加载机制做,若你要求同时静态登记,我再补(不影响正确性)。
|
||||||
|
2. `/app/orders/verify` 走真实 Google Play 才有完整链路;本机无商店凭据,端到端验证到"200002 消失、进入校验环节"为止。
|
||||||
|
3. 公告"发布时间"语义:`publish_at` 为空视为立即生效(C 端条件里已按"为空或 <= now"处理)。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# J1 全局 ID 字符串化(Long → String)+ 契约统一 —— 计划(2026-10-16)
|
||||||
|
|
||||||
|
> 依据:`docs/plans/INBOX.md` 18:30 条目。用户裁定:雪花 id 精度问题**按方案 (a) 全局修改、统一契约**。
|
||||||
|
> 起因:I1 实测「UI 点下架 → `200002 公告不存在`」,根因是 19 位雪花 id 超出 JS `Number.MAX_SAFE_INTEGER`
|
||||||
|
> (9007199254740991),浏览器 `JSON.parse` 会把它四舍五入。
|
||||||
|
|
||||||
|
## 一、方案
|
||||||
|
|
||||||
|
### 1. 后端:fruitshorts-framework 里注册全局序列化模块
|
||||||
|
|
||||||
|
- 新增 `com.fruitshorts.framework.web.JacksonLongToStringConfig`:
|
||||||
|
- `SimpleModule` 把 **`Long.class` 与 `Long.TYPE`(`long`)→ `ToStringSerializer`**;
|
||||||
|
- 通过 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 自动配置的 `ObjectMapper`。
|
||||||
|
- 两个入口(admin-api / app-api)都是 `scanBasePackages = "com.fruitshorts"`,framework 在扫描范围内,
|
||||||
|
一处生效即两端生效;**不改任何 yml**。
|
||||||
|
- **不动 `Integer` / `int`**:`pageIndex` / `pageSize` / `Result.code` / `quantity` / `durationSec` …
|
||||||
|
仍是数字。
|
||||||
|
- **序列化方向**:`Long` 值一律输出成 JSON 字符串(含普通字段、嵌套对象、`List`、`Map` 值、分页 list 元素)。
|
||||||
|
- **反序列化方向**:Jackson 默认允许 `"123"` → `Long` 的标量强转,所以**请求体里 id 传字符串可以直接绑定**;
|
||||||
|
路径变量 `/…/{id}` 与查询参数走 Spring 的 `ConversionService`,本来就是 String → Long,不受影响。
|
||||||
|
这两条都要有测试兜住。
|
||||||
|
|
||||||
|
### 2. 一个刻意的例外:`PageResult.total` 保持数字
|
||||||
|
|
||||||
|
`PageResult.total` 是 `long` 计数,不是 id。放行它会让后台 ~13 处 `el-pagination :total=` 与 C 端分页全部变成
|
||||||
|
字符串,收益为零、风险不小。所以给这个字段加 `@JsonSerialize(using = NumberSerializer.class)` 显式保持数字,
|
||||||
|
并在契约 §0 里写明「**id 一律字符串;分页 total 是计数,仍是数字**」。
|
||||||
|
⚠️ 这是我自己拍的口径,已单独列进回执,若要求"Long 一律字符串"我再把这一行去掉。
|
||||||
|
|
||||||
|
### 3. 契约统一(`docs/api-phase1.md`)
|
||||||
|
|
||||||
|
- §0 通用约定新增「**所有 ID 字段(主键与外键)一律以字符串下发**」+ 示例 + `total` 例外说明。
|
||||||
|
- §2.11 / §2.12(I1 商品/公告)里逐模块写的 "id 以字符串下发" 改成引用 §0。
|
||||||
|
- **代码侧**:I1 给 `ProductRespVO` / `NoticeRespVO` 加的 `@JsonSerialize` 与 `Result<String>` 新建返回
|
||||||
|
**回退成自然写法**(全局生效后它们就是冗余的特例),保持"所有模块一个口径"。
|
||||||
|
|
||||||
|
### 4. ReelShortWeb 适配(审计结果见 §二)
|
||||||
|
|
||||||
|
把 id 当数字用的地方逐处改成字符串语义。**不建议**的写法(`Number(id)` / `id === 数字`)全部清掉。
|
||||||
|
|
||||||
|
### 5. ReelShort(Flutter)适配(最小改动)
|
||||||
|
|
||||||
|
**先说结论:客户端 model 里的 id 已经是 `(json['id'] ?? '').toString()` —— id 方向本来就是字符串,不会崩。**
|
||||||
|
会崩的是被全局规则扫到的 **Long 计数**(`as num?` 在收到字符串时直接抛异常)。逐个 Java 字段核过类型后,
|
||||||
|
只有两个字段真正会崩:
|
||||||
|
|
||||||
|
| Dart 位置 | 字段 | Java 类型 | 处理 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lib/core/data/models/auth_session.dart:41` | `expiresIn` | `TokenRespVO.expiresIn` = `long` | 改成兼容解析 |
|
||||||
|
| `lib/core/data/models/drama.dart:101,117` | `viewCount` | `AppDramaCardVO/AppDramaDetailVO.viewCount` = `Long` | 改成兼容解析 |
|
||||||
|
| 其余 8 处 `as num?`(`durationSec`/`unlockCoin`/`number`/`totalEpisodes`/`freeEpisodes`/`code`…) | — | `Integer`/`int` | 不动(Java 侧不会被字符串化) |
|
||||||
|
|
||||||
|
做法:加一个兼容解析小工具(接受 `num` 或数字字符串),替换上面 3 处。**不做模型改造、不碰业务逻辑**,
|
||||||
|
避免和正在推进客户端的另一位同事冲突。
|
||||||
|
|
||||||
|
## 二、影响面清单(审计结果)
|
||||||
|
|
||||||
|
### ReelShortWeb(id 当数字用 → 必须改)
|
||||||
|
|
||||||
|
| 文件 | 问题 | 改法 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/utils/creatorImpersonation.ts` | `Number(raw)` 读 localStorage 里的代看创作者 id → 19 位 id 被四舍五入,`X-Creator-Id` 头会带错值 | 存/读都按字符串 |
|
||||||
|
| `src/views/contentManagement/EpisodeManagementView.vue:58` | `Number(route.query.drama)` 与 `item.id ===` 比较 → 从短剧管理带 `?drama=` 跳过来选不中 | 按字符串比较 |
|
||||||
|
| `src/views/creator-center/composables/useDramaEdit.js:45` | `Number(route.params.id)` 与 `item.id ===` 比较 → 作品编辑找不到自己那条 | 按字符串比较 |
|
||||||
|
| `src/views/permission/composables/useMenuManagement.js:50,76,97,117` | 顶级菜单哨兵值是数字 `0`,而 `raw.parentId` 会变成字符串 `"0"` → 下拉回显错 | 哨兵统一成字符串 `'0'` |
|
||||||
|
|
||||||
|
### ReelShortWeb(核过不需要改)
|
||||||
|
|
||||||
|
- `total.value = res.data.total`(13 处)→ `total` 保持数字,无需改。
|
||||||
|
- `role.id === selectedRoleId`、`detail.id === row.id`、`useDramaEdit#findIndex`、`useCreatorPrice#find`
|
||||||
|
→ 两侧都来自接口,同为字符串,仍然相等。
|
||||||
|
- `formatCount(row.views)` → 内部 `Number(value)`,吃到字符串也正常。
|
||||||
|
- 全局 `parseInt`/`Number(`/id 算术/id 当数组下标/`a.id - b.id`:审计后**没有命中**。
|
||||||
|
|
||||||
|
### FruitServer(会跟着变化、需确认消费方)
|
||||||
|
|
||||||
|
- admin-api:所有 `Long` id、`viewCount`、`likeCount`、`memberCount` 等计数器 → 字符串。
|
||||||
|
- app-api:C 端 `id/dramaId/episodeId/userId`、`viewCount`、`positionMs/durationMs`、
|
||||||
|
`downloadedBytes/totalBytes`、`TokenRespVO.expiresIn` → 字符串;`Result.code`、`PageResult.total`、
|
||||||
|
`Integer` 字段 → 仍是数字。
|
||||||
|
|
||||||
|
## 三、风险 / 待确认
|
||||||
|
|
||||||
|
1. **`PageResult.total` 的例外**(见 §一.2)—— 我自己拍的,一句话可回退。
|
||||||
|
2. **契约测试的 standalone MockMvc 自带 mapper**:它本来是手搓的 `JsonMapper`,不含全局模块,
|
||||||
|
会让"字符串化"在测试里**假绿**。所以本轮把模块也挂进 `ControllerContractSupport`,让测试与生产一致;
|
||||||
|
代价是几条老断言(id 断数字)要改成断字符串。
|
||||||
|
3. **Flutter 仓库当前只有 `main`**(无 develop)。按条目要求在 develop 提交推送,会新建分支;
|
||||||
|
客户端由另一位同事推进,若他在 main 上继续,需要把这笔改动带过去 —— 回执里会写清。
|
||||||
|
4. `Map<Long, X>` 的 **key** 不在本次范围(只要求 Map 值);当前也没有接口返回 `Map<Long, ?>` 给前端。
|
||||||
|
5. 大 id 只在"新建后立刻用 id 操作"时暴露;老页面用种子小 id,所以**回归要靠端到端实测**,不能只看单测。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 —— 计划(2026-10-16)
|
||||||
|
|
||||||
|
> 依据:`docs/plans/INBOX.md` 19:40 条目。用户指令:「现在你要做的事对接客户端接口及 UI 调整,
|
||||||
|
> 仍然严格按照 specs 执行」→ 本轮做客户端 `ReelShort`(Flutter),目标是一条完整看剧闭环:
|
||||||
|
> **首页点进剧 → 详情正确 → 能起播 → 进度能续看 → 能收藏**,全链路走真接口。
|
||||||
|
> 契约以 `FruitServer/docs/api-phase1.md` §1.4 / §1.5 为准。
|
||||||
|
|
||||||
|
## 一、勘察更正(先说清,免得后面对不上)
|
||||||
|
|
||||||
|
条目里写「客户端只调了 6 个接口、没有详情和票据」—— 这条**不准确**,实测:
|
||||||
|
|
||||||
|
| 接口 | 现状 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /app/dramas/{id}` | **已接**:`CatalogApi.dramaDetail` + `CatalogController.requireDetail`,详情页在用 |
|
||||||
|
| `GET /app/episodes/{id}/play` | **半接**:`CatalogApi.playTicket` + `CatalogController.resolvePlay` 存在,但**没有任何页面调用**;详情下发的 `playUrl`(免费集已带签名)被直接使用 |
|
||||||
|
| `/app/watch/*` | **没接**:`WatchHistoryRepository` 是 `SharedPreferences` 本地实现,`demoProfileId` 假身份 |
|
||||||
|
| `/app/favorites/*` | **没接**:收藏走本地 `EngagementController`(SharedPreferences),点赞也是本地(点赞属 K3,不动) |
|
||||||
|
|
||||||
|
所以本轮真正要做的是:**播放票据接上并带过期重取**、**进度从本地换远程**、**收藏换服务端**、
|
||||||
|
**三态 UI**,以及把详情页已有的加载/失败态补齐(空态、续看进度条)。
|
||||||
|
|
||||||
|
## 二、方案(分层:api → repository → logic → view)
|
||||||
|
|
||||||
|
### 1. 新增 api 类(`lib/core/data/remote/`)
|
||||||
|
|
||||||
|
| 文件 | 方法 | 对应接口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `playback_api.dart`(新增) | `ticket(episodeId)` | `GET /app/episodes/{id}/play` |
|
||||||
|
| `watch_api.dart`(新增) | `saveProgress` / `progressOfDrama` / `recent` | `POST /app/watch/progress`、`GET /app/watch/progress?dramaId=`、`GET /app/watch/recent?limit=` |
|
||||||
|
| `library_api.dart`(新增) | `toggleFavorite` / `favorites` / `removeFavorite` | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
|
||||||
|
|
||||||
|
票据调用从 `CatalogApi` **搬**到 `PlaybackApi`(条目要求新增 playback_api),
|
||||||
|
`CatalogRepository.resolvePlay` 随之移到新的 `PlaybackRepository`——这是本轮唯一"动到既有目录层"的地方,最小接线。
|
||||||
|
|
||||||
|
### 2. repository(保留抽象,测试注入假实现)
|
||||||
|
|
||||||
|
- `playback_repository.dart`(新增):`ticketFor(episodeId, {forceRefresh})`。
|
||||||
|
`HttpPlaybackRepository` 内含**短时缓存**:默认 TTL 10 分钟(签名有效期契约是 15 分钟,留余量),
|
||||||
|
**时钟与 TTL 可注入** → 过期自动重取,测试用假时钟钉死。
|
||||||
|
- `watch_history_repository.dart`(**改签名**):服务端按 `(user, drama)` 存一条续看记录,
|
||||||
|
所以接口从 `(profileId, dramaId, episodeId)` 变成
|
||||||
|
`progressFor(dramaId)` / `save({episodeId, position, duration, finished})` / `recent(limit)`;
|
||||||
|
`HttpWatchHistoryRepository` + `InMemoryWatchHistoryRepository`(测试用);
|
||||||
|
`demoProfileId` 删除;`clampResumePosition` 保留(续看钳位规则不变)。
|
||||||
|
- `favorite_repository.dart`(新增):`toggle` / `list` / `remove`;HTTP + 内存实现。
|
||||||
|
|
||||||
|
### 3. controller / logic
|
||||||
|
|
||||||
|
- `favorite_controller.dart`(新增):`savedIds` + `cards` + `loading/error`,
|
||||||
|
`toggle(dramaId)` **乐观更新 → 失败回滚**(`DELETE`/`toggle` 任一失败都回到原状态)。
|
||||||
|
- `DramaDetailLogic` / `MineLogic` / `PlayerLogic`:改用新的 `WatchHistoryRepository`,
|
||||||
|
续看点从 `WatchProgress(episodeId, positionMs)` 来(不再是本地 episodeId 字符串)。
|
||||||
|
- `PlayerLogic`:
|
||||||
|
- 起播前 `ticketFor(episode)`,把签名地址写回 session 的剧集对象;**地址原样交给播放器**
|
||||||
|
(`AppConfig.mediaUrl` 只补 host,不动 path/query —— 新增测试钉住 `?e=&s=` 原样保留);
|
||||||
|
- 票据拿不到 / `accessType=COIN_REQUIRED|NO_MEDIA` → 由**后端返回值**驱动 UI(锁定/无片源提示 + 重试);
|
||||||
|
- 进度上报**节流 10 秒**(原实现是 5 秒),切集/退出/切后台各补一次,`finished` 在播完时上报。
|
||||||
|
|
||||||
|
### 4. UI
|
||||||
|
|
||||||
|
- 详情页:加载中 / 失败(可重试)/ 空剧集三态;续看位置在"继续观看"按钮与对应剧集行上给进度条。
|
||||||
|
- 播放页:票据加载中 / 失败(可重试)/ 锁定(`accessType` 驱动)三态;收藏按钮真实状态 + 回滚 +
|
||||||
|
失败提示(未登录先引导登录,沿用现有 `ensureSignedIn`)。
|
||||||
|
- 我的页:最近观看卡片三态(服务端续看位置驱动)、收藏列表三态,去掉 `demoProfileId` 假身份。
|
||||||
|
- 文案:新增键写进 `app_en.arb` / `app_zh.arb` / `app_id.arb`,`flutter gen-l10n` 重新生成。
|
||||||
|
|
||||||
|
## 三、影响面清单
|
||||||
|
|
||||||
|
新增:`playback_api.dart`、`watch_api.dart`、`library_api.dart`、`watch_progress.dart`(模型)、
|
||||||
|
`playback_repository.dart`、`favorite_repository.dart`、`favorite_controller.dart`。
|
||||||
|
|
||||||
|
改:`watch_history_repository.dart`(签名)、`catalog_api.dart`(去掉 playTicket)、
|
||||||
|
`catalog_repository.dart`(去掉 resolvePlay)、`catalog_controller.dart`(同步)、
|
||||||
|
`play_ticket.dart`(补 dramaId/number/previewSeconds)、`app.dart`(注入三个仓库 + FavoriteController)、
|
||||||
|
`drama_detail_{logic,view,binding}.dart`、`mine_{logic,view,binding}.dart`、
|
||||||
|
`player_{logic,view,binding}.dart`、`discover_{logic,binding}.dart`(续看点来源)、
|
||||||
|
`app_{en,zh,id}.arb` + 生成的 4 个 l10n 文件。
|
||||||
|
|
||||||
|
测试:改 `test/support/fake_watch_history.dart`、`test/playback/watch_history_test.dart`、
|
||||||
|
`test/catalog/catalog_controller_test.dart`、`test/support/fake_catalog.dart`、
|
||||||
|
`test/{discover,mine,drama_detail,player}/…`(续看点改签名);新增
|
||||||
|
`playback_ticket_expiry_test.dart`、`progress_throttle_test.dart`、`watch_history_remote_test.dart`、
|
||||||
|
`favorite_controller_test.dart`、`player_ticket_gate_test.dart`、`integration/live_watch_flow_test.dart`。
|
||||||
|
|
||||||
|
## 四、风险 / 待确认
|
||||||
|
|
||||||
|
1. **改动面比"只加接口"大**:`WatchHistoryRepository` 的签名必须改(服务端是"每剧一条续看记录",
|
||||||
|
没有 profile 概念),这会连带改 4 个 logic + 6 个测试文件。这是接口模型决定的,不是顺手重构。
|
||||||
|
2. **未登录的边界**:`/app/watch/*` 与 `/app/favorites/*` 需要登录态。客户端策略:
|
||||||
|
未登录时**不发**这些请求(浏览/试看仍可匿名),收藏按钮沿用"先引导登录"。
|
||||||
|
3. **票据过期无法从响应判断**(契约里没有 expiresAt 字段),所以用**客户端 TTL(10 分钟)**保守失效重取;
|
||||||
|
若后端后续补 `expiresAt`,直接换成按字段判断即可(口径已写进计划)。
|
||||||
|
4. **`/app/watch/recent` 只给 dramaId/episodeId/位置**,没有剧名封面 —— K1 里"我的"页用
|
||||||
|
已加载的首页/发现卡片按 id 映射显示;映射不到就不显示(K2 的记录页再做成独立请求)。
|
||||||
|
5. 联调测试需要登录态:新增的 live 测试会**注册一个一次性邮箱账号**,跑完在库里清掉(回执写明)。
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 2026-10-17 K10 统一空列表占位(EmptyState)
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
新建统一 `EmptyState` 组件,替换任务书列出的 10 处纯文字空态,保留现有空态
|
||||||
|
文案、按钮行为和错误态逻辑。只改空态视觉、三语 l10n 与测试,不改数据、接口、
|
||||||
|
列表加载/失败逻辑或页面信息架构。
|
||||||
|
|
||||||
|
- 客户端分支:`feature/flutter-k10-empty-states`,基线 `develop 19babb8`。
|
||||||
|
- 文档分支:`docs/k10-empty-states`,FruitServer 基线 K10 任务书 `3f84bc6`。
|
||||||
|
- 不引入图片、SVG、字体或第三方依赖。
|
||||||
|
- 不跑真机,设备侧深色/浅色、小屏和页面状态由用户验收。
|
||||||
|
|
||||||
|
## 二、组件设计
|
||||||
|
|
||||||
|
`lib/core/widgets/empty_state.dart`:
|
||||||
|
|
||||||
|
1. 默认居中,最大宽度 280,外层 Padding 24。
|
||||||
|
2. 默认图形 96x96、圆角 28,背景 `AppColors.blue` 透明度 0.11;图标 42。
|
||||||
|
3. 深色图标色 `AppColors.blueSoft`,浅色图标色 `AppColors.blue`。
|
||||||
|
4. compact 图形 64x64、圆角 18、图标 28,用于评论弹窗。
|
||||||
|
5. 标题 16/w600(compact 14)、最多 2 行;可选副标题 13、`muted`、最多 2 行。
|
||||||
|
6. 可选主色 TextButton,最小点击区域 44x44;保留调用方动作。
|
||||||
|
7. 外层 `Semantics(label: title)`;图形、标题和副标题文本使用
|
||||||
|
`ExcludeSemantics`,避免重复朗读,操作按钮仍保持可访问。
|
||||||
|
8. 稳定 Key:`empty-state-art`、`empty-state-icon`、`empty-state-title`、
|
||||||
|
`empty-state-action`。
|
||||||
|
|
||||||
|
新增通用副标题 `emptyHint`:
|
||||||
|
|
||||||
|
- zh:有新内容时会显示在这里。
|
||||||
|
- en:New content will appear here.
|
||||||
|
- id:Konten baru akan muncul di sini.
|
||||||
|
|
||||||
|
## 三、10 处替换与图标
|
||||||
|
|
||||||
|
| 位置 | 图标 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| 首页 noStories | `movie_outlined` | 保留重试 |
|
||||||
|
| 首页分类空 | `grid_view_rounded` | 无新增动作 |
|
||||||
|
| 首页搜索空 | `search_off_rounded` | 无新增动作 |
|
||||||
|
| 收藏 noFavorites | `favorite_border_rounded` | 原按钮不变 |
|
||||||
|
| 通知 noNotices | `notifications_none_rounded` | 原下拉刷新不变 |
|
||||||
|
| 详情 noEpisodesYet | `video_library_outlined` | 保留重试 |
|
||||||
|
| 我的-片单 noSavedShows | `bookmark_border_rounded` | 无新增动作 |
|
||||||
|
| 我的-最近剧目 noStories | `history_rounded` | 无新增动作 |
|
||||||
|
| 下载 noDownloads | `download_outlined` | 原清空按钮不变 |
|
||||||
|
| 观看记录 noWatchRecords | `history_rounded` | 原清空/刷新逻辑不变 |
|
||||||
|
| 评论 noComments | `chat_bubble_outline_rounded` | compact |
|
||||||
|
|
||||||
|
首页错误态继续使用 `error_outline_rounded` 和 `catalogError`;分类、收藏、通知、
|
||||||
|
记录、评论等失败态继续使用原错误图标/重试,不允许用 `EmptyState` 冒充失败。
|
||||||
|
|
||||||
|
## 四、影响文件
|
||||||
|
|
||||||
|
- `lib/core/widgets/empty_state.dart`(新增)
|
||||||
|
- `lib/pages/home/home_view.dart`
|
||||||
|
- `lib/pages/discover/widgets/home_drama_grid.dart`
|
||||||
|
- `lib/pages/favorites/favorites_page.dart`
|
||||||
|
- `lib/pages/notices/notices_page.dart`
|
||||||
|
- `lib/pages/drama_detail/drama_detail_view.dart`
|
||||||
|
- `lib/pages/mine/mine_view.dart`
|
||||||
|
- `lib/pages/downloads/downloads_page.dart`
|
||||||
|
- `lib/pages/watch_records/watch_records_page.dart`
|
||||||
|
- `lib/pages/player/widgets/drama_comments_sheet.dart`
|
||||||
|
- `lib/l10n/app_en.arb`、`app_zh.arb`、`app_id.arb` 与生成文件
|
||||||
|
- 组件测试及收藏/下载/记录/通知/评论页面空态测试
|
||||||
|
- `FruitServer/docs/plans/2026-10-17-K10.md`
|
||||||
|
- `FruitServer/docs/plans/PROGRESS.md`
|
||||||
|
- `FruitServer/docs/plans/INBOX.md`
|
||||||
|
|
||||||
|
## 五、TDD 与门禁
|
||||||
|
|
||||||
|
1. 先新增 `EmptyState` 组件测试和五处页面空态测试,确认组件不存在时红灯。
|
||||||
|
2. 实现组件并替换 10 处空态后跑定向测试绿灯。
|
||||||
|
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||||
|
4. 构建前检查并发 `flutter run/build`;当前有用户 iOS `flutter run` 会话,
|
||||||
|
不主动终止,若仍未结束则等待其退出后再构建。
|
||||||
|
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||||
|
合并推送。作者与提交者统一为
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
|
||||||
|
## 六、风险与待确认
|
||||||
|
|
||||||
|
- 首页当前把 `catalog.error` 与空列表放在同一分支,需先拆出错误态,再单独用
|
||||||
|
`EmptyState` 渲染 noStories,避免错误态被误认为空态。
|
||||||
|
- 部分空态位于 Sliver、固定高度滚动区或弹窗内,需保持可滚动和居中。
|
||||||
|
- 组件外层统一读标题,若操作按钮位于同一 Semantics 容器内,必须确保按钮自身
|
||||||
|
语义未被 `ExcludeSemantics` 吞掉。
|
||||||
|
- 最大宽度 280 与 compact 64 图标容器需在 320px 宽度和浅/深色主题下无溢出。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 2026-10-17 K11 首页网格 3 列 + 分类首位「全部」
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
在 K9 首页「全部剧目」网格上做最小修订:每行从 2 个改为 3 个,分类首个仍绑定
|
||||||
|
`CategoryController.allCode`,文案改为「全部 / All / Semua」。
|
||||||
|
|
||||||
|
- 客户端分支:`feature/flutter-k11-home-grid-3col`,基线 `develop 0466b4b`。
|
||||||
|
- 文档分支:`docs/k11-home-grid-3col`,FruitServer 基线任务书 `2a06190`。
|
||||||
|
- 不改轮播、空态、三态、数据源、接口、分页或其他页面。
|
||||||
|
- 不跑真机;设备侧 3 列布局、分类切换和详情跳转由用户验收。
|
||||||
|
|
||||||
|
## 二、实现方案
|
||||||
|
|
||||||
|
1. `home_drama_grid.dart`:
|
||||||
|
- `crossAxisCount: 3`
|
||||||
|
- `mainAxisSpacing: 14`
|
||||||
|
- `crossAxisSpacing: 10`
|
||||||
|
- `childAspectRatio: 2 / 3` 不变
|
||||||
|
2. 3 列下卡片仍只保留标题最多 2 行、类型最多 1 行;由现有 `Text` 的
|
||||||
|
`maxLines` / `ellipsis` 保证不溢出。
|
||||||
|
3. `discover_view.dart` 分类第一项 code 保持 `CategoryController.allCode`,
|
||||||
|
label 从 `l10n.forYou` 改为 `l10n.all`。
|
||||||
|
4. 已核对三份 ARB 已存在 `all` key:
|
||||||
|
- en:All
|
||||||
|
- zh:全部
|
||||||
|
- id:Semua
|
||||||
|
|
||||||
|
因此直接复用现有 l10n,不重复插入 JSON key;热门、最新和服务端分类顺序不变。
|
||||||
|
|
||||||
|
## 三、测试
|
||||||
|
|
||||||
|
- 更新 K9 网格断言:`crossAxisCount == 3`、`crossAxisSpacing == 10`、
|
||||||
|
`mainAxisSpacing == 14`、比例仍为 2/3。
|
||||||
|
- 新增首个分类 chip 文案断言为 `All`;切换分类时仍能更新网格。
|
||||||
|
- K9/K10 既有测试保留,不删除用例;只更新本次行为明确变化的参数断言。
|
||||||
|
- 组件/页面空态测试保持通过。
|
||||||
|
|
||||||
|
## 四、影响文件
|
||||||
|
|
||||||
|
- `ReelShort/lib/pages/discover/widgets/home_drama_grid.dart`
|
||||||
|
- `ReelShort/lib/pages/discover/discover_view.dart`
|
||||||
|
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`
|
||||||
|
- `FruitServer/docs/plans/2026-10-17-K11.md`
|
||||||
|
- `FruitServer/docs/plans/PROGRESS.md`
|
||||||
|
- `FruitServer/docs/plans/INBOX.md`
|
||||||
|
|
||||||
|
## 五、门禁与 Git
|
||||||
|
|
||||||
|
1. 先更新测试取得与 3 列/全部文案相关的红灯。
|
||||||
|
2. 实现后运行定向测试。
|
||||||
|
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||||
|
4. 当前有 Android `flutter run` 会话,不主动终止;构建前检查进程,必要时使用
|
||||||
|
独立工作区副本构建,避免争用原项目 `build/`。
|
||||||
|
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||||
|
合并推送。作者与提交者统一为
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
|
||||||
|
## 六、风险与待确认
|
||||||
|
|
||||||
|
- 3 列后单卡宽度变窄,长标题和类型必须继续 ellipsis,不允许改变卡片结构。
|
||||||
|
- 现有 `all` l10n 已具备三语,新增重复 key 会破坏 ARB;本批复用现有 key。
|
||||||
|
- 用户正在运行的 Flutter `run` 不终止;APK 构建需隔离原 `build/`。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 2026-10-17 K12 首页去标题 + 空态视觉重做
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
在 K11 首页基础上做两项视觉修订:
|
||||||
|
|
||||||
|
1. 删除首页「全部剧目」标题,分类 chips 后直接显示网格。
|
||||||
|
2. 将 `EmptyState` 从单个实心圆角块改为轻盈的双层同心圆。
|
||||||
|
|
||||||
|
只改首页间距、`EmptyState` 视觉与对应测试,不改接口、数据、轮播、三态或其他页面。
|
||||||
|
|
||||||
|
- 客户端分支:`feature/flutter-k12-home-empty-polish`,基线 `develop b5a8656`。
|
||||||
|
- 文档分支:`docs/k12-home-empty-polish`,FruitServer 基线任务书 `87a37e9`。
|
||||||
|
- 不引入图片、SVG、字体或动画;不跑真机。
|
||||||
|
|
||||||
|
## 二、首页去标题
|
||||||
|
|
||||||
|
- 删除 `Key('home-all-dramas-title')` 的 Text。
|
||||||
|
- 删除标题与 chips 之间、标题与网格之间的多余间距。
|
||||||
|
- chips 与网格间距调整为 14px。
|
||||||
|
- 网格、数据、空态和分类切换逻辑保持不变。
|
||||||
|
|
||||||
|
## 三、EmptyState 双层同心圆
|
||||||
|
|
||||||
|
默认:
|
||||||
|
|
||||||
|
- 外层直径 84,blue 5.5% 填充,1px blue 11% 描边。
|
||||||
|
- 内层直径 56,blue 11% 填充。
|
||||||
|
- 图标 27px,深色 `blueSoft` / 浅色 `blue`。
|
||||||
|
- 标题 15/w600,与图形间距 14。
|
||||||
|
- 副标题 12.5 muted,行高 1.45,与标题间距 6。
|
||||||
|
- TextButton 13 主色,点击区至少 44,与副标题间距 8。
|
||||||
|
- 上下 Padding 28,最大宽度 260。
|
||||||
|
|
||||||
|
compact:
|
||||||
|
|
||||||
|
- 外层 60、内层 40、图标 21。
|
||||||
|
- 图形与标题间距 10,上下 Padding 16。
|
||||||
|
|
||||||
|
保留外层 Key `empty-state-art`,新增内层 Key `empty-state-art-inner`;继续使用
|
||||||
|
`empty-state-icon/title/action`。外层 Semantics 仍只读标题。
|
||||||
|
|
||||||
|
## 四、测试
|
||||||
|
|
||||||
|
- 更新组件测试:外/内层尺寸、图标尺寸、标题 15、副标题 12.5、按钮 44、
|
||||||
|
最大宽 260、compact 尺寸与深浅主题颜色。
|
||||||
|
- 首页测试:`home-all-dramas-title` 不存在;chips 存在后直接能找到
|
||||||
|
`home-all-dramas-grid`。
|
||||||
|
- K9/K10/K11 既有用例保留,不删除测试文件;只更新本次明确变化的视觉/锚点断言。
|
||||||
|
- 既有壳层测试中若用「All Dramas」作为页面定位锚点,改用当前稳定锚点
|
||||||
|
`All` chip,不降低导航流程覆盖。
|
||||||
|
|
||||||
|
## 五、影响文件
|
||||||
|
|
||||||
|
- `ReelShort/lib/core/widgets/empty_state.dart`
|
||||||
|
- `ReelShort/lib/pages/discover/discover_view.dart`
|
||||||
|
- `ReelShort/test/empty_state/empty_state_test.dart`
|
||||||
|
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`
|
||||||
|
- `ReelShort/test/widget_test.dart`(仅稳定锚点更新)
|
||||||
|
- `FruitServer/docs/plans/2026-10-17-K12.md`
|
||||||
|
- `FruitServer/docs/plans/PROGRESS.md`
|
||||||
|
- `FruitServer/docs/plans/INBOX.md`
|
||||||
|
|
||||||
|
## 六、门禁与 Git
|
||||||
|
|
||||||
|
1. 先更新组件与首页测试,取得标题仍存在、空态仍是旧尺寸的红灯。
|
||||||
|
2. 实现后跑定向测试。
|
||||||
|
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||||
|
4. 当前已确认真机会话停止且无并发构建;构建前再次检查。
|
||||||
|
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||||
|
合并推送。作者与提交者统一为
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
|
||||||
|
## 七、风险与待确认
|
||||||
|
|
||||||
|
- 删除标题后,部分既有测试用「All Dramas」文本定位页面;需改为稳定的
|
||||||
|
`All` chip 或网格 Key,保持原测试流程。
|
||||||
|
- 双层圆使用固定尺寸和 Stack,小屏需保证 84/60 不溢出。
|
||||||
|
- 副标题 12.5 与按钮 13 是视觉规格;测试按 double 精确断言。
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 2026-10-17 K13 运行时服务器地址设置(仅 debug)
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
解决 debug APK 不带 `--dart-define` 时固定回退到
|
||||||
|
`http://jiangdeMacBook-Air.local:8081` 的问题。增加仅 debug 可用的运行时地址
|
||||||
|
覆盖,保存后下次启动生效。
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SharedPreferencesAsync dev.api_base_url
|
||||||
|
> --dart-define(API_BASE_URL)
|
||||||
|
> AppConfig 现有默认地址
|
||||||
|
```
|
||||||
|
|
||||||
|
- 客户端分支:`feature/flutter-k13-dev-server-setting`,基线 `b762289`。
|
||||||
|
- 文档分支:`docs/k13-dev-server-setting`,FruitServer 基线 `1a5951c`。
|
||||||
|
- release 不读取 SharedPreferences 覆盖,也不显示设置入口。
|
||||||
|
- 不做环境列表、远程配置、接口或后端改动。
|
||||||
|
- 不跑真机。当前有真机 `flutter run` 时,构建使用隔离副本,不终止用户会话。
|
||||||
|
|
||||||
|
## 二、运行时解析
|
||||||
|
|
||||||
|
新增 `lib/core/config/server_endpoint.dart`:
|
||||||
|
|
||||||
|
1. 常量 key:`dev.api_base_url`。
|
||||||
|
2. `normalize`:
|
||||||
|
- `trim()`;
|
||||||
|
- 只接受 `http://` 或 `https://`;
|
||||||
|
- `Uri.host` 必须非空;
|
||||||
|
- 去掉末尾所有 `/`。
|
||||||
|
3. `resolve`:
|
||||||
|
- 本地值有效时使用本地值;
|
||||||
|
- 否则使用有效 dart-define;
|
||||||
|
- 否则使用现有默认值。
|
||||||
|
4. `load({debug, preferences})`:
|
||||||
|
- debug=false 时立即返回,不创建/读取 SharedPreferences;
|
||||||
|
- debug=true 时读取并规范化,合法值写入 `AppConfig` 的运行时覆盖。
|
||||||
|
5. `save` 与 `clear`:
|
||||||
|
- save 前规范化,非法值返回 false 且不写;
|
||||||
|
- clear 删除 key。
|
||||||
|
|
||||||
|
`AppConfig` 增加运行时覆盖读写入口和编译期/默认地址 getter;`apiBaseUrl` 优先
|
||||||
|
返回已加载的运行时值。`main()` 在 `runApp` 前
|
||||||
|
`await ServerEndpoint.load(debug: kDebugMode)`,确保 `LumaDramaApp` 创建
|
||||||
|
`ApiClient` 前已加载完成。
|
||||||
|
|
||||||
|
## 三、Debug 设置入口
|
||||||
|
|
||||||
|
`MineLogic.showLanguage` 增加 `showServerSetting` 参数。我的页设置弹窗中在语言
|
||||||
|
列表下方增加「服务器地址」行:
|
||||||
|
|
||||||
|
- 仅 `MyPage.debugShowSetting == true` 时显示,默认值 `kDebugMode`,测试可注入;
|
||||||
|
- 副标题显示当前 `AppConfig.apiBaseUrl`;
|
||||||
|
- 点击打开编辑弹窗,预填当前地址;
|
||||||
|
- 保存合法地址后关闭编辑弹窗并提示“重启 App 生效”;
|
||||||
|
- 非法地址在输入框显示校验错误,不保存;
|
||||||
|
- 提供「恢复默认」,清除本地 key,并提示重启生效;
|
||||||
|
- release 默认不显示,也不触发本地读取。
|
||||||
|
|
||||||
|
新增三语文案:
|
||||||
|
|
||||||
|
- `serverAddress`
|
||||||
|
- `serverAddressHint`
|
||||||
|
- `saveAddress`
|
||||||
|
- `restartToApply`
|
||||||
|
- `restoreDefault`
|
||||||
|
- `invalidServerAddress`
|
||||||
|
|
||||||
|
## 四、测试
|
||||||
|
|
||||||
|
新增 `test/config/server_endpoint_test.dart`:
|
||||||
|
|
||||||
|
- 本地覆盖 > dart-define > 默认;
|
||||||
|
- 非法本地值忽略并回落;
|
||||||
|
- trim、末尾斜杠、http/https;
|
||||||
|
- `ftp://`、无 scheme、空 host 被拒绝;
|
||||||
|
- debug=false 不读取覆盖;
|
||||||
|
- save/clear 对 SharedPreferencesAsync 的行为。
|
||||||
|
|
||||||
|
新增/扩展设置弹窗测试:
|
||||||
|
|
||||||
|
- debug 注入 true:显示服务器地址行,可打开编辑弹窗;
|
||||||
|
- 注入 false:不显示入口;
|
||||||
|
- 非法地址显示错误且不写入。
|
||||||
|
|
||||||
|
既有测试只更新必要锚点/新增断言,不删除用例。
|
||||||
|
|
||||||
|
## 五、影响文件
|
||||||
|
|
||||||
|
- `ReelShort/lib/core/config/server_endpoint.dart`(新增)
|
||||||
|
- `ReelShort/lib/core/config/app_config.dart`
|
||||||
|
- `ReelShort/lib/main.dart`
|
||||||
|
- `ReelShort/lib/pages/mine/mine_view.dart`
|
||||||
|
- `ReelShort/lib/pages/mine/mine_logic.dart`
|
||||||
|
- `ReelShort/lib/l10n/app_en.arb`、`app_zh.arb`、`app_id.arb` 与生成文件
|
||||||
|
- `ReelShort/test/config/server_endpoint_test.dart`(新增)
|
||||||
|
- 我的设置弹窗测试
|
||||||
|
- `FruitServer/docs/plans/2026-10-17-K13.md`
|
||||||
|
- `FruitServer/docs/plans/PROGRESS.md`
|
||||||
|
- `FruitServer/docs/plans/INBOX.md`
|
||||||
|
|
||||||
|
## 六、门禁与 Git
|
||||||
|
|
||||||
|
1. 先写运行时解析与入口可见性测试,确认红灯。
|
||||||
|
2. 实现后跑定向测试。
|
||||||
|
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||||
|
4. 当前真机 run 会话不终止;构建在独立副本中进行,原 `build/` 不受影响。
|
||||||
|
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||||
|
合并推送。作者与提交者统一为
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
|
||||||
|
## 七、风险与待确认
|
||||||
|
|
||||||
|
- `AppConfig.apiBaseUrl` 当前是纯 getter;增加运行时静态状态后,测试必须显式
|
||||||
|
清理,避免用例间污染。
|
||||||
|
- release 的“完全不读取”由 `ServerEndpoint.load(debug: kDebugMode)` 和主入口
|
||||||
|
的 debug 参数共同保证;测试使用可注入 debug 参数验证。
|
||||||
|
- 保存和恢复默认都只在下次启动生效,当前 ApiClient 不在运行中重建。
|
||||||
|
- 设置入口只在 debug 显示,不修改发布包的信息架构。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 2026-10-17 K16 注册限流可配置
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
将 C 端注册的 IP/设备 24 小时限流阈值从硬编码改为后台 `sys_config` 配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app.register.max-per-ip-per-day
|
||||||
|
app.register.max-per-device-per-day
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时优先读取后台配置;键缺失或非整数时回退 `@Value` 注入值(默认 5/3);
|
||||||
|
值小于等于 0 时跳过对应 guard。注册接口、错误码和错误文案不变。
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
1. `AppAuthService` 注入 `ConfigService`,从 `sys_config` 优先读取两个键;
|
||||||
|
再用 `@Value` 注入 yml 值作为缺失/非法时的 fallback。
|
||||||
|
2. `sql/data-phase1.sql` 幂等写入开发默认 0/0 和说明。
|
||||||
|
3. `application.yml` 只声明 fallback(默认 5/3),运行时不以后台固化值为准。
|
||||||
|
4. `docs/api-phase1.md` §1.1 补充后台配置键、缺省/非法回退和 0=不限制口径。
|
||||||
|
5. 测试 mock `ConfigService`,覆盖自定义、0、缺省/非数字回退。
|
||||||
|
6. 确认后台「系统设置」页面可访问 `/admin/configs`;若现有表单未展示新键,
|
||||||
|
记录为待后续 Web 表单补充,不把配置固化回 yml。
|
||||||
|
|
||||||
|
## 验证与交付
|
||||||
|
|
||||||
|
- `mvn test`
|
||||||
|
- `mvn -pl fruitshorts-app-api -am package -DskipTests`
|
||||||
|
- 停止当前 8081 服务,启动新 jar,`/healthz` 通过
|
||||||
|
- 使用新设备号连续注册至少 4 次,确认不再 429(接口自检)
|
||||||
|
- 后台把 IP 上限临时改为 2:前两次成功,第三次返回 429;恢复 0 后继续注册成功
|
||||||
|
- `feature/backend-register-rate-config-fix` 以 `--no-ff` 合并回 `develop` 并 push
|
||||||
|
- PROGRESS 记录命令、原始输出和解读
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 配置 0 会关闭注册滥用保护,仅用于当前开发环境;生产需在后台恢复 5/3。
|
||||||
|
- 旧 8081 服务重启期间会有短暂不可用。
|
||||||
|
- 限流仍基于客户端可伪造 IP/设备号,本次不放宽其他风控。
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# K2 客户端第二批:分类 + 观看记录 + 收藏列表 + 下载授权管理
|
||||||
|
|
||||||
|
> 依据:`docs/plans/INBOX.md` 2026-10-17 `[待处理] K2` 条目。
|
||||||
|
> 客户端基线:`ReelShort` `develop` / `00280ca`。
|
||||||
|
> 接口唯一权威:`FruitServer/docs/api-phase1.md` §0、§1.3、§1.5、§1.6。
|
||||||
|
> 本批只做 K2,完成后停下写回执,不连做 K3/K4。
|
||||||
|
|
||||||
|
## 一、范围与完成口径
|
||||||
|
|
||||||
|
### K2-A 分类
|
||||||
|
|
||||||
|
- 接 `GET /app/categories`,分类 id 全程按字符串处理。
|
||||||
|
- 发现页分类条改为服务端分类;保留现有分类条样式。
|
||||||
|
- 分类内容只从 `GET /app/discover/feed` 的 `sections[]` 中读取
|
||||||
|
`code == "category-{id}"` 的分区,不在客户端拼装分类请求。
|
||||||
|
- 分类在发现 feed 中没有对应分区时显示空态。
|
||||||
|
- 保留 `hot` / `new` 入口与既有样式;其内容同样只按发现 feed 中对应 `code`
|
||||||
|
分区读取,缺失时显示空态。
|
||||||
|
|
||||||
|
### K2-B 观看记录页
|
||||||
|
|
||||||
|
- 接 `GET /app/watch/recent?limit=20`、`DELETE /app/watch/progress/{dramaId}`、
|
||||||
|
`DELETE /app/watch/progress`。
|
||||||
|
- 最近记录先去重 `dramaId`,再按 id 补 `GET /app/dramas/{id}` 卡片数据。
|
||||||
|
- 详情补取并发上限 4,同页按 `dramaId` 只请求一次。
|
||||||
|
- 单条详情失败只影响该条:显示“加载失败”占位与单项重试,其余记录正常渲染。
|
||||||
|
- 独立“观看记录”页:封面、剧名、集号、进度、更新时间;点击详情并续看;
|
||||||
|
单条删除、清空均有二次确认;删除采用乐观更新,失败回滚并提示。
|
||||||
|
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
|
||||||
|
|
||||||
|
### K2-C 收藏列表页
|
||||||
|
|
||||||
|
- 复用 `GET /app/favorites`、`DELETE /app/favorites/{dramaId}`、
|
||||||
|
`POST /app/favorites/toggle`,不增加分页参数。
|
||||||
|
- 独立“我的收藏”页,复用卡片数据模型与列表卡片视觉;点击进详情。
|
||||||
|
- 取消收藏采用乐观更新,失败回滚并提示。
|
||||||
|
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
|
||||||
|
|
||||||
|
### K2-D 下载授权管理
|
||||||
|
|
||||||
|
- 接契约 §1.6 全部 8 个接口:
|
||||||
|
`GET /app/downloads/policy`、`PUT /app/downloads/settings`、
|
||||||
|
`POST /app/downloads`、`GET /app/downloads`、
|
||||||
|
`GET /app/downloads/{episodeId}/url`、`POST /app/downloads/{episodeId}/progress`、
|
||||||
|
`DELETE /app/downloads/{episodeId}`、`DELETE /app/downloads`。
|
||||||
|
- 详情页与播放页增加下载入口;后端拒绝时直接展示后端 `message`。
|
||||||
|
- 独立“我的下载”页:策略卡、授权列表、单条删除、清空、仅 Wi-Fi、最高清晰度。
|
||||||
|
- 授权状态与任务状态完全使用后端字符串,客户端不新增状态枚举、不自行判断付费/配额。
|
||||||
|
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
|
||||||
|
- 明确不做:文件分片落盘、断点续传、离线播放、`licenseKey` 本地校验。
|
||||||
|
`url` 与 `progress` 本批只提供并测试 API 方法,不驱动实际下载任务。
|
||||||
|
|
||||||
|
## 二、实施顺序与 TDD
|
||||||
|
|
||||||
|
1. 基线:从 `ReelShort/develop` 建 `feature/flutter-k2-library`。
|
||||||
|
2. K2-A:先写分类解析、`category-{id}` 映射、无分区空态和筛选状态测试,
|
||||||
|
确认红灯后实现;再跑该块门禁。
|
||||||
|
3. K2-B:先写控制器测试覆盖详情去重、并发上限 4、单条失败降级、
|
||||||
|
删除/清空乐观更新与回滚;确认红灯后实现 API、repository、controller、页面;
|
||||||
|
再跑门禁。
|
||||||
|
4. K2-C:先写独立收藏页取消收藏失败回滚、三态与登录引导测试,
|
||||||
|
确认红灯后实现页面与导航;再跑门禁。
|
||||||
|
5. K2-D:先写 DTO 字符串 ID/时间解析、策略/列表渲染、授权失败透传、
|
||||||
|
设置更新、删除/清空和未登录测试;确认红灯后实现;
|
||||||
|
`ApiClient` 仅补契约所需的 `PUT` 能力;再跑门禁。
|
||||||
|
6. 三份 ARB 一次补齐并生成 l10n;全量跑
|
||||||
|
`dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||||
|
7. 门禁全绿后合并回 `develop` 并 push;不动 `main`。
|
||||||
|
8. 在 `PROGRESS.md` 与 INBOX K2 条目追加回执;真机验证交用户执行。
|
||||||
|
|
||||||
|
## 三、预计影响文件
|
||||||
|
|
||||||
|
### 数据层
|
||||||
|
|
||||||
|
- 修改:`lib/core/data/remote/api_client.dart`(补 `put`)
|
||||||
|
- 修改:`lib/core/data/models/watch_progress.dart`(补 `updatedAt`)
|
||||||
|
- 修改:`lib/core/data/remote/watch_api.dart`(补清空)
|
||||||
|
- 修改:`lib/core/data/repositories/watch_history_repository.dart`(补清空)
|
||||||
|
- 新增:`lib/core/data/models/content_category.dart`
|
||||||
|
- 新增:`lib/core/data/remote/category_api.dart`
|
||||||
|
- 新增:`lib/core/data/repositories/category_repository.dart`
|
||||||
|
- 新增:`lib/core/data/services/category_controller.dart`
|
||||||
|
- 新增:`lib/core/data/models/watch_record.dart`
|
||||||
|
- 新增:`lib/core/data/services/watch_records_controller.dart`
|
||||||
|
- 新增:`lib/core/data/models/download_policy.dart`
|
||||||
|
- 新增:`lib/core/data/models/download_license.dart`
|
||||||
|
- 新增:`lib/core/data/remote/download_api.dart`
|
||||||
|
- 新增:`lib/core/data/repositories/download_repository.dart`
|
||||||
|
- 新增:`lib/core/data/services/download_controller.dart`
|
||||||
|
|
||||||
|
### 页面与共享 UI
|
||||||
|
|
||||||
|
- 修改:`lib/app.dart`(新仓库/控制器注入)
|
||||||
|
- 修改:`lib/core/widgets/auth_gate.dart`(页面级登录引导复用)
|
||||||
|
- 修改:`lib/pages/discover/discover_logic.dart`
|
||||||
|
- 修改:`lib/pages/discover/discover_view.dart`
|
||||||
|
- 修改:`lib/pages/home/home_view.dart`(我的页入口接线)
|
||||||
|
- 修改:`lib/pages/mine/mine_view.dart`(观看记录、收藏、下载入口)
|
||||||
|
- 修改:`lib/pages/drama_detail/drama_detail_view.dart`(下载入口)
|
||||||
|
- 修改:`lib/pages/player/player_view.dart`(当前集下载入口)
|
||||||
|
- 新增:`lib/pages/watch_records/watch_records_binding.dart`
|
||||||
|
- 新增:`lib/pages/watch_records/watch_records_view.dart`
|
||||||
|
- 新增:`lib/pages/favorites/favorites_binding.dart`
|
||||||
|
- 新增:`lib/pages/favorites/favorites_view.dart`
|
||||||
|
- 新增:`lib/pages/downloads/downloads_binding.dart`
|
||||||
|
- 新增:`lib/pages/downloads/downloads_view.dart`
|
||||||
|
- 视复用需要新增列表卡片/状态组件,预计位于 `lib/core/widgets/` 或各页面 `widgets/`。
|
||||||
|
|
||||||
|
### 文案
|
||||||
|
|
||||||
|
- 修改:`lib/l10n/app_en.arb`
|
||||||
|
- 修改:`lib/l10n/app_zh.arb`
|
||||||
|
- 修改:`lib/l10n/app_id.arb`
|
||||||
|
- 重新生成:`lib/l10n/app_localizations*.dart`
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- 修改:`test/catalog/catalog_controller_test.dart` 及分类筛选相关测试
|
||||||
|
- 修改:`test/discover/discover_logic_test.dart`
|
||||||
|
- 修改:`test/mine/mine_logic_test.dart`
|
||||||
|
- 修改:`test/library/favorite_controller_test.dart`
|
||||||
|
- 新增:分类 API/控制器与 section 映射测试
|
||||||
|
- 新增:观看记录 API、并发加载、单项降级、删除/清空回滚和页面三态测试
|
||||||
|
- 新增:收藏独立页、取消收藏回滚、三态和登录引导测试
|
||||||
|
- 新增:下载 DTO/API/controller/page 测试
|
||||||
|
- 新增:未登录 `AuthGate` 页面测试
|
||||||
|
|
||||||
|
## 四、契约缺口(本批登记,不改契约/后端)
|
||||||
|
|
||||||
|
1. `GET /app/watch/recent` 未返回剧名与封面,客户端只能先取最近记录,
|
||||||
|
再对去重后的 `dramaId` 调详情补齐卡片;代价是 N+1 请求。
|
||||||
|
建议后续在 `ProgressRespVO` 补卡片字段(需 specs 负责人改契约)。
|
||||||
|
2. `GET /app/favorites` 没有分页参数并返回全量数组,与
|
||||||
|
`TC-RECORD-04` 的“分页正确”不一致。本批按全量列表实现,
|
||||||
|
不自造 `page/size`。
|
||||||
|
3. `api-phase1.md` 没有搜索接口,本批不做搜索,也不以本地模糊匹配冒充搜索接口。
|
||||||
|
|
||||||
|
## 五、风险与控制
|
||||||
|
|
||||||
|
1. 记录页 N+1 详情请求可能放大流量和延迟:严格去重、并发上限 4、页内缓存,
|
||||||
|
单项失败降级,不在失败后自动无限重试。
|
||||||
|
2. 页面销毁时异步详情仍可能返回:controller 的 `notify` 做 disposed 保护,
|
||||||
|
不把迟到结果写回已退出页面。
|
||||||
|
3. `updatedAt` 为后端 `LocalDateTime` 字符串,解析失败只能显示占位,
|
||||||
|
不能让整条记录解析失败。
|
||||||
|
4. 下载契约里授权与任务状态均为字符串,客户端只原样展示/映射现有文案,
|
||||||
|
不根据未知值猜测业务状态。
|
||||||
|
5. 下载地址字段 `playUrl` 后端当前未填充,本批不做实际下载;不能据此宣称
|
||||||
|
“可离线播放”。
|
||||||
|
6. 本机可能缺少 Android SDK,`flutter build apk --debug` 若失败将保留原始输出,
|
||||||
|
回执标记“未验证”,不静默跳过。
|
||||||
|
7. 真机/模拟器可达性由用户验证;开发机 `curl` 不作为设备证据。
|
||||||
|
|
||||||
|
## 六、待确认(不阻塞本批)
|
||||||
|
|
||||||
|
1. 发现页 `hot` / `new` 入口保留,但当前后端 `discoverFeed()` 只生成
|
||||||
|
`category-{id}` 分区。若后续要展示热门/最新内容,需要后端在发现 feed 下发
|
||||||
|
对应分区;在此之前按契约显示空态。
|
||||||
|
2. 收藏分页与观看记录卡片字段待契约负责人后续补足;本批按 INBOX 降级方案执行。
|
||||||
|
3. 下载真实落盘、断点续传与离线播放需要存储、权限、加密播放方案,另立批次。
|
||||||
|
|
||||||
|
## 七、完成定义
|
||||||
|
|
||||||
|
- 四块均有“红 → 绿”测试证据。
|
||||||
|
- `dart analyze lib test` 零告警,`flutter test` 全过。
|
||||||
|
- `flutter build apk --debug` 通过,或保留缺 SDK 的原始失败输出并标记未验证。
|
||||||
|
- 三语言 ARB 齐全且生成物已更新。
|
||||||
|
- 独立记录、收藏、下载页和分类条均接契约接口;未登录入口可完成登录引导。
|
||||||
|
- 不改 `main`、不改 specs、不改 FruitServer 后端、不做搜索。
|
||||||
|
- `PROGRESS.md` 与 INBOX K2 回执包含验证步骤、预期结果、提交号与原始门禁输出。
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 2026-10-17 K3 客户端第三批:互动
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
本轮只完成 Flutter 客户端互动能力的真实接口接入:
|
||||||
|
|
||||||
|
- K3-A 评论:匿名游标列表、顶层评论与一层回复、发布、敏感词三档、删除、三态与重试。
|
||||||
|
- K3-B 点赞:剧集与评论点赞、乐观更新、失败回滚、未登录拦截、防连点。
|
||||||
|
- K3-C 举报:举报评论与短剧、固定原因单选、可选描述、成功与失败提示。
|
||||||
|
- K3-D 分享回归:沿用现有系统分享,核对标题、简介与 footer 的三语文案。
|
||||||
|
|
||||||
|
不做 K4、拉黑、举报用户、后台治理、图片/表情/@提及、无限嵌套、我的评论列表和并发压测。
|
||||||
|
后端代码、后台 Web、specs 仓库均不改;接口只使用 `FruitServer/docs/api-phase1.md` §1.7。
|
||||||
|
|
||||||
|
## 一之一、实测基线
|
||||||
|
|
||||||
|
- ReelShort:`develop` / `feature/flutter-k3-interaction` 均为 `45f5f25`,功能分支从 K2 合并点派生。
|
||||||
|
- FruitServer:`develop` 为 `a0156ee`,比 `origin/develop` 多 1 个仅包含 INBOX 的派单提交。
|
||||||
|
- 本计划文件在开工前的 FruitServer 工作区中已存在且尚未提交,本轮仅增量补充,不覆盖既有内容。
|
||||||
|
|
||||||
|
## 二、契约与数据口径
|
||||||
|
|
||||||
|
| 能力 | 接口 | 客户端处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 评论列表 | `GET /app/comments?dramaId=&episodeId=&rootId=&cursor=&size=` | 首屏 20 条,按 `nextCursor` 续拉,用 `hasMore` 控制加载更多 |
|
||||||
|
| 发布评论 | `POST /app/comments` | 顶层传 `dramaId/content`,回复额外传 `parentId` |
|
||||||
|
| 删除评论 | `DELETE /app/comments/{id}` | 仅 `mine=true` 可操作,二次确认,失败恢复列表 |
|
||||||
|
| 点赞切换 | `POST /app/likes/toggle` | `DRAMA` / `COMMENT`,以响应 `{liked,count}` 为最终态 |
|
||||||
|
| 举报 | `POST /app/reports` | `COMMENT` / `DRAMA`,`reasonCode` 必填 |
|
||||||
|
| 系统分享 | 无后端接口 | 保持 `PlatformShareService` |
|
||||||
|
|
||||||
|
评论字段按 `CommentRespVO` 解析,ID 全部使用字符串。所有新增用户可见文案进入
|
||||||
|
`lib/l10n/app_{en,zh,id}.arb` 并重新生成。
|
||||||
|
|
||||||
|
## 三、已确认的契约缺口与降级
|
||||||
|
|
||||||
|
1. `AppDramaDetailVO` / `AppDramaCardVO` 没有 `likeCount` / `liked`。
|
||||||
|
降级:不展示剧集点赞数字,只维护当前会话内的 liked 状态。
|
||||||
|
建议:后端详情与卡片补 `likeCount` / `liked`,需 specs 负责人变更契约。
|
||||||
|
2. §1.7 没有定义 `reasonCode` 枚举,后端当前不校验。
|
||||||
|
降级:客户端固定 `SPAM / ABUSE / PORN / AD / OTHER` 五项,文案进三语 arb。
|
||||||
|
建议:后端补 `reasonCode` 枚举与后台工单映射。
|
||||||
|
3. §1.7 没有拉黑接口。
|
||||||
|
降级:本批不做拉黑,不提供入口。
|
||||||
|
|
||||||
|
## 四、预计影响文件
|
||||||
|
|
||||||
|
FruitServer 文档:
|
||||||
|
|
||||||
|
- `docs/plans/2026-10-17-K3.md`
|
||||||
|
- `docs/plans/PROGRESS.md`
|
||||||
|
- `docs/plans/INBOX.md`
|
||||||
|
|
||||||
|
ReelShort 客户端:
|
||||||
|
|
||||||
|
- `lib/core/data/remote/comment_api.dart`
|
||||||
|
- `lib/core/data/remote/interaction_api.dart`
|
||||||
|
- `lib/core/data/repositories/comment_repository.dart`
|
||||||
|
- `lib/core/data/repositories/interaction_repository.dart`
|
||||||
|
- `lib/core/data/services/comment_controller.dart`
|
||||||
|
- 互动相关模型、Provider 注入与页面/组件
|
||||||
|
- `lib/pages/player/widgets/drama_comments_sheet.dart`
|
||||||
|
- `lib/pages/player/player_view.dart`
|
||||||
|
- 详情页举报入口及互动弹窗
|
||||||
|
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||||
|
- 对应 `test/**/*_test.dart`
|
||||||
|
|
||||||
|
实际文件以现有 K1/K2 分层和注入方式为准,避免无关重构。
|
||||||
|
|
||||||
|
## 五、TDD 与执行顺序
|
||||||
|
|
||||||
|
1. 只读核对 `ReelShort` 与 `FruitServer` 的 `develop` 基线、工作区状态与提交。
|
||||||
|
2. 从 `develop` 建 `feature/flutter-k3-interaction`。
|
||||||
|
3. K3-A:先写评论分页、发布、敏感词、删除、失败回滚和未登录测试并确认红灯,再实现并转绿。
|
||||||
|
4. K3-B:先写剧集/评论点赞成功、计数、乐观更新、回滚和未登录测试并确认红灯,再实现并转绿。
|
||||||
|
5. K3-C:先写举报成功、重复举报 message 透传和未登录测试并确认红灯,再实现并转绿。
|
||||||
|
6. K3-D:补充分享文案回归检查,确认三语资源完整。
|
||||||
|
7. 每块执行定向测试和 `dart analyze lib test`。
|
||||||
|
8. 全量执行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||||
|
9. 分块提交,`--no-ff` 合并回 `develop` 并 push;文档单独提交并 push。
|
||||||
|
10. 在 `PROGRESS.md` 与 INBOX 写回执,列出用户真机验证步骤,并明确设备侧取证未由 Codex 执行。
|
||||||
|
|
||||||
|
## 六、风险与待确认
|
||||||
|
|
||||||
|
- 评论游标的实际响应字段以已实现后端 VO 为准;若与任务书 `nextCursor/hasMore` 不一致,先停止并在 INBOX 顶部登记待协助,不自行扩契约。
|
||||||
|
- 剧集点赞没有初始查询接口,页面重建后会话内状态可能丢失;本批按降级方案不伪造初始值。
|
||||||
|
- 匿名列表可见,但发布、点赞、举报必须登录;需确保 AuthGate 在发起业务请求前拦截。
|
||||||
|
- 举报重复与举报自己依赖后端 message,客户端只原样展示,不自行判断。
|
||||||
|
- 敏感词 `REVIEW` 的 pending 评论仅自己可见,客户端必须保留发布成功返回内容并显示“审核中”。
|
||||||
|
- 现有互动控制器为本地演示数据,移除时不得破坏 K2 的分类、记录、收藏、下载和播放进度逻辑。
|
||||||
|
- 全量构建可能受本机 Flutter/Android SDK 环境影响;若失败会记录原始输出,不以开发机接口请求代替设备证据。
|
||||||
|
- 当前没有待确认的产品问题;若执行中出现契约与实现不一致,按任务书先停并登记。
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 2026-10-17 K5 客户端视觉:克莱因蓝主题 + 印尼市场适配
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
本轮只完成 Flutter 客户端的主题色、蓝色收敛、印尼语 locale 和回归测试:
|
||||||
|
|
||||||
|
- K5-A:建立克莱因蓝 token 与浅色/深色显式 `ColorScheme`,主色统一 `#002FA7`。
|
||||||
|
- K5-B:清理页面层的旧蓝字面量,统一使用 `AppColors` / `ColorScheme`;不改语义色。
|
||||||
|
- K5-C:`LocaleController` 支持 `id`;未显式选择时跟随系统,非 `en/zh/id` 回退 `id`;
|
||||||
|
两个现有语言入口增加 Bahasa Indonesia。
|
||||||
|
- K5-D:补主题 token、对比度、硬编码扫描、locale 回退与语言切换测试,
|
||||||
|
回归深色/浅色、三态和小屏布局。
|
||||||
|
|
||||||
|
不做 K4、支付、Google 登录、新接口、下载落盘、App 图标/启动图或信息架构调整;
|
||||||
|
不改后端、`ReelShortWeb`、specs。
|
||||||
|
|
||||||
|
## 二、用户裁定色值
|
||||||
|
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `blue` | `#002FA7` | Klein Blue / IKB 主色 |
|
||||||
|
| `bluePressed` | `#00227A` | 按下/激活态与大面积蓝色背景 |
|
||||||
|
| `blueSoft` | `#5B7BE0` | 深色背景上的高亮/次级文字与图标 |
|
||||||
|
| `onPrimary` | `#FFFFFF` | 主色上的文字/图标 |
|
||||||
|
| `ink` | `#0B0C10` | 保持现值 |
|
||||||
|
| `bar` | `#101114` | 保持现值 |
|
||||||
|
| `card` | `#17191F` | 保持现值 |
|
||||||
|
| `muted` | `#B7BDC9` | 保持现值 |
|
||||||
|
|
||||||
|
对比度口径:正文 ≥ 4.5:1,大字/图标 ≥ 3:1;错误红、成功绿、点赞红等语义色不改。
|
||||||
|
|
||||||
|
## 三、预计影响文件
|
||||||
|
|
||||||
|
- `lib/core/theme/app_colors.dart`
|
||||||
|
- `lib/app.dart`
|
||||||
|
- `lib/core/config/locale_controller.dart`
|
||||||
|
- `lib/pages/mine/mine_logic.dart`
|
||||||
|
- `lib/pages/home/widgets/home_quick_drawer.dart`
|
||||||
|
- `lib/pages/home/home_view.dart`
|
||||||
|
- `lib/pages/discover/discover_view.dart`
|
||||||
|
- `lib/pages/drama_detail/drama_detail_view.dart`
|
||||||
|
- `lib/pages/player/player_view.dart`
|
||||||
|
- `lib/pages/player/player_logic.dart`
|
||||||
|
- `lib/pages/player/widgets/seek_progress_bar.dart`
|
||||||
|
- `lib/pages/player/widgets/comment_tile.dart`
|
||||||
|
- `lib/pages/player/widgets/drama_comments_sheet.dart`
|
||||||
|
- `lib/pages/mine/mine_view.dart`
|
||||||
|
- `lib/pages/watch_records/watch_records_page.dart`
|
||||||
|
- `lib/pages/downloads/downloads_page.dart`
|
||||||
|
- `lib/pages/auth/auth_view.dart`
|
||||||
|
- `lib/core/widgets/auth_gate.dart`
|
||||||
|
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||||
|
- `test/theme/*`、`test/config/*`、相关页面 widget 测试
|
||||||
|
|
||||||
|
实际改动以静态扫描结果为准;不改业务逻辑和页面布局。
|
||||||
|
|
||||||
|
## 四、TDD 顺序
|
||||||
|
|
||||||
|
1. 写主题 token/对比度、硬编码蓝色扫描、locale `id` 持久化与未知语言回退测试,确认红灯。
|
||||||
|
2. K5-A:新增色值 token 和浅色/深色 `ColorScheme`,补主题测试转绿。
|
||||||
|
3. K5-B:按扫描结果替换页面蓝色字面量,补语言入口和 widget 回归。
|
||||||
|
4. K5-C:实现 `id` locale、系统语言跟随、未知系统语言回退 `id`,两个入口增加
|
||||||
|
Bahasa Indonesia。
|
||||||
|
5. K5-D:全量扫描、深色/浅色与小屏 widget 回归。
|
||||||
|
6. 门禁:`dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||||
|
7. `--no-ff` 合并回 `develop` 并 push;FruitServer 仅提交 `docs/plans`。
|
||||||
|
|
||||||
|
## 五、风险与处理
|
||||||
|
|
||||||
|
- 克莱因蓝在浅色背景上作为文字可能对比不足:正文继续使用现有着色,
|
||||||
|
主色主要用于按钮/图标/选中态;主色背景统一配 `onPrimary`。
|
||||||
|
- `blueSoft` 仅用于深色背景高亮,避免在浅色背景当正文色造成低对比度。
|
||||||
|
- 现有 `_orange` 等历史命名可能实际承载蓝色,替换时按语义改名,避免误导。
|
||||||
|
- 直接替换主题色可能影响 FilledButton、Slider、Switch 等 Material 控件:
|
||||||
|
通过显式 `ColorScheme` 和 widget 测试统一验证。
|
||||||
|
- 语言入口两处需同步,避免“我的”和快捷抽屉行为不一致。
|
||||||
|
- 真机、设备截图与系统语言切换证据由用户执行,Codex 不跑真机。
|
||||||
|
|
||||||
|
## 六、待确认
|
||||||
|
|
||||||
|
- 当前无产品歧义;按用户裁定的方案 A 色值执行。
|
||||||
|
- specs 无成文色板,若后续提供 Stitch 视觉稿,再按新稿更新 token 与页面映射。
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 2026-10-17 K6 客户端 UI:登录页 + 底部导航优化
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
本批只优化 Flutter 登录页和首页底部导航,基于 K5 克莱因蓝主题:
|
||||||
|
|
||||||
|
- K6-A:登录页品牌区、autofill、密码显隐、键盘 next/done 提交、
|
||||||
|
提交中禁用、主题语义错误色、小屏键盘可用性。
|
||||||
|
- K6-B:底部导航背景/边框/选中态统一使用 K5 token,安全区适配,
|
||||||
|
点击区域 ≥48pt,切换动效 ≤200ms。
|
||||||
|
- K6-C:新增可注入 `Haptics` 封装,只对关键操作提供 selection/light/medium 反馈。
|
||||||
|
- K6-D:新增 widget 测试并保持既有 Key、导航逻辑与沉浸隐藏行为。
|
||||||
|
|
||||||
|
不做忘记密码、Google/Apple 登录、隐私勾选、新 tab、页面信息架构调整;
|
||||||
|
不改接口、业务校验、注册/登录流程、后端、Web 或 specs。
|
||||||
|
|
||||||
|
## 二、设计口径
|
||||||
|
|
||||||
|
- 主色:`AppColors.blue = #002FA7`。
|
||||||
|
- 按下态/大面积激活背景:`AppColors.bluePressed = #00227A`。
|
||||||
|
- 深色底高亮:`AppColors.blueSoft = #5B7BE0`。
|
||||||
|
- 主色上的文字/图标:`AppColors.onPrimary = #FFFFFF`。
|
||||||
|
- 错误提示使用 `ColorScheme.error/onError` 对应语义色。
|
||||||
|
- 底栏背景使用现有 `AppColors.bar`,顶部边框使用主题 outline 或现有暗色 token;
|
||||||
|
页面不新增色值字面量。
|
||||||
|
|
||||||
|
## 三、预计影响文件
|
||||||
|
|
||||||
|
- `lib/pages/auth/auth_view.dart`
|
||||||
|
- `lib/pages/home/home_view.dart`
|
||||||
|
- `lib/core/platform/haptics.dart`
|
||||||
|
- `lib/app.dart`(Haptics Provider)
|
||||||
|
- 收藏、点赞、评论、举报、下载、记录相关页面回调
|
||||||
|
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||||
|
- `test/auth/auth_page_test.dart`
|
||||||
|
- `test/widget_test.dart`
|
||||||
|
- 必要时补首页导航专用测试文件
|
||||||
|
|
||||||
|
不修改 `HomeLogic` 的 tab 顺序、选中规则和 Key;不动播放页沉浸状态来源。
|
||||||
|
|
||||||
|
## 四、交互与可用性
|
||||||
|
|
||||||
|
### 登录页
|
||||||
|
|
||||||
|
- 品牌区显示 App 名和印尼市场副标题,全部走 l10n 三语。
|
||||||
|
- 邮箱 `TextInputAction.next`,提交后焦点进入密码框。
|
||||||
|
- 密码 `TextInputAction.done`,提交后直接执行现有 `_submit()`。
|
||||||
|
- 邮箱 `AutofillHints.email`,密码 `AutofillHints.password`。
|
||||||
|
- 密码显隐按钮语义标签走 l10n,所有点击区域 ≥44pt。
|
||||||
|
- `busy` 时禁用两个输入框、显隐按钮、提交和模式切换。
|
||||||
|
- 使用 `resizeToAvoidBottomInset` + ListView/SafeArea,保证小屏键盘下
|
||||||
|
submit 可滚动到可见区域。
|
||||||
|
|
||||||
|
### 底部导航
|
||||||
|
|
||||||
|
- 保持 4 个 tab 的数量、顺序、Key 和回调不变。
|
||||||
|
- 选中态采用克制的背景胶囊(现有方案收敛)或短指示条,二选一即可;
|
||||||
|
使用 `bluePressed` 胶囊 + `blueSoft` 高亮文字/图标。
|
||||||
|
- 底栏高度 + SafeArea bottom 适配 home indicator。
|
||||||
|
- 每个 tab 的可点击区域至少 48pt;动效控制在 200ms 内。
|
||||||
|
- `logic.videoImmersive == true` 时保持 `bottomNavigationBar: null`。
|
||||||
|
|
||||||
|
### 点击震动反馈
|
||||||
|
|
||||||
|
- 使用 Flutter 内置 `HapticFeedback`,统一封装为 `Haptics` 抽象 +
|
||||||
|
`FlutterHaptics` 默认实现,通过 Provider 注入,测试使用 fake。
|
||||||
|
- tab 真正切换:`selectionClick()`。
|
||||||
|
- 登录/注册提交、收藏/取消、点赞/取消、评论发送、举报提交、下载授权申请:
|
||||||
|
`lightImpact()`。
|
||||||
|
- 清空记录、清空下载、删除记录/授权:`mediumImpact()`。
|
||||||
|
- 全部 `unawaited`;平台不可用时默认实现静默忽略;普通列表/滚动/页面切换不触发。
|
||||||
|
|
||||||
|
## 五、TDD 顺序
|
||||||
|
|
||||||
|
1. 新增登录页密码显隐、autofill、next/done 焦点、busy 禁用、错误语义色测试。
|
||||||
|
2. 新增底栏 4 Key、tab 切换选中态、沉浸隐藏、最小点击区域和硬编码色扫描测试。
|
||||||
|
3. 新增 fake Haptics 测试,覆盖 tab selection、主操作 light、危险操作 medium、
|
||||||
|
普通列表不触发与默认实现异常静默。
|
||||||
|
4. 先运行定向测试确认红灯,再实现登录页、底栏与 Haptics 转绿。
|
||||||
|
5. 补 320×568 小屏与键盘弹出回归。
|
||||||
|
6. 全量门禁:`dart analyze lib test`、`flutter test`、
|
||||||
|
`flutter build apk --debug`。
|
||||||
|
|
||||||
|
## 六、风险与处理
|
||||||
|
|
||||||
|
- 登录页已有业务逻辑较长:仅提取品牌区和表单展示组件,不改
|
||||||
|
`AuthLogic`、接口调用与校验规则。
|
||||||
|
- 键盘焦点流转依赖 `FocusNode`:显式创建并释放,测试使用
|
||||||
|
`tester.testTextInput.receiveAction(TextInputAction.next/done)`。
|
||||||
|
- busy 状态下测试请求不能立即返回:使用可控 Fake Auth 或延迟 Future,
|
||||||
|
验证输入和按钮禁用,再释放请求避免测试悬挂。
|
||||||
|
- 底栏新增动画可能影响既有 `pumpAndSettle`:统一使用 ≤200ms,
|
||||||
|
不引入第三方动画库。
|
||||||
|
- Haptics 注入遗漏会让页面测试缺少 Provider:所有直接 pump 页面的测试
|
||||||
|
统一注入 fake;生产默认实现吞掉平台不可用异常,不影响业务。
|
||||||
|
- 安全区/小屏可能压缩按钮高度:使用最小点击区约束与
|
||||||
|
`SafeArea(minimum: ...)`,保持现有 Key 可定位。
|
||||||
|
|
||||||
|
## 七、待确认
|
||||||
|
|
||||||
|
- specs 无 UI 设计稿,按“视觉统一 + 可用性”执行;不引入图片资源。
|
||||||
|
- 当前无产品歧义;若真机出现键盘安全区差异,由用户提供设备侧截图后再做最小修正。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 2026-10-17 K7 首页轮播图 + 站内信通知
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
本批只完成 Flutter 客户端两项能力:
|
||||||
|
|
||||||
|
- K7-A:增强现有 `CoverCarousel`,继续使用 `feed.banners` 和 3D 翻转风格,
|
||||||
|
增加自动轮播、交互暂停、指示器、失败占位和语义。
|
||||||
|
- K7-B:新增匿名站内信分页页,接入 `GET /app/notices`,在我的页增加通知入口。
|
||||||
|
- K7-C:补轮播/通知/响应式与 locale 时间测试。
|
||||||
|
|
||||||
|
不做已读未读、红点、FCM、通知设置、`/app/config noticeEnabled`、首页铃铛、
|
||||||
|
新 banner 接口、后端、Web 或 specs 修改。
|
||||||
|
|
||||||
|
## 二、契约口径
|
||||||
|
|
||||||
|
### Banner
|
||||||
|
|
||||||
|
- 数据源:`GET /app/home/feed` / `GET /app/discover/feed` 的 `banners[]`。
|
||||||
|
- 无独立接口;空列表继续 `SizedBox.shrink()`。
|
||||||
|
- 点击继续沿用 `linkType=DRAMA` + `linkValue` 的现有 `onOpen`。
|
||||||
|
|
||||||
|
### Notices
|
||||||
|
|
||||||
|
- `GET /app/notices?pageIndex=1&pageSize=20`,匿名。
|
||||||
|
- 响应:`{total, list:[{id,title,content,type,publishAt,expireAt}]}`。
|
||||||
|
- ID 全部按字符串;`total` 是数字。
|
||||||
|
- `list.length` 用于判断当前页是否有数据;是否加载更多按
|
||||||
|
“已加载数量 < total”判断。
|
||||||
|
- `type` 原样展示,不建立客户端枚举映射。
|
||||||
|
|
||||||
|
## 三、预计影响文件
|
||||||
|
|
||||||
|
轮播:
|
||||||
|
|
||||||
|
- `lib/pages/discover/widgets/cover_carousel.dart`
|
||||||
|
- `lib/pages/discover/discover_view.dart`
|
||||||
|
- `lib/core/widgets/remote_image.dart`(可选失败图标参数)
|
||||||
|
- `test/discover/cover_carousel_test.dart`
|
||||||
|
|
||||||
|
通知:
|
||||||
|
|
||||||
|
- `lib/core/data/models/notice.dart`
|
||||||
|
- `lib/core/data/remote/notice_api.dart`
|
||||||
|
- `lib/core/data/repositories/notice_repository.dart`
|
||||||
|
- `lib/core/data/services/notices_controller.dart`
|
||||||
|
- `lib/pages/notices/notices_page.dart`
|
||||||
|
- `lib/pages/mine/mine_view.dart`
|
||||||
|
- `lib/app.dart`(NoticeRepository Provider)
|
||||||
|
- `test/notice/**`
|
||||||
|
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||||
|
|
||||||
|
## 四、实现口径
|
||||||
|
|
||||||
|
### 轮播
|
||||||
|
|
||||||
|
- `Timer.periodic` 4.5s;单张不创建定时器;`dispose` 取消。
|
||||||
|
- 使用 `TickerMode.of(context)`/`Visibility` 语义监听页面可见性;
|
||||||
|
不可见时取消,恢复时重新启动。
|
||||||
|
- pointer down 暂停,up/cancel 恢复;拖拽时沿用现有 3D 翻转。
|
||||||
|
- 指示器采用底部圆点,使用 `AppColors.onPrimary` + `muted` 低透明度。
|
||||||
|
- 语义标签进入三语 arb。
|
||||||
|
- 图片失败使用现有 `RemoteImage` 并显示图标占位。
|
||||||
|
|
||||||
|
### 通知
|
||||||
|
|
||||||
|
- `NoticesController` 管理首屏、分页、加载更多、刷新、错误。
|
||||||
|
- `NoticesPage` 深色页,保留 K5/K6 主题与 Haptics 不触发滑动振动。
|
||||||
|
- 列表项标题/类型/时间/两行摘要;点击打开详情 Dialog。
|
||||||
|
- 详情 `content` 用可滚动 `Text` 保留 `\n`,长内容不溢出。
|
||||||
|
- `publishAt` / `expireAt` 使用现有 `formatLocalizedDateTime`。
|
||||||
|
- 我的页 `_menuCard` 增加通知入口,匿名可进入。
|
||||||
|
|
||||||
|
## 五、TDD 顺序
|
||||||
|
|
||||||
|
1. 轮播:自动切页、单张无 Timer、指示器、触摸暂停、点击回调、失败占位。
|
||||||
|
2. 通知 API/model:分页字段、字符串 ID、时间解析。
|
||||||
|
3. Controller:首屏、追加、加载更多条件、空/失败/刷新。
|
||||||
|
4. NoticesPage:三态、列表、分页、详情、长内容、小屏。
|
||||||
|
5. 我的页入口与三语文案。
|
||||||
|
6. 门禁:`dart analyze lib test`、`flutter test`、
|
||||||
|
`flutter build apk --debug`。
|
||||||
|
|
||||||
|
## 六、风险与待确认
|
||||||
|
|
||||||
|
- 自动轮播 Timer 与 3D AnimationController 并存,需避免 dispose 后 setState。
|
||||||
|
- 页面用 `IndexedStack` 保持状态,需通过 `TickerMode` 判断真实可见性。
|
||||||
|
- `total` 可能在分页期间变化,以本次响应 total 与已加载数量比较。
|
||||||
|
- 详情长内容必须可滚动,避免小屏 overflow。
|
||||||
|
- `noticeEnabled` 属 `/app/config`,本批不接;通知入口暂时常驻,已登记后续。
|
||||||
|
- 无独立 banner 接口和已读接口,不自行扩展契约。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 2026-10-17 K8 登录页视觉重设计
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
将现有登录页从表单堆叠改为深色、克莱因蓝品牌的单页视觉,保留 K6 已完成的
|
||||||
|
AuthLogic、校验、接口、登录/注册流程、autofill、键盘流转、busy 状态和 Haptics。
|
||||||
|
|
||||||
|
- 客户端分支:`feature/flutter-k8-auth-visual`,基线 `develop 6cc5bfc`。
|
||||||
|
- 文档分支:`docs/k8-auth-visual`,FruitServer 只改 `docs/plans/`。
|
||||||
|
- 不改后端、Web、specs、接口契约、其他页面或业务逻辑。
|
||||||
|
- 不新增图片、字体、第三方依赖;品牌图形使用 Material Icons。
|
||||||
|
- 不跑真机,设备侧视觉、键盘与小屏验收由用户执行。
|
||||||
|
|
||||||
|
## 二、视觉与交互实现
|
||||||
|
|
||||||
|
1. 页面背景使用 `AppColors.ink`,顶部叠加克莱因蓝渐变光晕,颜色透明度不超过
|
||||||
|
0.18,并忽略指针事件。
|
||||||
|
2. 品牌区使用 48px 克莱因蓝圆角方块、白色剧集图标、App 名和现有
|
||||||
|
`authBrandTagline`。
|
||||||
|
3. 表单放入 20px 圆角、1px `outlineVariant` 边框、20px 内边距的
|
||||||
|
`AppColors.card` 卡片。
|
||||||
|
4. 输入框使用 52px 高、14px 圆角、`AppColors.bar` 填充,带邮箱/锁前缀图标;
|
||||||
|
聚焦时显示 1.5px 主色边框并高亮图标;密码显隐按钮保留。
|
||||||
|
5. 主按钮全宽 52px、26px 圆角、`AppColors.blue` 背景、白色 16/w700 文字,
|
||||||
|
pressed 态使用 `bluePressed`,loading 为 18px 白色环,busy 时输入、切换与
|
||||||
|
提交均禁用。
|
||||||
|
6. 登录/注册改为双段 pill,实际可见标签为 `signIn` / `signUp`;点击调用现有
|
||||||
|
`toggleMode()`。保留既有 `auth-switch-mode` 兼容入口,避免破坏既有测试。
|
||||||
|
7. 错误提示使用红色 10% 透明背景、12px 圆角、图标和主题 error 色文字的胶囊。
|
||||||
|
8. 首帧使用 220ms 的 `TweenAnimationBuilder` 完成淡入和上滑。
|
||||||
|
9. 保留 `auth-scroll`、`auth-email`、`auth-password`;新增
|
||||||
|
`auth-brand`、`auth-card`、`auth-mode-sign-in`、`auth-mode-sign-up`、
|
||||||
|
`auth-submit`、`auth-error-capsule` 等稳定 Key。
|
||||||
|
|
||||||
|
## 三、影响文件
|
||||||
|
|
||||||
|
- `ReelShort/lib/pages/auth/auth_view.dart`
|
||||||
|
- `ReelShort/test/auth/auth_visual_test.dart`(新增)
|
||||||
|
- `ReelShort/test/auth/auth_page_test.dart`(既有用例保持不变)
|
||||||
|
- `FruitServer/docs/plans/2026-10-17-K8.md`
|
||||||
|
- `FruitServer/docs/plans/PROGRESS.md`
|
||||||
|
- `FruitServer/docs/plans/INBOX.md`(完成后标记 K8 已处理)
|
||||||
|
|
||||||
|
## 四、TDD 与门禁
|
||||||
|
|
||||||
|
1. 先新增视觉测试并运行,确认分段控件、品牌、错误胶囊和尺寸断言红灯。
|
||||||
|
2. 实现登录页视觉后,定向 auth 测试绿灯。
|
||||||
|
3. 运行 `dart analyze lib test` 零告警。
|
||||||
|
4. 运行 `flutter test` 全过。
|
||||||
|
5. 构建前确认没有并发 `flutter run/build`;运行
|
||||||
|
`flutter build apk --debug`。
|
||||||
|
6. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||||
|
合并推送。提交作者与提交者统一为
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
|
||||||
|
## 五、风险与待确认
|
||||||
|
|
||||||
|
- 旧测试按文字链接和 `auth-switch-mode` TextButton 检查交互;本次通过保留
|
||||||
|
兼容入口满足旧用例,同时可见交互必须由新 pill 分段控件完成。
|
||||||
|
- 320px 小屏和键盘弹出时,卡片、错误胶囊及按钮需保持可滚动且不溢出。
|
||||||
|
- 当前检测到 Android `flutter run` 会话;构建门禁前必须复查,若仍占用
|
||||||
|
`build/` 则等待其结束,不能并发构建。
|
||||||
|
- specs 无登录页设计稿,颜色与尺寸以本 INBOX 条目为准,无需新增契约。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 2026-10-17 K9 首页剧目列表改造(2 列全部剧目)
|
||||||
|
|
||||||
|
## 一、目标与边界
|
||||||
|
|
||||||
|
首页(发现 tab)移除「继续观看」区块,在分类 chips 下方以懒构建的 2 列网格展示
|
||||||
|
当前分类的全部剧目;保留 header、轮播、分类、三态、空态和底部导航。
|
||||||
|
|
||||||
|
- 客户端分支:`feature/flutter-k9-home-grid`,基线 `develop 99f64ab`。
|
||||||
|
- 文档分支:`docs/k9-home-grid`,FruitServer 基线 K9 任务书 `b434f65`。
|
||||||
|
- 只改首页(发现页)、三语 l10n、对应 widget 测试与 `docs/plans/`。
|
||||||
|
- 不改接口、数据模型、CategoryController 过滤逻辑、轮播、通知、观看记录、
|
||||||
|
收藏、下载或底部导航。
|
||||||
|
- 不新增图片、依赖或分页;继续使用 `RemoteImage` 懒加载。
|
||||||
|
- 不跑真机,设备侧滚动、布局、分类切换和详情跳转由用户验收。
|
||||||
|
|
||||||
|
## 二、实现方案
|
||||||
|
|
||||||
|
1. 删除 `_continueHeader`、`_dramaContent`、`_continueTile`、`_ShowcaseCard`
|
||||||
|
与 `_cardsFor`,首页不再构建或保留「继续观看」文案和代码。
|
||||||
|
2. 将页面滚动容器改为 `CustomScrollView`,依次放置:
|
||||||
|
carousel `SliverToBoxAdapter`、分类与标题 `SliverToBoxAdapter`、状态
|
||||||
|
`SliverToBoxAdapter` 或剧目 `SliverGrid`、底部留白 `SliverToBoxAdapter`。
|
||||||
|
3. 剧目网格使用 `SliverGridDelegateWithFixedCrossAxisCount`:
|
||||||
|
`crossAxisCount: 2`、`crossAxisSpacing: 12`、`mainAxisSpacing: 16`、
|
||||||
|
`childAspectRatio: 2 / 3`;左右由 `SliverPadding` 保持 16。
|
||||||
|
4. 卡片封面使用 2:3 比例、14px 圆角、`RemoteImage` 和现有底部深色渐变;
|
||||||
|
标题最多 2 行,类型最多 1 行。点击调用现有
|
||||||
|
`logic.openCard(..., embedded, onDramaSelected, onAction)`。
|
||||||
|
5. 数据直接使用 `CategoryController.visibleDramas`;`For You` 使用其全量
|
||||||
|
去重结果。分类切换后由 ChangeNotifier 驱动 sliver 内容更新。
|
||||||
|
6. 保留 `categories.loading` 首载进度、`categories.error` 重试、
|
||||||
|
`noCategoryDramas` / `noSearchResults` 空态文案。
|
||||||
|
7. l10n 新增 `allDramas`:
|
||||||
|
`全部剧目` / `All Dramas` / `Semua Drama`,并重新生成 l10n。
|
||||||
|
|
||||||
|
## 三、影响文件
|
||||||
|
|
||||||
|
- `ReelShort/lib/pages/discover/discover_view.dart`
|
||||||
|
- `ReelShort/lib/l10n/app_en.arb`
|
||||||
|
- `ReelShort/lib/l10n/app_zh.arb`
|
||||||
|
- `ReelShort/lib/l10n/app_id.arb`
|
||||||
|
- `ReelShort/lib/l10n/app_localizations*.dart`(生成物)
|
||||||
|
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`(新增)
|
||||||
|
- `ReelShort/test/widget_test.dart`(仅更新作废的「继续观看」断言)
|
||||||
|
- `FruitServer/docs/plans/2026-10-17-K9.md`
|
||||||
|
- `FruitServer/docs/plans/PROGRESS.md`
|
||||||
|
- `FruitServer/docs/plans/INBOX.md`
|
||||||
|
|
||||||
|
## 四、测试与门禁
|
||||||
|
|
||||||
|
1. 先新增 K9 首页 widget 测试并取得红灯。
|
||||||
|
2. 实现后验证:无「继续观看」、有「全部剧目」、2 列 sliver、左右/行列间距、
|
||||||
|
2:3 封面、分类切换更新、空态与失败态。
|
||||||
|
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`;
|
||||||
|
构建前检查无并发 `flutter run/build`。
|
||||||
|
4. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||||
|
合并推送。作者与提交者统一为
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
|
||||||
|
## 五、风险与待确认
|
||||||
|
|
||||||
|
- 既有 `test/widget_test.dart` 有两个壳层用例断言首页出现
|
||||||
|
`Continue Watching`。K9 明确要求该文案彻底消失,因此原断言与
|
||||||
|
`find.text('Continue Watching'), findsNothing` 不可能同时成立。
|
||||||
|
处理方式:保留两个既有用例及完整导航流程,只把已作废的文案断言更新为
|
||||||
|
`All Dramas`,不删除任何测试用例。
|
||||||
|
- 首页 feed 可能一次返回较多剧目;必须使用 `SliverGrid` 懒构建,不能嵌套
|
||||||
|
`GridView(shrinkWrap: true)`。
|
||||||
|
- 2:3 卡片在小屏需要保持标题 2 行和类型 1 行不溢出;测试覆盖 320px 宽度。
|
||||||
|
- 不改 `CategoryController` 的数据语义;筛选仍由现有分区 code 决定。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 2026-10-19 K19 客户端账号中心
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
- ReelShort `develop`: `7d68699`
|
||||||
|
- FruitServer `develop`: `5c93ba9`
|
||||||
|
- 只改 ReelShort;不改 FruitServer 业务代码、ReelShortWeb、specs、main。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
对接 `docs/api-phase1.md` §1.2 的六个个人中心接口:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 客户端用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/app/me` | 资料展示 |
|
||||||
|
| PUT | `/app/me` | 修改昵称、性别、生日 |
|
||||||
|
| GET | `/app/me/devices` | 只读登录设备列表 |
|
||||||
|
| GET | `/app/me/export` | 账号数据摘要与完整 JSON 复制 |
|
||||||
|
| POST | `/app/me/deactivate` | 申请注销并进入 15 天冷静期 |
|
||||||
|
| POST | `/app/me/deactivate/cancel` | 撤销待处理注销申请 |
|
||||||
|
|
||||||
|
## 接口口径
|
||||||
|
|
||||||
|
1. 资料响应仅使用:
|
||||||
|
`uid/nickname/avatarUrl/gender/countryCode/regionCode/language/timezone/currency/status/maskedTarget/createdAt/lastLoginAt/vipLevel/vipExpireAt`。
|
||||||
|
`vipLevel` 是整数;时间字段按 `LocalDateTime` 字符串解析。
|
||||||
|
2. 资料更新只发送本次变更字段:
|
||||||
|
`nickname/avatarUrl/gender/birthday/language/timezone/regionCode`。
|
||||||
|
本页只修改 `nickname/gender/birthday`;昵称 trim 后非空且不超过 30 字符,
|
||||||
|
性别取 `male/female/unknown`,生日格式 `yyyy-MM-dd`。
|
||||||
|
3. 设备数组只解析并展示
|
||||||
|
`deviceId/platform/deviceModel/firstSeenAt/lastSeenAt/status`;
|
||||||
|
`id/userId/deleted` 不展示、不打印。页面只读。
|
||||||
|
4. 导出按实际结构解析
|
||||||
|
`{profile, credentials, consents, devices}`,展示资料摘要、凭据、授权记录数和设备数;
|
||||||
|
“复制完整 JSON”只写系统剪贴板,不写本地文件。
|
||||||
|
5. 注销请求只发送 `reason`,不采集或上送密码;成功返回 `scheduledAt`。
|
||||||
|
成功后清本地会话,不调用 logout。撤销接口成功 `data=null`,失败原样透传后端文案。
|
||||||
|
6. 所有数值和 ID 使用 `jsonToId/jsonToInt`;错误由 `ApiException` 透传到逻辑层。
|
||||||
|
|
||||||
|
## 客户端改造
|
||||||
|
|
||||||
|
- `lib/core/data/remote/account_api.dart`
|
||||||
|
- `lib/core/data/models/account_profile.dart`
|
||||||
|
- `lib/core/data/models/account_device.dart`
|
||||||
|
- `lib/core/data/models/account_export.dart`
|
||||||
|
- `lib/core/data/repositories/account_repository.dart`
|
||||||
|
- `lib/pages/account/` 下四个页面及各自的 binding/logic
|
||||||
|
- `lib/pages/mine/mine_view.dart` 登录态新增“账号”卡
|
||||||
|
- `lib/core/data/services/auth_controller.dart` 新增 `applyProfile`
|
||||||
|
- `lib/app.dart` 注册 `AccountRepository` Provider
|
||||||
|
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||||
|
- 测试新增 `test/account/`、`test/support/fake_account_repository.dart`
|
||||||
|
|
||||||
|
## TDD 顺序
|
||||||
|
|
||||||
|
1. 先写 API/模型测试:六条路径、方法、请求体、字符串/数字 ID、缺字段;
|
||||||
|
运行定向测试确认红灯。
|
||||||
|
2. 实现 AccountApi、三个模型和 AccountRepository,定向测试转绿。
|
||||||
|
3. 先写 AuthController.applyProfile 测试:保留令牌、更新昵称/头像并持久化;
|
||||||
|
再实现。
|
||||||
|
4. 先写 profile/device/export/deactivate logic 测试:
|
||||||
|
资料保存回调登录态、注销成功清会话、撤销失败透传文案、导出复制内容;
|
||||||
|
再实现逻辑层。
|
||||||
|
5. 先写 widget 测试:我的页入口登录可见、资料回填与保存、设备空态、
|
||||||
|
注销二次确认、导出加载/失败/重试;再实现页面和入口。
|
||||||
|
6. 生成 l10n,跑全量门禁;最后一次全量测试与 APK 构建。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
```text
|
||||||
|
dart analyze lib test
|
||||||
|
flutter test
|
||||||
|
flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Flutter 分支:`feature/flutter-k19-account`。
|
||||||
|
|
||||||
|
## 风险与待确认
|
||||||
|
|
||||||
|
- `GET /app/me` 不返回注销申请状态,UI 不猜测状态;撤销失败展示后端文案。
|
||||||
|
- 无设备下线接口,设备页只读;`TC-ACCOUNT-10` 的“单点下线”保留为缺口。
|
||||||
|
- 无头像上传接口,头像只展示;空值使用现有占位。
|
||||||
|
- 资料响应不含 `birthday`,也没有清空入口。本批生日只能设置,无法回填或清除;
|
||||||
|
不补后端字段,页面文案明确为本次设置。
|
||||||
|
- 注销成功后服务端立即吊销全部会话,本地同步清理;撤销入口在重新登录后仍可使用。
|
||||||
|
- 真机验证由用户执行,本批不提供开发机 curl 或设备侧结果作为证据。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 2026-10-19 K20 客户端视觉统一
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
- ReelShort `develop`: `42ce1d1`
|
||||||
|
- FruitServer `develop`: `810d9fc`
|
||||||
|
- 只改 ReelShort 视觉与布局;不改业务逻辑、接口、后端、Web、specs、main。
|
||||||
|
- 保留辣椒红 `AppColors.blue = #C4322A`。
|
||||||
|
|
||||||
|
## 统一 Token
|
||||||
|
|
||||||
|
新增 `lib/core/theme/app_dimens.dart`:
|
||||||
|
|
||||||
|
| Token | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 页面左右边距 | 16 |
|
||||||
|
| 卡片圆角 / 内边距 | 16 / 16 |
|
||||||
|
| 内嵌块圆角 / 内边距 | 12 / 12 |
|
||||||
|
| 胶囊圆角 | 20 |
|
||||||
|
| caption / body / list / section / page | 12 / 14 / 15 / 16 / 20 |
|
||||||
|
| 导航标签 | 11 |
|
||||||
|
| 详情主标题 | 24 |
|
||||||
|
| 主按钮高度 | 48;整页主操作 52 |
|
||||||
|
| 点击区 | 44 × 44 |
|
||||||
|
| 导航高度 / 图标 | 64 / 22 |
|
||||||
|
| 进度条高度 / 圆角 | 4 / 4 |
|
||||||
|
| 账号卡高度 / 图标 / 标签 | 64 / 18 / 11 |
|
||||||
|
| 观看占位高度 | 114 |
|
||||||
|
|
||||||
|
## 改造顺序
|
||||||
|
|
||||||
|
1. 加 token 与主题:`AppDimens`、`AppColors.favorite`、深色唯一口径、
|
||||||
|
按钮最小高度、OutlinedButton 主题、AppBar/状态栏样式。
|
||||||
|
2. 新增 `AppBottomNav` / `AppBottomNavItem`,替换 home/discover/mine/player fallback。
|
||||||
|
3. 调整账号入口卡为 64 高、18 图标、11 标签、44 点击区,并回归 K19 首屏测试。
|
||||||
|
4. 收敛字号下限:业务文案 ≥12,导航 11;清理 9.5/10。
|
||||||
|
5. 收敛卡片圆角与间距:页面 16、卡片 16/12、区块 16/12。
|
||||||
|
6. 替换任务书列出的硬编码颜色;收藏红心使用 `AppColors.favorite`。
|
||||||
|
7. 统一详情、记录、下载进度条为 4 高、4 圆角、blueSoft/white24;播放器拖动条保持金色。
|
||||||
|
8. 统一页面/区块/列表标题层级与按钮高度,移除 mine 按钮 VisualDensity.compact。
|
||||||
|
9. 详情加载/失败分支补返回 AppBar,封面顶部加黑色渐变,详情边距 24→16。
|
||||||
|
10. 点赞/收藏选中态改实心图标;分类和 my tabs 补 selected 语义。
|
||||||
|
11. HeaderPill / LoginButton / 分类 Chip 点击区补到 44。
|
||||||
|
12. 列表页底部安全区补齐;观看记录占位高度 114,与真实卡片 padding 一致。
|
||||||
|
|
||||||
|
## 测试与门禁
|
||||||
|
|
||||||
|
- 新增 `AppDimens` token 测试与 `AppBottomNav` 组件测试。
|
||||||
|
- 不修改既有测试断言;视觉布局变化导致测试失败时改实现。
|
||||||
|
- `dart analyze lib test`
|
||||||
|
- `flutter test`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
|
||||||
|
## 风险与边界
|
||||||
|
|
||||||
|
- 账号卡升高到 64 可能重新挤压“我的”页首屏;必须保持 K19 语言设置和观看历史入口测试通过。
|
||||||
|
- 保留页面级深色 Theme 包装以降低大范围回归风险,但根 `themeMode` 必须固定 `dark`。
|
||||||
|
- 不碰 K21 范围:搜索、Android 返回键、播放器切票黑屏、登录后续操作、下拉刷新、
|
||||||
|
注销状态闭环、日期/时长格式、语言上送。
|
||||||
|
- 真机验收由用户执行,本批不提供设备侧截图或日志。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 2026-10-19 K21 登录页 UI 优化
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
- ReelShort `develop`: `e44fbdf`
|
||||||
|
- FruitServer `develop`: `af28161`
|
||||||
|
- 只改 `ReelShort/lib/pages/auth/**`、l10n 与必要的新增测试。
|
||||||
|
|
||||||
|
## 布局方案
|
||||||
|
|
||||||
|
- 用 `LayoutBuilder + SingleChildScrollView + ConstrainedBox + Center`,
|
||||||
|
让品牌与卡片在可视区域垂直居中,键盘弹起时仍可滚动。
|
||||||
|
- 保留 `auth-scroll`,底部 padding 继续使用 `28 + viewInsets.bottom`。
|
||||||
|
- 返回按钮移到顶部 `Positioned + SafeArea` 浮层,不再占据内容流。
|
||||||
|
- 内容最大宽度继续为 440,页面左右边距 16。
|
||||||
|
- 保留主 background glow(alpha ≤0.18),新增独立 Key 的 warm glow(alpha ≤0.06)。
|
||||||
|
|
||||||
|
## 品牌与卡片
|
||||||
|
|
||||||
|
- 品牌区居中竖排:48×48 splash mark、App 名 26/w800、tagline 14/blueSoft。
|
||||||
|
- `auth-brand`、`auth-brand-mark` 保留,mark 尺寸严格 48×48。
|
||||||
|
- 卡片结构保持:模式切换 → 标题 25/w800 → 副标题 → 字段 → 错误胶囊 → 提交。
|
||||||
|
- 登录继续使用 `authSubtitle`;注册使用新增 `authSignUpSubtitle`。
|
||||||
|
- 注册态密码框下显示 12px 的 `authPasswordHint`,登录态隐藏。
|
||||||
|
|
||||||
|
## 字段错误与交互
|
||||||
|
|
||||||
|
- `AuthLogic` 增加 `fieldError`;`validate()` 记录具体错误,输入变化或切换模式时清除。
|
||||||
|
- 邮箱/密码字段按 `fieldError` 使用 1.5px semantic error `errorBorder` /
|
||||||
|
`focusedErrorBorder`。
|
||||||
|
- 保留 `auth-error-capsule`、`auth-error` 的文案、圆角 12 和 alpha 0.1。
|
||||||
|
- `_submit()` 首行 busy 短路;异步 catch 中的 `setState` 前检查 mounted。
|
||||||
|
- 密码框 done 提交仍走同一 `_submit()`,busy 时自然忽略。
|
||||||
|
- 提交按钮增加极淡辣椒红阴影,背景和按下色保持不变。
|
||||||
|
|
||||||
|
## 不可变清单
|
||||||
|
|
||||||
|
```text
|
||||||
|
auth-brand-mark 48x48
|
||||||
|
auth-card radius 20 / border outlineVariantDark / width 1
|
||||||
|
input height 52 / radius 14 / fill bar / focus blue 1.5
|
||||||
|
submit height 52 / radius 26 / blue / bluePressed
|
||||||
|
auth-enter-animation <=220ms / tween 0 -> 1
|
||||||
|
mode switch keys, hidden compatibility copy
|
||||||
|
error capsule radius 12 / alpha 0.1 / semantic color
|
||||||
|
small screen 320x568 + keyboard 260 scroll reachability
|
||||||
|
```
|
||||||
|
|
||||||
|
## TDD
|
||||||
|
|
||||||
|
1. 新增测试:注册副标题、注册态密码提示、登录态不显示提示、字段错误红框。
|
||||||
|
2. 先确认红灯。
|
||||||
|
3. 补 `authSignUpSubtitle` 三语并生成 l10n。
|
||||||
|
4. 调整 AuthLogic、表单字段、品牌和页面布局。
|
||||||
|
5. 跑两份既有 Auth 测试与新增测试,再跑全量门禁。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
```text
|
||||||
|
dart analyze lib test
|
||||||
|
flutter test
|
||||||
|
flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 居中布局与小屏滚动会互相影响,必须保持 `auth-submit` 可滚动到。
|
||||||
|
- 字段红框不能通过 errorText 增加字段高度,否则会破坏 52 高度断言。
|
||||||
|
- 品牌改为图片资源后仍必须保持 `auth-brand-mark` 为 48×48。
|
||||||
|
- 不改接口、后端、Web、specs、main;真机由用户验收。
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 2026-10-19 K22.1 后台收尾
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
- ReelShortWeb `develop`: `5494190`
|
||||||
|
- FruitServer `develop`: `2807ae6`
|
||||||
|
- 只改 ReelShortWeb 的后台视觉与资源引用、构建配置及无引用文件。
|
||||||
|
- 不改接口契约、后端业务、客户端与 main。
|
||||||
|
|
||||||
|
## 登录页
|
||||||
|
|
||||||
|
1. 保留 hero 品牌 mark、kicker 与标题。
|
||||||
|
2. 删除标题下方说明段落及 `pointDrama/pointPublish/pointAccess` 三个标签。
|
||||||
|
3. 删除不再使用的三语 key 与 hero 列表样式。
|
||||||
|
4. 删除注册切换、注册表单、入口事件及相关导出和文案。
|
||||||
|
|
||||||
|
## 品牌与图标
|
||||||
|
|
||||||
|
1. `brand-mark.png` 复制到 `public`,作为浏览器 favicon 与首屏 loading。
|
||||||
|
2. 首屏背景改为 `#100C0A`,loading mark 增加圆角。
|
||||||
|
3. 删除 `public/logo.png`、`public/favicon.ico`。
|
||||||
|
4. 四个 env 均关闭在线图标,由 `PurgeIcons` 本地打包 Material Symbols。
|
||||||
|
5. 构建后扫描旧前缀、`<el-icon>`、Element Plus 图标引用,并检查浏览器实际图标。
|
||||||
|
|
||||||
|
## Mock 清理
|
||||||
|
|
||||||
|
1. dev/pro/test 环境关闭 `VITE_USE_MOCK`。
|
||||||
|
2. 删除 `mock/` 与 `viteMockServe` 配置。
|
||||||
|
3. 全仓确认不再存在 mock 环境分支和旧 mock 图标来源。
|
||||||
|
|
||||||
|
## 文件删除
|
||||||
|
|
||||||
|
删除前分别对目标路径执行全仓 grep,确认只命中自身或已同步处理的引用:
|
||||||
|
|
||||||
|
- `src/assets/imgs/logo.png`
|
||||||
|
- `src/assets/imgs/avatar.jpg`
|
||||||
|
- `public/logo.png`
|
||||||
|
- `public/favicon.ico`
|
||||||
|
- `src/views/permission/CustomMenuView.vue`
|
||||||
|
- `src/views/Login/components/RegisterForm.vue`
|
||||||
|
- `mock/`
|
||||||
|
|
||||||
|
禁止删除仍被路由、菜单、组件或构建配置引用的文件。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
- `pnpm lint:eslint`
|
||||||
|
- `pnpm build:pro`
|
||||||
|
- 浏览器自查登录页、后台壳、菜单、顶栏、表格工具栏与首屏 loading。
|
||||||
|
- 控制台无 `/logo.png`、mock 或旧图标资源 404。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 在线图标关闭后必须确认 PurgeIcons 已本地打包全部实际使用图标。
|
||||||
|
- 删除 mock 后需确认开发环境只依赖真实后台接口。
|
||||||
|
- 删除文件前先处理所有引用,避免残留 import 或路由失败。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 2026-10-19 K22 后台管理视觉与交互统一
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
- ReelShortWeb `develop`: `5c087f4`
|
||||||
|
- FruitServer `develop`: `b025bfd`
|
||||||
|
- 只改 Web 视觉/交互与 FruitServer 菜单图标 SQL,不改接口、客户端、main。
|
||||||
|
|
||||||
|
## 图标体系
|
||||||
|
|
||||||
|
1. 新增 `src/constants/icons.ts`:
|
||||||
|
- `AppIcons` 保存语义名到 `material-symbols:*-rounded` 的映射;
|
||||||
|
- `normalizeAppIcon()` 兼容后端旧短名和历史 Iconify 名;
|
||||||
|
- `MenuIconOptions` 供菜单管理页选择。
|
||||||
|
2. 新增 `src/components/AppIcon`:
|
||||||
|
- 复用现有 `Icon` 组件;
|
||||||
|
- 默认尺寸 18,支持颜色/悬停色;
|
||||||
|
- 全局注册 `AppIcon`。
|
||||||
|
3. 替换 `src/components/**`、`src/views/**` 的旧图标前缀与 Element Plus 图标组件。
|
||||||
|
4. 对 `node_modules/@iconify/json/json/material-symbols.json` 做存在性校验。
|
||||||
|
|
||||||
|
任务书要求的 `language/view-column/lock/chevron-down` 在当前 JSON 中没有对应的
|
||||||
|
`*-rounded` 名,实施时使用同语义且存在的
|
||||||
|
`translate-rounded/combine-columns-rounded/lock-person-rounded/keyboard-arrow-down-rounded`。
|
||||||
|
|
||||||
|
## 菜单落库
|
||||||
|
|
||||||
|
更新 `sql/data.sql` 与 `sql/data-phase1.sql` 中 id 1/2/3/6/15/17/20/22/24/26/32:
|
||||||
|
|
||||||
|
| 菜单 | 图标 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工作台 | `material-symbols:dashboard-rounded` |
|
||||||
|
| 内容管理 | `material-symbols:video-library-rounded` |
|
||||||
|
| 后台管理 | `material-symbols:settings-rounded` |
|
||||||
|
| 用户管理 | `material-symbols:group-rounded` |
|
||||||
|
| 创作者管理 | `material-symbols:movie-edit-rounded` |
|
||||||
|
| 内容治理 | `material-symbols:policy-rounded` |
|
||||||
|
| 订单与权益 | `material-symbols:payments-rounded` |
|
||||||
|
| 数据看板 | `material-symbols:monitoring-rounded` |
|
||||||
|
| 创作者中心 | `material-symbols:video-camera-front-rounded` |
|
||||||
|
| 运营管理 | `material-symbols:campaign-rounded` |
|
||||||
|
|
||||||
|
执行两份 SQL,管理员登录后请求 `/admin/auth/menus`,断言所有 icon 以
|
||||||
|
`material-symbols:` 开头。
|
||||||
|
|
||||||
|
## 品牌与登录
|
||||||
|
|
||||||
|
- 复制客户端 `splash_mark.png` 为 `src/assets/imgs/brand-mark.png`。
|
||||||
|
- Logo 组件展开/收起统一使用品牌 mark。
|
||||||
|
- 删除 admin/admin123 占位、记住密码持久化、固定 `FruitShorts Studio`。
|
||||||
|
- 记住我只保存用户名;store reset 时删除 loginInfo。
|
||||||
|
- 表单回车提交、loading 覆盖请求与动态路由、防止重复。
|
||||||
|
- 960 以下压缩 hero,登录卡首屏完整。
|
||||||
|
|
||||||
|
## 全局壳与主题
|
||||||
|
|
||||||
|
- 修复深色模式菜单、头部、标签关闭、主题开关、面包屑对比度。
|
||||||
|
- body 去掉 `min-width: 980px`,补 1200/768 断点。
|
||||||
|
- 顶栏、标签页、表格工具栏、弹窗统一 AppIcon。
|
||||||
|
- CSS token:一组字体变量、4/6/12 圆角、`#409eff !important` 改
|
||||||
|
`var(--el-color-primary)`,删除或合并未使用壳层选择器。
|
||||||
|
- 用户信息读取 `userStore`,不再使用固定 avatar/Archer。
|
||||||
|
- 清除缓存增加二次确认。
|
||||||
|
|
||||||
|
## 门禁与自查
|
||||||
|
|
||||||
|
- 静态扫描:无旧图标前缀、无 `<el-icon>`、无页面 `@element-plus/icons-vue` 引用。
|
||||||
|
- `node_modules/@iconify/json/json/material-symbols.json` 逐个校验新图标。
|
||||||
|
- `pnpm lint:eslint` 零错误。
|
||||||
|
- `pnpm build:pro` 成功。
|
||||||
|
- 浏览器自查:登录页、侧边栏展开/折叠、顶栏、标签页、浅/深色、1024/800/375。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 图标批量替换可能影响动态图标与右键菜单,保留 normalize 兜底。
|
||||||
|
- 窄屏下侧栏与固定内容宽度可能冲突,优先保证内容区不裁切。
|
||||||
|
- 登录 loading 必须覆盖动态路由生成,避免二次提交。
|
||||||
|
- 本轮不碰表格、筛选、分页、拦截器、删除确认、rules、权限与上传校验。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 2026-10-19 K23.1 后台修正
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
- ReelShortWeb `develop`: `fe1ae95`
|
||||||
|
- FruitServer `develop`: `b8676d0`
|
||||||
|
- 只改 ReelShortWeb 视觉与交互,不改接口、后端、客户端、main。
|
||||||
|
|
||||||
|
## 品牌文字
|
||||||
|
|
||||||
|
1. 浅色菜单品牌字与浅色顶栏文字固定为 `#303133`。
|
||||||
|
2. 处理本地持久化的旧 `inherit` 值,避免刷新后仍继承主色。
|
||||||
|
3. 检查 Logo、ToolHeader、Breadcrumb 的实际颜色。
|
||||||
|
|
||||||
|
## 删除按钮
|
||||||
|
|
||||||
|
1. 扫描所有删除、移除、退款、回收、冻结危险按钮。
|
||||||
|
2. 统一 `type="danger"`、表格内 `size="small"`,禁止 link/plain/text 和浅色 `danger-action`。
|
||||||
|
3. 删除确认按钮保持 `el-button--danger` 实心红底。
|
||||||
|
4. 调整过窄操作列,保证按钮不换行。
|
||||||
|
|
||||||
|
## 布局回归
|
||||||
|
|
||||||
|
1. 1440×900、1024×768、375×812。
|
||||||
|
2. 检查侧栏品牌、菜单 42px 基线、popper、短剧编辑弹窗、锁屏弹窗和表格操作列。
|
||||||
|
3. 检查顶栏、标签页、面包屑图标与文字基线。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
- `pnpm lint:eslint`
|
||||||
|
- `pnpm build:pro`
|
||||||
|
- 浏览器控制台 0 error、0 404,页面无横向溢出。
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 2026-10-19 K23 后台视觉深化
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
- ReelShortWeb `develop`: `3e8312e`
|
||||||
|
- FruitServer `develop`: `9c96e18`
|
||||||
|
- 只改后台视觉/交互与菜单图标种子,不改接口契约、权限和业务流程。
|
||||||
|
|
||||||
|
## 主色
|
||||||
|
|
||||||
|
1. Element Plus 主色改为辣椒红 `#c4322a`,同步 light-3/5/7/8/9 与 dark-2。
|
||||||
|
2. 清理后台源码中的默认 Element 蓝与蓝色透明背景。
|
||||||
|
3. 验证按钮、开关、链接、选中菜单、标签页和焦点环对比度。
|
||||||
|
|
||||||
|
## 菜单
|
||||||
|
|
||||||
|
1. 一级菜单保留 Material Symbols Rounded 图标;所有二级菜单图标置空。
|
||||||
|
2. 开发库幂等执行后通过 `/admin/auth/menus` 检查一级图标存在、二级 icon 为空。
|
||||||
|
3. 菜单管理只允许目录菜单选择图标,保存子菜单时强制清空。
|
||||||
|
4. 一级菜单行高 44,二级菜单行高 40;父子文字统一落在 42px 基线。
|
||||||
|
5. 二级菜单不渲染图标、不保留图标空位,折叠态和 popper 同样只显示文字。
|
||||||
|
|
||||||
|
## 短剧编辑弹窗
|
||||||
|
|
||||||
|
1. 680px 三区块表单:基础信息、付费设置、封面。
|
||||||
|
2. 表单增加 model/rules,数字输入固定 180px 且控件在右。
|
||||||
|
3. 封面改为预览卡:basename、状态、更换/移除、类型/大小校验、进度与失败提示、查看原图。
|
||||||
|
4. 保存按钮防重复,保存中与上传中禁止关闭。
|
||||||
|
|
||||||
|
## 锁屏弹窗
|
||||||
|
|
||||||
|
1. 440px 紧凑弹窗,移除全屏入口。
|
||||||
|
2. 重做标题、说明、内容和底部按钮。
|
||||||
|
3. 支持回车锁定,删除调试日志。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
- 图标名逐个对照 `node_modules/@iconify/json/json/material-symbols.json`。
|
||||||
|
- `pnpm lint:eslint` 零错误。
|
||||||
|
- `pnpm build:pro` 通过。
|
||||||
|
- 浏览器自查登录页、工作台、短剧管理、锁屏;1024/375、明暗主题、控制台 0 error、0 404。
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# K24 计划:客户端 UI 对齐设计稿
|
||||||
|
|
||||||
|
日期:2026-10-19
|
||||||
|
客户端基线:`ReelShort/develop` = `b1c69e2`
|
||||||
|
文档基线:`FruitServer/develop` = `93b2299`
|
||||||
|
分支:`feature/flutter-k24-design-system`
|
||||||
|
|
||||||
|
## 一、范围
|
||||||
|
|
||||||
|
本批只做设计系统、3 Tab 底部导航、首页沉浸层、发现页:
|
||||||
|
|
||||||
|
- 设计 token:Obsidian Flare 色板、字体、间距/圆角、玻璃面板。
|
||||||
|
- 底部导航:Home / Discover / Me 三项;My List 不再独立成 Tab,内容并入 Me 页。
|
||||||
|
- 首页:保留 `PlayerPage(homeMode: true)` 播放链路,只替换叠加层与信息卡。
|
||||||
|
- 发现页:品牌行、搜索、分类 chips、Continue Watching、4 个快捷入口、2 列 Trending。
|
||||||
|
|
||||||
|
不做:详情页、播放器、我的页、登录页的视觉重做(K25/K26);后端、Web、specs、main。
|
||||||
|
|
||||||
|
## 二、设计口径
|
||||||
|
|
||||||
|
权威来源:`docs/design/README.md` + `screens/01-home.html` + `screens/02-discover.html`。
|
||||||
|
不采用 `DESIGN.md` 顶部 YAML 的 `#ffb4a5/#cdbdff`。
|
||||||
|
|
||||||
|
| 语义 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| background / ink / bar | `#0B0C10` |
|
||||||
|
| card | `#16181F` |
|
||||||
|
| surface-elevated / raised | `#1D1E28` |
|
||||||
|
| primary / blue | `#FF5B3D` |
|
||||||
|
| pressed / bluePressed | `#FF4321` |
|
||||||
|
| secondary / lavender / blueSoft | `#9278EA` |
|
||||||
|
| gold | `#FBBF24` |
|
||||||
|
| success | `#34D399` |
|
||||||
|
| on-surface | `#FFFFFF` / `#E3E2E8` |
|
||||||
|
| muted | `#A1A1AA` |
|
||||||
|
| subtle | `#71717A` |
|
||||||
|
| border | `rgba(255,255,255,0.08)` |
|
||||||
|
|
||||||
|
字体:Outfit(标题)、Plus Jakarta Sans(正文),OFL TTF 本地打包,不运行时联网。
|
||||||
|
下载失败时保留系统字体,并在回执与 PROGRESS 标注未完成项。
|
||||||
|
|
||||||
|
## 三、TDD 顺序
|
||||||
|
|
||||||
|
1. 设计 token / 主题:先更新 `test/theme/app_theme_test.dart` 期望值与新增 token 断言,再改
|
||||||
|
`app_colors.dart`、`app_theme.dart`、`app_dimens.dart`。
|
||||||
|
2. GlassPanel:先写组件测试(blur、渐变、描边、圆角),再实现 `glass_panel.dart`。
|
||||||
|
3. 3 Tab:先改导航测试为 `tab-home/tab-discover/tab-my`,覆盖选中态、映射、沉浸隐藏;
|
||||||
|
再改 `app_bottom_nav.dart` 与 `home_view.dart`。
|
||||||
|
4. 首页:先用现有 Player 测试保证播放/进度/收藏不回归,再新增首页结构断言
|
||||||
|
(剧名、HOT、右侧互动栏、底部玻璃卡、Follow Series、Episodes)。
|
||||||
|
5. 发现页:先写搜索过滤、2 列网格、Continue Watching 隐藏/显示测试,再改
|
||||||
|
`discover_view.dart` / `discover_logic.dart` / `home_drama_grid.dart`。
|
||||||
|
6. 回归:更新 4 Tab 与轮播挂载相关断言,保留播放、进度、收藏、举报等行为测试。
|
||||||
|
|
||||||
|
## 四、影响文件
|
||||||
|
|
||||||
|
- `lib/core/theme/app_colors.dart`
|
||||||
|
- `lib/core/theme/app_theme.dart`
|
||||||
|
- `lib/core/theme/app_dimens.dart`
|
||||||
|
- `lib/core/widgets/glass_panel.dart`(新增)
|
||||||
|
- `lib/core/widgets/app_bottom_nav.dart`
|
||||||
|
- `lib/pages/home/home_view.dart`
|
||||||
|
- `lib/pages/home/home_logic.dart`
|
||||||
|
- `lib/pages/discover/discover_view.dart`
|
||||||
|
- `lib/pages/discover/discover_logic.dart`
|
||||||
|
- `lib/pages/discover/widgets/home_drama_grid.dart`
|
||||||
|
- `lib/pages/player/player_view.dart`(仅 homeMode 叠加层)
|
||||||
|
- `lib/pages/mine/mine_view.dart`(仅把 My List 区块并入 Me)
|
||||||
|
- `pubspec.yaml`、`assets/fonts/*`
|
||||||
|
- 相关 `test/**`
|
||||||
|
|
||||||
|
## 五、数据缺口与降级
|
||||||
|
|
||||||
|
| 设计字段 | 现有数据 | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 评分 | `Drama` 无评分字段 | 隐藏,不伪造 |
|
||||||
|
| 完结状态 | 无完结字段 | 隐藏 |
|
||||||
|
| 榜单名次 | 无字段 | 用 `isHot` + `viewCount` 排序展示,不编名次 |
|
||||||
|
| VIP 徽章 | 无 VIP 字段;`Episode.locked` 仅集级 | 列表不显示 VIP 徽章 |
|
||||||
|
| Free 徽章 | `freeEpisodes` 仅详情接口 | 有值才显示 |
|
||||||
|
| 点赞/评论数 | 互动接口无剧级计数 | 只显示图标或文案,不编数字 |
|
||||||
|
| Continue Watching 集数/剩余时长 | `WatchProgress` 有位置/时长,无集号 | 有记录才显示,集号缺失时省略 |
|
||||||
|
|
||||||
|
## 六、风险
|
||||||
|
|
||||||
|
- 首页叠加层改动可能影响沉浸模式、PiP、下载入口与既有 tooltip;通过保留原有行为入口并跑
|
||||||
|
`test/widget_test.dart`、`test/home/**`、`test/player/**` 控制风险。
|
||||||
|
- 3 Tab 合并会触碰多个导航测试;只调整 Tab 数量/Key 断言,不改播放与收藏断言。
|
||||||
|
- 新字体增加安装包体积;只打包 Regular/SemiBold/Bold 必要字重。
|
||||||
|
- 发现页数据源仍用 `CategoryController.visibleDramas`,不新增接口。
|
||||||
|
|
||||||
|
## 七、门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
dart analyze lib test
|
||||||
|
flutter test
|
||||||
|
flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
提交作者/提交者:`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# K25 计划:客户端详情页 + 播放器对齐设计稿
|
||||||
|
|
||||||
|
日期:2026-10-19
|
||||||
|
基线:`ReelShort/develop` = `4553606`
|
||||||
|
分支:`feature/flutter-k25-detail-player`
|
||||||
|
|
||||||
|
## 一、范围
|
||||||
|
|
||||||
|
只改详情页与 `homeMode=false` 的播放器:
|
||||||
|
|
||||||
|
- 详情页:16:9 hero、玻璃导航、元数据、简介展开、CTA、选集 chips、2 列剧集卡。
|
||||||
|
- 卡片播放器:顶部导航、9:10 视频框、进度条、三按钮行、精选评论卡。
|
||||||
|
- `homeMode=true` 的 K24 首页沉浸 feed 保持现状。
|
||||||
|
|
||||||
|
不改:后端接口/模型字段、首页、发现页、我的页、登录页、`playback_session`/ticket 协议。
|
||||||
|
|
||||||
|
## 二、设计口径
|
||||||
|
|
||||||
|
沿用 K24 Obsidian Flare token、Outfit/Plus Jakarta Sans、`GlassPanel`。
|
||||||
|
|
||||||
|
详情页结构:
|
||||||
|
|
||||||
|
1. 16:9 hero(当前模型只有 `posterUrl`,作为 hero 图;无 `coverUrl` 字段)。
|
||||||
|
2. 左侧玻璃返回,右侧分享/收藏/更多(举报在更多菜单)。
|
||||||
|
3. 集数 pill、24px 标题、Season 副标题、viewCount、题材标签。
|
||||||
|
4. 16px 简介卡,2 行 + More/Less。
|
||||||
|
5. 橙色全宽 CTA + 48×48 收藏/下载,续看进度与错误重试保留。
|
||||||
|
6. Episodes 标题 + 范围 chip(无区间数据时显示 All)+ 46×44 横向集数 chips。
|
||||||
|
7. 2 列剧集预览卡:缩略图 96×64、Free/Coin、时长、锁态、下载/播放入口。
|
||||||
|
|
||||||
|
播放器结构(`homeMode=false`):
|
||||||
|
|
||||||
|
1. 顶部返回、剧名 + Ep.x、更多(下载/PiP)。
|
||||||
|
2. 9:10 圆角 16 视频框,Auto-Next/1.0x pill、中心橙色播放、右侧点赞/全屏。
|
||||||
|
3. 1.5px 进度条,白色缓冲、橙色播放、白色 thumb;保留拖动 seek。
|
||||||
|
4. Episodes / Comments / Next Ep 三按钮;末集 Next Ep 置灰。
|
||||||
|
5. 精选评论卡复用 `CommentController`,无评论隐藏;VIP/总评论数字段缺失时隐藏。
|
||||||
|
|
||||||
|
## 三、TDD 顺序
|
||||||
|
|
||||||
|
1. 详情 widget 测试:hero 16:9、CTA 行、简介展开、集数 chips、2 列剧集卡。
|
||||||
|
2. 详情实现:先复用现有 `DramaDetailLogic`,只重排视图和控件。
|
||||||
|
3. 播放器 widget 测试:卡片模式结构、三按钮行、进度条颜色/thumb、末集禁用。
|
||||||
|
4. 播放器实现:新增卡片布局,保留原全屏 feed;复用 ticket/session/queue。
|
||||||
|
5. 回归:PiP、票据错误、进度、收藏、下载、举报、评论、自动连播。
|
||||||
|
|
||||||
|
## 四、数据缺口
|
||||||
|
|
||||||
|
| 设计字段 | 模型状态 | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| coverUrl | 无(只有 posterUrl) | hero 使用 posterUrl |
|
||||||
|
| 评分 | 无 | 隐藏 |
|
||||||
|
| 完结状态 | 无 | 隐藏 |
|
||||||
|
| Top 排名 | 无 | 隐藏 |
|
||||||
|
| Trailer/4K | 无 | 隐藏 |
|
||||||
|
| VIP | 无 | 隐藏 |
|
||||||
|
| 剧级点赞/评论总数 | 无 | 只显示文案/已加载数量,不伪造 |
|
||||||
|
| 场景标签 | 无 | 隐藏 |
|
||||||
|
| 集数范围 | 无 | 显示 All chip |
|
||||||
|
| 倍速能力 | 无播放速率接口 | 显示 1.0x,不伪造切换能力 |
|
||||||
|
|
||||||
|
## 五、风险
|
||||||
|
|
||||||
|
- 卡片播放器切换全屏时复用同一 `PageController`;只在二者之一挂载,避免控制器绑定两个 PageView。
|
||||||
|
- 自动连播依赖 PageController 动画,卡片模式保留 PageView 作为视频底层。
|
||||||
|
- 评论卡新增一次评论首屏请求;无评论/失败时直接隐藏,不影响播放。
|
||||||
|
- 详情页 hero、2 列剧集卡在小屏下需自适应缩略图宽度,避免溢出。
|
||||||
|
|
||||||
|
## 六、门禁与 Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart analyze lib test
|
||||||
|
flutter test
|
||||||
|
flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
完成后 `--no-ff` 合并回 `develop` 并 push;作者/提交者
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。真机验收由用户执行。
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# K26 计划:客户端按「移动端设计预览」校正六屏
|
||||||
|
|
||||||
|
日期:2026-10-19
|
||||||
|
基线:`ReelShort/develop` = `eefabb6`
|
||||||
|
分支:`feature/flutter-k26-mobile-preview`
|
||||||
|
|
||||||
|
## 一、范围
|
||||||
|
|
||||||
|
按本地 `/Users/jianghaifeng/Desktop/ReelShort/docs/移动端设计预览.html`
|
||||||
|
校正六屏:首页、发现、详情、播放器、我的、登录。该文件优先于此前 Stitch screens。
|
||||||
|
|
||||||
|
保留 K24 的 Outfit / Plus Jakarta Sans、Provider 业务逻辑、现有接口与数据模型。
|
||||||
|
不改后端、Web、specs、main。
|
||||||
|
|
||||||
|
## 二、Token
|
||||||
|
|
||||||
|
| 语义 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| primary | `#FF5B3D` |
|
||||||
|
| secondary | `#9278EA` |
|
||||||
|
| background | `#0B0C10` |
|
||||||
|
| nav | `#0D0D11` |
|
||||||
|
| card | `#1B1C21` |
|
||||||
|
| cardAlt | `#1C1D21` |
|
||||||
|
| input / quick | `#25252A` / `#202126` |
|
||||||
|
| gold card | `#201B1E` |
|
||||||
|
| text / dim / accent | `#E6E1E3` / `#D2CDD0` / `#FFA490` |
|
||||||
|
| border | `white 8%` |
|
||||||
|
|
||||||
|
圆角:卡片 10-14、按钮 9、chips 20、封面 10。
|
||||||
|
新增紧凑字号 token(9/10/11/12/13/14/15/18/20)与按钮高度 token(28/32/34/40/44)。
|
||||||
|
|
||||||
|
## 三、六屏
|
||||||
|
|
||||||
|
1. 首页:剧名 + 搜索/菜单;Like/Comment/Share/Favorite 四互动;底部玻璃卡
|
||||||
|
(tags、第 x/共 y 集、13px 标题、34px 追剧/选集);去掉 HOT/Season/Follow。
|
||||||
|
2. 发现:Logo + 发现 + 搜索/头像;36px 搜索框;分类 chips;52×66 Continue Watching;
|
||||||
|
4 个 62px 快捷入口;热门短剧标题 + 2 列封面高 120 网格。
|
||||||
|
3. 详情:190px hero + 28px 返回;N 集、18px 标题、11px 简介、40px 开始观看、选集 chips;
|
||||||
|
收藏/下载/举报收进更多;删除统计行、预览卡与底部 Tab。
|
||||||
|
4. 播放器:全屏;46px 顶栏;中心 52px 播放 + 1.0x;3px 进度;32px 选集/评论/下一集;
|
||||||
|
一条精选评论卡;无右侧互动栏;下载/PiP/互动收进更多。
|
||||||
|
5. 我的:顶部 Logo + 我的 + 搜索/头像;48px 用户行;金币卡;Continue Watching;
|
||||||
|
历史/收藏/喜欢;语言行;底部 3 Tab。
|
||||||
|
6. 登录:64px 渐变 Logo + 水果短剧 + 副标题;Google/手机号/邮箱三个 44px 入口;
|
||||||
|
Google/手机号置灰提示即将开放,邮箱进入 K21 表单;底部法律文案。
|
||||||
|
|
||||||
|
## 四、TDD 顺序
|
||||||
|
|
||||||
|
1. Token / AppDimens / GlassPanel 测试。
|
||||||
|
2. 首页 overlay 结构与底部导航测试。
|
||||||
|
3. 发现页搜索、Continue Watching、快捷入口、2 列网格测试。
|
||||||
|
4. 详情页 190px hero、40px CTA、选集 chips 测试。
|
||||||
|
5. 播放器全屏顶栏、三按钮、精选评论测试。
|
||||||
|
6. 我的页用户行、金币卡、Continue Watching、语言行测试。
|
||||||
|
7. 登录入口与邮箱二级表单测试。
|
||||||
|
8. 回归播放、进度、收藏、下载、PiP、票据错误、评论与导航。
|
||||||
|
|
||||||
|
## 五、数据缺口
|
||||||
|
|
||||||
|
- 剧级点赞/评论数、已完结、Top 排名、VIP、评分、场景标签无模型字段:隐藏。
|
||||||
|
- Continue Watching 仅有 `episodeId/position/duration`;集号能从剧集列表匹配时显示。
|
||||||
|
- 金币余额与 Google/手机号登录后端未就绪:金币显示 0,Google/手机号入口提示即将开放。
|
||||||
|
- 剧集时长只有 `durationSec`;缺失则不显示。
|
||||||
|
|
||||||
|
## 六、风险
|
||||||
|
|
||||||
|
- 六屏同时调整会触碰大量既有 widget 测试;只改视觉/布局与入口,不改业务逻辑。
|
||||||
|
- 登录页改成入口页后,K21 邮箱表单测试需要先点击邮箱入口;表单本身与认证逻辑保持不变。
|
||||||
|
- 播放器仍复用 ticket/PlaybackSession/PlaybackQueue;只替换非首页的 overlay。
|
||||||
|
- 卡片圆角与按钮高度变化可能影响小屏布局,用 320×568、360×720、390×844 回归。
|
||||||
|
|
||||||
|
## 七、门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart analyze lib test
|
||||||
|
flutter test
|
||||||
|
flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
完成后 `--no-ff` 合并回 `develop` 并 push;作者/提交者
|
||||||
|
`Zhou Weidong <502333560@qq.com>`。真机验收由用户执行。
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# 2026-10-20 K28 客户端 UI 重构(按简图)
|
||||||
|
|
||||||
|
> 来源:用户 2026-10-19 提供的 5 屏 + 4 面板手绘简图。
|
||||||
|
> 原则:先出 UI,再划分功能任务;本批不改后端契约、不伪造接口、不放假按钮的“可用”承诺。
|
||||||
|
> 设计语言沿用现有深色主题(Obsidian Flare),简图只定结构与层级。
|
||||||
|
|
||||||
|
## 一、信息架构
|
||||||
|
|
||||||
|
底部 4 Tab:
|
||||||
|
|
||||||
|
| Tab | 页面 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 首页 | `home` | 浏览型首页(Banner + 继续观看 + 热门漫剧) |
|
||||||
|
| 剧场 | `theater` | 分类 + 语言筛选 + 两列剧库 + 搜索 |
|
||||||
|
| 创作 | `create` | AI 制作 / 上传发布 两个入口 |
|
||||||
|
| 我的 | `mine` | 账户、金币、会员、菜单 |
|
||||||
|
|
||||||
|
播放页改为**推入式全屏页**(保留现有 `PlayerPage` 双模式能力),从首页 Banner/卡片、“继续观看”、剧场卡片、选集进入。
|
||||||
|
|
||||||
|
## 二、分屏口径
|
||||||
|
|
||||||
|
### 01 首页
|
||||||
|
|
||||||
|
- 顶栏:搜索框(占满剩余宽度)+ 头像入口 + 星期文案(周一…周日)。
|
||||||
|
- 频道 Tab:`热门 / 免费 / 新剧 / 榜单`,选中项主色下划线/胶囊;`榜单` 跳剧场榜单区。
|
||||||
|
- Hero Banner:封面图、剧名(大字号 Outfit)、一句文案、底部页码点;自动轮播 + 手势切换。
|
||||||
|
- 继续观看:标题行 + 「查看全部」;卡片含封面、剧名、`第 n 集 · 还剩 m 分钟`、播放按钮。
|
||||||
|
- 热门漫剧:标题行 + 「查看更多」;两列卡片,封面 + 标题 + 题材 + `免费/集数` + 热度。
|
||||||
|
- 无数据时用现有 `EmptyState`;下拉刷新可选(本批可不加)。
|
||||||
|
|
||||||
|
### 02 剧场
|
||||||
|
|
||||||
|
- 顶栏:搜索框 + 头像(与首页一致)。
|
||||||
|
- 分类 chips:全部 + 服务端分类;语言 chips:全部语言 / 中文 / 印尼语 / 马来语 / 英语。
|
||||||
|
- 两列剧卡:封面(原图比例铺满)、标题、题材、`免费` 角标、集数、热度。
|
||||||
|
- 搜索:保留现有搜索输入与结果页;点击页面失焦。
|
||||||
|
- 下拉刷新保留。
|
||||||
|
- 语言筛选:当前接口未下发 `language`,本批只做 UI 选中态,不做数据过滤;在任务清单标注为后端缺口。
|
||||||
|
|
||||||
|
### 03 创作
|
||||||
|
|
||||||
|
- 标题「创作」+ 副标题「让故事成为漫剧」。
|
||||||
|
- 卡 1「AI 制作」:从故事或想法开始,右侧箭头。
|
||||||
|
- 卡 2「上传发布」:发布已完成的漫剧,右侧箭头。
|
||||||
|
- 入口行为:本批统一进入「敬请期待 / 入驻申请」的诚实占位页,不做假上传、假 AI 生成。
|
||||||
|
- 后续功能任务单列(见第五节)。
|
||||||
|
|
||||||
|
### 04 我的
|
||||||
|
|
||||||
|
- 头像 + 昵称 + 签名/身份文案 + 右侧箭头。
|
||||||
|
- 三快捷入口:收藏/追剧、观看历史、本地下载。
|
||||||
|
- 金币卡:`金币余额` + 数值 + `充值` 按钮(跳现有钱包/充值页)。
|
||||||
|
- 会员卡:`开通会员,解锁更多精彩内容`(跳现有 VIP 页;无 C 端商品时展示“暂未开放”)。
|
||||||
|
- 菜单:我的作品、创作者中心、消息通知(支持红点)、设置、帮助与反馈。
|
||||||
|
- 未登录:头像区显示登录入口,金币/会员/菜单保持可点但需登录。
|
||||||
|
|
||||||
|
### 05 竖屏播放
|
||||||
|
|
||||||
|
- 顶栏:返回、更多(右上)。
|
||||||
|
- 右侧互动 rail:点赞(含数量)、评论(含数量)、收藏、分享。
|
||||||
|
- 底部:剧名、`第 n 集`、题材标签 + `关注`、简介、进度条 + `当前/总时长`、`选集`、`更多`。
|
||||||
|
- 中心播放按钮:仅暂停时显示(已定口径)。
|
||||||
|
- 清屏:保留现有清屏/退出清屏与“清屏状态跨集保持”。
|
||||||
|
|
||||||
|
### 面板
|
||||||
|
|
||||||
|
1. **选集**:标题「选集」+ 剧名 + `共 n 集` + 关闭;6 列等宽网格,当前集主色,锁定集显示锁图标,点击切集。
|
||||||
|
2. **评论**:标题 `评论 (n)` + 关闭;列表项(头像、昵称、时间、内容、点赞数、回复);底部输入框 + 发送。
|
||||||
|
3. **更多操作**:倍速(当前值)、清晰度(自动)、下载到本地、文字大小、投诉;行尾箭头/当前值。
|
||||||
|
4. **内容解锁**:金币解锁、看广告解锁、查看会员内容三条;底部说明「具体方式以作品而定」;仅展示可行入口,不可用项明确禁用/说明。
|
||||||
|
|
||||||
|
## 二·五 视觉风格 v2(浅色暖橙,2026-10-20 用户指定)
|
||||||
|
|
||||||
|
> 用户提供两张参考图(剧场页、竖屏剧集页),以此为准;K28 各屏不再沿用深色主题。
|
||||||
|
|
||||||
|
### 色板
|
||||||
|
|
||||||
|
| 用途 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 页面背景 | `#FFF8F0`(顶部带 `#FFE9D6` 暖色渐变) |
|
||||||
|
| 卡片/弹层 | `#FFFFFF` |
|
||||||
|
| 主色(按钮/选中/图标) | `#FF7A1A` |
|
||||||
|
| 主色渐变(中央按钮) | `#FF9A3D → #FF6A00` |
|
||||||
|
| 主文字 | `#1F1B16` |
|
||||||
|
| 次要文字 | `#8C837B` |
|
||||||
|
| 弱文字/占位 | `#B3AAA1` |
|
||||||
|
| 分割线/描边 | `#F0E4D6` |
|
||||||
|
| 粉色角标底 | `#FFE3EC`(文字 `#FF6A8A`) |
|
||||||
|
| 深色角标(锁定数) | `#33000000` + 白字 |
|
||||||
|
|
||||||
|
### 组件
|
||||||
|
|
||||||
|
- 圆角:卡片 16、封面 16、弹层 20、chips 999(胶囊)、按钮 999。
|
||||||
|
- 阴影:`0x14FF8A3D`,blur 18,y 6;卡片不使用重描边。
|
||||||
|
- 分类 chips:选中为橙色实心 + 白字;未选中为白底 + 深色字,间距 10。
|
||||||
|
- 剧卡:大封面(原图比例或 3:4)、左上「桃花源」粉色胶囊、右上锁图标 + 数量、封面右下「全 24 集 / 大结局」米色胶囊、标题 15–16 粗体、副行「更新至 18 集 · 09-04」12px 灰字、右下橙色圆形「+」。
|
||||||
|
- 底部导航:白/米色毛玻璃,**保持 4 槽(首页 / 剧场 / 创作 / 我的)**,选中图标与文字为橙色;参考图中间的橙色「+」不实现。
|
||||||
|
- 播放/剧集页:大标题 24 粗体 + 橙点、副标题 14 灰、右侧「…」与圆形按钮;Hero 大图 20 圆角 + 右上题材胶囊 + 分页点;下方横向剧集卡(第 01 集 初遇 / 更新中胶囊)。
|
||||||
|
|
||||||
|
### 落地方式
|
||||||
|
|
||||||
|
1. 先改 `AppColors/AppTheme/AppDimens` 全局 token,使现有组件整体切换为浅色暖橙。
|
||||||
|
2. `AppBottomNav` 保持 4 项(首页/剧场/创作/我的),只做视觉换肤;不新增中央按钮/`nav-center-action`。
|
||||||
|
3. K28-A1 先完成结构;样式统一由 K28-S 批次落地,后续 A2/B/C 直接使用新 token。
|
||||||
|
|
||||||
|
## 三、复用与约束
|
||||||
|
|
||||||
|
- 底部导航复用 `AppBottomNav`(需支持 4 项)。
|
||||||
|
- 主题色/字号沿用 `AppColors/AppDimens`;标题 Outfit、正文 Plus Jakarta Sans。
|
||||||
|
- 所有新增文案走 l10n(en/zh/id),禁止硬编码。
|
||||||
|
- 所有手写 Dart 首行 `// Author: fruit.com.cn`;public 类/方法有文档注释。
|
||||||
|
- Key 约定:`tab-home` / `tab-theater` / `tab-create` / `tab-mine`;`home-channel-*`;`theater-language-*`;`create-ai` / `create-upload`;`mine-wallet` / `mine-vip`;播放面板沿用 `player-episodes` / `player-comments` / `player-more-*` / `player-unlock-*`。
|
||||||
|
- 单文件 ≤500 行:现有 `player_view.dart` 已超限,本批允许拆文件,不新增超大文件。
|
||||||
|
|
||||||
|
## 四、实施批次(先 UI)
|
||||||
|
|
||||||
|
| 批次 | 范围 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| K28-A | 4 Tab IA + 首页 + 剧场 | 4 Tab 可切换;首页/剧场结构与简图一致;既有播放/详情链路可达;`dart analyze` 零告警 + `flutter test` 全过 |
|
||||||
|
| K28-B | 创作 + 我的 | 两个新页面结构与简图一致;金币/会员/菜单入口可达;未登录态正确 |
|
||||||
|
| K28-C | 竖屏播放 UI + 四面板 | 播放页结构与简图一致;四面板可开合;选集切集/评论/倍速/清屏回归通过 |
|
||||||
|
| K28-D | 回归与门禁 | l10n 三语齐全;全部测试通过;APK 构建成功;PROGRESS 留痕 |
|
||||||
|
|
||||||
|
## 五、UI 之后的功能任务(待排期)
|
||||||
|
|
||||||
|
1. 首页频道真实数据(热门/免费/新剧/榜单)与 Banner 数据补齐。
|
||||||
|
2. 剧场语言筛选:后端 VO 下发 `language` 后再接真实过滤。
|
||||||
|
3. 创作:AI 制作与上传发布需 C 端创作接口/资质(当前契约缺口,见 INBOX 顶部)。
|
||||||
|
4. 金币充值:支付通道接入前保持入口隐藏或明确“暂未开放”。
|
||||||
|
5. 会员:C 端会员商品/订阅接口缺失。
|
||||||
|
6. 看广告解锁:需要广告平台账号、广告位 ID 与发奖校验接口。
|
||||||
|
7. 播放清晰度:接口仅下发 `AUTO`,多清晰度需后端补齐。
|
||||||
|
8. 文字大小:需全局字号偏好与持久化。
|
||||||
|
|
||||||
|
## 六、风险 / 待确认
|
||||||
|
|
||||||
|
- 简图为浅色线稿,实际落地为现有深色主题;如需要浅色版需另出视觉稿。
|
||||||
|
- 「创作」两个入口在后端能力缺失时只做诚实占位,不做假流程。
|
||||||
|
- 首页频道与周几文案涉及新增 l10n key,需三语翻译。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# UX 改造计划:红果式 3 Tab + 剧场九宫格 + 我的页减法
|
||||||
|
|
||||||
|
日期:2026-10-20
|
||||||
|
仓库:`C:\work\ReelShort`(Flutter)
|
||||||
|
分支:`feature/ux-theatre-3tabs`(基线 `develop`)
|
||||||
|
|
||||||
|
## 一、范围
|
||||||
|
|
||||||
|
- 底部导航 4 → 3:推荐(播放)/ 发现(剧场)/ 我的。
|
||||||
|
- 删除独立榜单 tab,榜单内容并入发现页。
|
||||||
|
- 发现页改为剧场:顶部搜索框 + 分类九宫格 + 榜单区块 + 全部剧目网格。
|
||||||
|
- 点击分类进入该分类剧目列表页。
|
||||||
|
- 我的页只保留:继续观看、我的追剧、观看历史、下载、金币钱包、会员、设置。
|
||||||
|
- 移除手动语言切换,App 跟随系统(默认印尼语/英语,系统中文仍可解析)。
|
||||||
|
- 保留首页沉浸式上下滑播放。
|
||||||
|
|
||||||
|
## 二、实施顺序
|
||||||
|
|
||||||
|
1. 导航 4 → 3,删除 RankingPage 路由。
|
||||||
|
2. 发现页重构:分类九宫格、分类剧目页、榜单区块、搜索;删除语言筛选与发现页续看。
|
||||||
|
3. 我的页重构:删除语言行、内嵌历史/收藏/点赞列表、账号卡、通知入口;新增设置页承载通知与账号。
|
||||||
|
4. 清理死代码:RankingPage、LocaleController 手动选择、CategoryController 筛选态、DiscoverLogic 续看/语言筛选。
|
||||||
|
5. 更新测试与 l10n 断言,跑 `dart analyze lib test` + `flutter test`。
|
||||||
|
6. `--no-ff` 合并回 `develop` 并 `git push origin develop`。
|
||||||
|
|
||||||
|
## 三、风险与对策
|
||||||
|
|
||||||
|
- 玩家页从 My/Discover 返回依赖 action 枚举:保留 home/discover/resume 必要动作,删除 watchlist/detail 等不再产生的动作。
|
||||||
|
- 设置页承载账号卡与通知,避免删除入口后留下死路由。
|
||||||
|
- 分类九宫格点击后进入独立列表页,避免全局筛选态残留。
|
||||||
|
- 测试大量断言 4 Tab/语言行/内嵌列表;按新 UX 重写对应断言,不改播放链路。
|
||||||
|
- 参数 ≤5:Discover/My 页使用 callbacks 聚合对象,页面 constructor 保持 ≤5 参数。
|
||||||
|
|
||||||
|
## 四、门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart analyze lib test
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、待确认
|
||||||
|
|
||||||
|
- 系统语言为中文时是否仍显示中文:本计划保留系统中文解析,仅移除手动切换入口。
|
||||||
|
- 通知功能保留但收进设置页;若要求彻底删除通知功能,需要另行删除 Notice 客户端链路。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# K100 后台剧集多选上传(分片 + 断点续传 + 批量建集)
|
||||||
|
|
||||||
|
> 日期:2026-10-21
|
||||||
|
> 范围:仅 `ReelShortWeb`;不改 FruitServer 后端、不改 ReelShort 客户端、不改接口契约。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
剧集管理页「批量上传」:多选视频 → 队列分片上传(文件并发 2 / 分片并发 3)→ 单项与字节加权总进度 → 失败隔离/可重试 → localStorage 断点续传 → 上传完成后批量创建分集。
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. 从 `develop` 拉 `feature/episode-batch-upload`。
|
||||||
|
2. 新建 `src/views/contentManagement/upload/uploadSessions.ts`:`reel_upload_sessions` 读写/过滤/清理。
|
||||||
|
3. 新建 `useUploadQueue.ts`:状态机、文件并发泵、进度加权、pause/retry/remove、complete 写回 assetId/url、挂接 session。
|
||||||
|
4. 新建 `UploadTaskRow.vue` / `BatchUploadDialog.vue` / `CreateEpisodesDialog.vue`。
|
||||||
|
5. `EpisodeManagementView.vue` 仅加「批量上传」入口,保留原单文件上传。
|
||||||
|
6. `zh-CN.ts` / `en.ts` 补齐文案;白名单 mp4/mov/mkv/webm;超限标红不入队;`beforeunload`。
|
||||||
|
7. 门禁:`pnpm lint:eslint` + `pnpm build:dev`。
|
||||||
|
8. 提交 `feat(admin): 剧集多选分片上传与断点续传` → `--no-ff` 合回 `develop` 并 push。
|
||||||
|
|
||||||
|
## 风险 / 待确认
|
||||||
|
|
||||||
|
- 续传需用户重新选择本地文件才能匹配;换机器/改名无法自动续传(产品已接受)。
|
||||||
|
- 暂停不 abort 进行中分片,已在传任务会跑完再停派发。
|
||||||
|
- `EpisodeManagementView.vue` 存量已超 500 行,本轮只加入口,不整体拆旧编辑器。
|
||||||
|
- 手工联调(多选/暂停/刷新续传/建集)由 PM 在 8080+3005 复核,本轮不操作真机、不 kill 已运行端口。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# K85 收藏数 / 评论数实时更新实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
修复收藏数、评论数在列表、播放页和详情页不实时更新的问题,并让服务端收藏计数与关系数据保持一致。
|
||||||
|
|
||||||
|
## 前置结论
|
||||||
|
|
||||||
|
根因已由任务书确认,不再重复排查:
|
||||||
|
|
||||||
|
- 收藏关系写入/删除时没有同步维护 `drama.favorite_count`。
|
||||||
|
- 客户端首页收藏未复用现有计数增量逻辑。
|
||||||
|
- 评论面板关闭后没有重新查询评论总数。
|
||||||
|
- 详情页收藏切换后没有本地收藏数增量。
|
||||||
|
|
||||||
|
## A. FruitServer
|
||||||
|
|
||||||
|
1. `DramaService` 新增原子收藏计数调整方法,参考现有点赞计数实现。
|
||||||
|
2. `FavoriteService` 在收藏、取消收藏、批量取消时按实际删除/插入结果同步增减,避免重复操作导致漂移。
|
||||||
|
3. 新增一次性 `sql/backfill_favorite_count.sql`,从 `user_favorite` 回填现有收藏数,不在应用启动时自动执行。
|
||||||
|
4. `AppCommentController` 新增匿名评论总数接口,复用 `CommentService.countByDrama`。
|
||||||
|
5. 补收藏同步维护、批量取消、评论计数接口测试。
|
||||||
|
6. 门禁:JDK 21 下完整 `mvn test`。
|
||||||
|
|
||||||
|
## B. ReelShort
|
||||||
|
|
||||||
|
1. `CommentApi` / `CommentRepository` 新增评论总数查询,兼容数字和字符串两种 Long 返回。
|
||||||
|
2. 播放器统一维护按剧评论数缓存;评论面板关闭后刷新,失败保留旧值。
|
||||||
|
3. 首页 feed 收藏复用 `_toggleSave`,与全屏播放页共用收藏增量更新。
|
||||||
|
4. 详情页收藏成功后本地显示收藏数增量,失败回滚,不修改 `Drama` 模型。
|
||||||
|
5. 补评论计数解析、收藏增量、首页 rail 刷新测试。
|
||||||
|
6. 门禁:`dart analyze lib test` 零告警、`flutter test` 全过。
|
||||||
|
|
||||||
|
## 风险与处理
|
||||||
|
|
||||||
|
- 并发收藏切换可能造成计数竞争:服务端使用 SQL 原子自增/自减,减法不低于 0。
|
||||||
|
- 历史收藏数全为 0:提供一次性回填脚本,由验收环境显式执行。
|
||||||
|
- 评论面板取消打开时仍刷新一次计数,用于获取匿名新增评论带来的总数变化;查询失败静默保留旧值。
|
||||||
|
- 详情页只在收藏状态确实变化时调整本地增量,避免重复点击造成累计误差。
|
||||||
|
|
||||||
|
## 合并与验收
|
||||||
|
|
||||||
|
- FruitServer:`fix/favorite-comment-count`,`--no-ff` 合回 `develop` 并推送。
|
||||||
|
- ReelShort:`fix/interaction-count-refresh`,`--no-ff` 合回 `develop` 并推送。
|
||||||
|
- 作者统一 `Zhou Weidong <502333560@qq.com>`。
|
||||||
|
- 不修改 specs 仓库,不执行真机安装;完成后等待用户验收。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# K86 评论面板按参考图重做实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
仅调整 ReelShort 客户端评论面板和评论行视觉,使顶部标题、评论元信息、回复入口和底部输入区接近参考图;不修改后端接口、控制器数据逻辑或评论业务能力。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- `lib/pages/player/widgets/drama_comments_sheet.dart`
|
||||||
|
- `lib/pages/player/widgets/comment_tile.dart`
|
||||||
|
- `test/comment/drama_comments_sheet_test.dart`
|
||||||
|
- 必要的 PROGRESS 留痕
|
||||||
|
|
||||||
|
## 实现计划
|
||||||
|
|
||||||
|
1. 顶部栏
|
||||||
|
- 删除拖拽把手和剧名行。
|
||||||
|
- 左侧放置 44x44 向下箭头,保留 `player-comments-close`,点击关闭面板。
|
||||||
|
- 中间使用现有 `commentsWithCount` / `comments`,16px、w600、`AppColors.onSurface`,保留 `comment-count-label`。
|
||||||
|
- 右侧留空,保持标题视觉居中。
|
||||||
|
|
||||||
|
2. 评论行
|
||||||
|
- 头像改为 40px,回复行 28px;文字区左间距 12。
|
||||||
|
- 昵称 12px muted、正文 16px cream、行高 1.4。
|
||||||
|
- 时间与回复入口组合为灰色元信息行,时间继续使用 `formatLocalizedDateTime`。
|
||||||
|
- 删除点赞按钮、心形图标与数字展示,控制器点赞逻辑保留但不绑定 UI。
|
||||||
|
- 举报/删除入口改为右上角 18px 的更多图标,保留 `comment-menu-{id}` 和现有菜单行为。
|
||||||
|
- 去除行间分隔线,调整上下留白为 12/14。
|
||||||
|
|
||||||
|
3. 回复
|
||||||
|
- 展开/收起回复使用 12px muted 文本,前面增加 16x1 的 border 色短横线。
|
||||||
|
- 展开后的回复沿用相同评论行样式,并通过 40px 头像宽度加 12px 间距与正文对齐。
|
||||||
|
|
||||||
|
4. 输入区
|
||||||
|
- 输入框改为 44px 高、22px 圆角、`AppColors.commentInput` 底色。
|
||||||
|
- 保留登录提示、回复态 banner、橙色 38px 发送按钮及 `comment-post` Key。
|
||||||
|
- 保持现有键盘避让和面板高度 clamp。
|
||||||
|
|
||||||
|
5. 状态与交互
|
||||||
|
- 保留加载、错误、空态、待审核、展开回复、翻页、回复、删除、举报和发送逻辑。
|
||||||
|
- 保留登录闸、自动聚焦和登录后输入框聚焦行为。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 删除依赖 `comment-like-10` 的点赞展示用例。
|
||||||
|
- 断言不存在任何 `comment-like-*` 按钮或心形计数。
|
||||||
|
- 断言 `player-comments-close` 可关闭面板。
|
||||||
|
- 断言标题居中、字号和字重符合规格。
|
||||||
|
- 断言头像、正文、元信息、回复入口、输入框与发送按钮尺寸。
|
||||||
|
- 保持既有评论分页、登录、发布、展开回复、删除和举报用例通过。
|
||||||
|
|
||||||
|
## 门禁与 Git
|
||||||
|
|
||||||
|
- ReelShort 从 `develop` 创建 `fix/comments-sheet-style`。
|
||||||
|
- 提交:`fix(player): 评论面板按参考图重做`。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
- `--no-ff` 合回 ReelShort `develop` 并 push,不使用 force push,不执行 `git add -A`。
|
||||||
|
- 门禁:`dart analyze lib test` 零告警、`flutter test` 全过。
|
||||||
|
- 不安装真机;完成后等待用户验收。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# K86.21 评论输入浮层稳定定位实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
修复 Android 收起评论输入键盘时,输入浮层因键盘收起动画中的瞬时 inset 被逐帧 `max` 抬高而向上跳动的问题。评论列表面板、输入内容和关闭规则保持不变。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- `CommentComposerSheet.build` 每次重建都用 `math.max` 累积当前 effective keyboard inset。
|
||||||
|
- IME 收起动画期间可能出现高于稳定值的瞬时 inset,累计后永久抬高 `_pinnedKeyboardInset`。
|
||||||
|
- 面板布局随后按更高的 pinned 值重新定位,造成输入浮层向上跳一小段。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- ReelShort `lib/pages/player/widgets/comment_composer_sheet.dart`
|
||||||
|
- ReelShort `test/comment/drama_comments_sheet_test.dart`
|
||||||
|
- FruitServer `docs/plans/PROGRESS.md`
|
||||||
|
|
||||||
|
## 实现计划
|
||||||
|
|
||||||
|
1. 移除 `build` 中逐帧更新 `_pinnedKeyboardInset` 的逻辑,新增键盘可见状态和 80ms 稳定捕获 Timer。
|
||||||
|
2. effective inset 从 0 变为正数时启动 Timer;Timer 触发时只读取一次当前 inset,并取整后写入 pinned。
|
||||||
|
3. 键盘持续可见期间,后续 inset 变大或变小都不更新 pinned;inset 归零只取消 Timer 并重置可见状态,下一次弹起可重新捕获。
|
||||||
|
4. pinned 上限为屏幕高度的 60%;非有限值、负值或超过上限的异常值忽略,保证 pinned 不小于 0。
|
||||||
|
5. 保持 `Positioned(bottom: 0)`、白色 Material 铺到屏幕底部及 `Padding(bottom: pinned)` 布局,不使用位移或回落动画。
|
||||||
|
6. 返回键/IME 下箭头只收键盘;透明外部点击和发表成功仍为关闭入口。
|
||||||
|
|
||||||
|
## 测试计划
|
||||||
|
|
||||||
|
- 先新增失败测试:2200 高视口模拟 `0 → 1100(稳定)→ 1150(收起瞬时峰值)→ 600 → 0`,断言 1100 捕获后输入浮层 top、bottom、高度均保持不变。
|
||||||
|
- 运行旧实现确认红灯,记录实际偏移。
|
||||||
|
- 新实现后运行定向测试、完整评论面板测试文件、`dart analyze lib test` 和全量 `flutter test --no-pub`。
|
||||||
|
|
||||||
|
## 风险与处理
|
||||||
|
|
||||||
|
- 80ms 可能早于极慢 IME 动画结束;按任务要求采用固定稳定窗口,不在后续可见阶段继续累计。
|
||||||
|
- 超过屏幕 60% 的异常值被忽略后,需等下一次 0 → 正数弹起再捕获,避免错误定位。
|
||||||
|
- Timer 在 dispose 时取消,避免 Overlay 关闭后回调访问已卸载 State。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShort 从 `develop` 创建 `fix/composer-stable-inset`。
|
||||||
|
- 提交:`fix(player): 固定评论输入浮层位置避免收键盘跳动`。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
|
||||||
|
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# K86.22 评论输入浮层去除底部安全区位移实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
修复 Android 收起评论输入键盘后,`SafeArea` 引入底部手势条 padding,导致输入区上移约 48px 的问题。输入区位置只由固定 `_pinnedKeyboardInset` 决定,键盘收起前后保持像素级一致。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- 输入面板 Material 内使用 `SafeArea(top: false)`。
|
||||||
|
- 键盘可见时系统导航栏被 IME 覆盖,`MediaQuery.padding.bottom` 为 0。
|
||||||
|
- 键盘收起后手势条 padding 恢复,`SafeArea` 自动追加约 48px 底部内边距,把输入区顶高。
|
||||||
|
- K86.19 的白色实底和固定 12px 内容内边距本身正常,问题仅来自额外安全区。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- ReelShort `lib/pages/player/widgets/comment_composer_sheet.dart`
|
||||||
|
- ReelShort `test/comment/drama_comments_sheet_test.dart`
|
||||||
|
- FruitServer `docs/plans/PROGRESS.md`
|
||||||
|
|
||||||
|
## 实现计划
|
||||||
|
|
||||||
|
1. 面板 `SafeArea` 设置 `bottom: false`,不再消费 `MediaQuery.padding.bottom`。
|
||||||
|
2. 保持 `Positioned(bottom: 0)`、白色 Material 铺到屏幕底部、顶部圆角 20。
|
||||||
|
3. 键盘区域继续由内部 `Padding(bottom: _pinnedKeyboardInset)` 决定,内容区底部保持固定 12。
|
||||||
|
4. 透明外部点击或发表成功仍为关闭入口;返回键/IME 下箭头只收键盘。
|
||||||
|
|
||||||
|
## 测试计划
|
||||||
|
|
||||||
|
- 先新增失败测试:视口 390×844,键盘弹起时 `viewInsets.bottom=180`、`padding.bottom=0`,记录输入框全局矩形。
|
||||||
|
- 收起键盘时同时设置 `viewInsets.bottom=0`、`padding.bottom=48`,断言输入框 top、bottom、height 偏移均小于 0.1。
|
||||||
|
- 旧实现预期因 SafeArea 多出 48px 而失败;修复后运行完整评论面板测试、静态分析和全量 Flutter 测试。
|
||||||
|
|
||||||
|
## 风险与处理
|
||||||
|
|
||||||
|
- 关闭底部 SafeArea 后,输入内容依赖固定 12px 底部内边距;与 K86.19 白色实底填充一致,不再叠加系统手势条。
|
||||||
|
- 面板本身仍延伸到屏幕底部,视觉圆角和白底不受影响。
|
||||||
|
- 不改 KeyboardInsetNotifier、pinned 稳定捕获、关闭规则或评论列表布局。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShort 从 `develop` 创建 `fix/composer-safearea`。
|
||||||
|
- 提交:`fix(player): 去除评论输入面板安全区导致的收键盘位移`。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
|
||||||
|
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# K87 下拉刷新完成后回弹实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将全局 `AppPullToRefresh` 改为刷新完成前保持下拉高度:松手达到阈值后内容停在 56px,显示“正在刷新”,`onRefresh` 完成后再用 200ms easeOut 回弹到 0。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- 当前下拉位移主要由 `BouncingScrollPhysics` 的系统 overscroll 提供。
|
||||||
|
- 松手后滚动位置立即执行系统回弹,组件只保留一个小指示器。
|
||||||
|
- 刷新期间没有独立的内容保持位移,因此无法维持下拉状态。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- ReelShort `lib/core/widgets/app_pull_to_refresh.dart`
|
||||||
|
- ReelShort `test/core/app_pull_to_refresh_test.dart`
|
||||||
|
- FruitServer `docs/plans/PROGRESS.md`
|
||||||
|
|
||||||
|
## 实现计划
|
||||||
|
|
||||||
|
1. 将 `AppPullToRefresh.physics` 从 Bouncing 改为 Clamping,阻止系统 overscroll 自行产生内容位移。
|
||||||
|
2. 使用 `AnimationController` 和 `Transform.translate` 统一控制内容位移;pull/armed 使用当前拖动距离,refreshing 固定 56,完成后回 0。
|
||||||
|
3. 松手达到阈值后,200ms easeOut 将当前距离过渡到 56;刷新期间忽略滚动通知并通过 `IgnorePointer` 阻止重复下拉。
|
||||||
|
4. `onRefresh` 完成后以 200ms easeOut 回到 0;指示器透明度跟随位移回弹淡出,结束后状态为 idle。
|
||||||
|
5. 检查全部 11 个使用方均传入 `AppPullToRefresh.physics`,保证全局组件行为一致。
|
||||||
|
|
||||||
|
## 测试计划
|
||||||
|
|
||||||
|
- 使用挂起的 `Completer` 模拟刷新未完成。
|
||||||
|
- 松手瞬间断言内容仍高于 hold,200ms 后稳定为 56,再等待 300ms 仍保持 56。
|
||||||
|
- 断言刷新状态显示 `CircularProgressIndicator` 与 `Refreshing…`。
|
||||||
|
- 刷新中再次下拉,断言 `onRefresh` 调用数仍为 1、offset 仍为 56。
|
||||||
|
- 完成 `Completer` 后断言 offset 回到 0、刷新指示器消失。
|
||||||
|
|
||||||
|
## 风险与处理
|
||||||
|
|
||||||
|
- 改为 Clamping 后不再依赖平台 overscroll 提供下拉视觉;内容位移完全由组件控制,避免双倍移动。
|
||||||
|
- 刷新期间阻止列表交互,避免用户手势破坏保持高度。
|
||||||
|
- 组件卸载时动画控制器会取消动画;异步刷新完成后仅在 mounted 时执行回弹收尾。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShort 从 `develop` 创建 `feat/pull-to-refresh-hold`。
|
||||||
|
- 提交:`feat(ui): 下拉刷新完成后才回弹`。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
|
||||||
|
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# K87.1 下拉刷新冻结位移实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
修复刷新 pending 期间仍从松手距离回收到 56px 的视觉回弹。达到阈值后冻结当前下拉距离 D,刷新完成前 offset 不变化,完成后才用 200ms easeOut 回到 0。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- K87 已将系统 physics 改为 Clamping,避免原生 overscroll 回弹叠加。
|
||||||
|
- 但 `_refresh` 仍先执行 `_animateOffsetTo(_holdDistance)`,会把松手时的 D(例如 96)回收到 56。
|
||||||
|
- 用户感知为“页面已经弹回去”,实际是组件自身在刷新开始时主动缩短 offset。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- ReelShort `lib/core/widgets/app_pull_to_refresh.dart`
|
||||||
|
- ReelShort `test/core/app_pull_to_refresh_test.dart`
|
||||||
|
- FruitServer `docs/plans/PROGRESS.md`
|
||||||
|
|
||||||
|
## 实现计划
|
||||||
|
|
||||||
|
1. 保留 `AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics())`,系统不移动 overscroll 内容。
|
||||||
|
2. 删除 `_holdDistance` 与刷新前的 56px 过渡动画。
|
||||||
|
3. 达到阈值松手后立即进入 refreshing,AnimationController 保持当前 D;刷新期间滚动通知和手势均忽略。
|
||||||
|
4. `onRefresh` 完成后再从 D 执行 200ms easeOut 回 0,指示器随 offset 淡出。
|
||||||
|
5. 未达阈值松手时新增 `_retracting` 状态,执行 200ms easeOut 回到 0,不触发 `onRefresh`。
|
||||||
|
|
||||||
|
## 测试计划
|
||||||
|
|
||||||
|
- 达阈值测试:记录松手 offset D,挂起 300ms 后仍等于 D;刷新中重复下拉不改变 D、不增加调用。
|
||||||
|
- complete 后断言 offset 回 0、指示器消失。
|
||||||
|
- 未达阈值测试:松手后立即开始回弹,100ms 时 offset 介于 0 与 D 之间,最终回 0 且 `onRefresh` 未调用。
|
||||||
|
- 断言 `AppPullToRefresh.physics.parent` 为 `ClampingScrollPhysics`。
|
||||||
|
|
||||||
|
## 风险与处理
|
||||||
|
|
||||||
|
- 保持 D 时下拉越高,内容下移越明显,但这是用户松手时的真实位置,刷新完成后才回收。
|
||||||
|
- 未达阈值回弹期间通过 `_retracting` 忽略新滚动通知,避免动画目标被手势覆盖。
|
||||||
|
- 不逐个页面改动,所有使用共享 physics 与组件的页面自动生效。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShort 从 `develop` 创建 `fix/pull-refresh-freeze`。
|
||||||
|
- 提交:`fix(ui): 下拉刷新刷新期间冻结下拉位移`。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
- `--no-ff` 合回 `develop` 并 push,不使用 force push,不执行 `git add -A`。
|
||||||
|
- FruitServer 追加 PROGRESS 留痕;不安装真机,完成后等待用户验收。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# K88 内容类型、互动数据编辑与分类 Feed 实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 后台短剧管理支持内容类型和互动数据编辑:收藏、点赞、热度可编辑,评论数实时统计只读。
|
||||||
|
- 新增 `drama.drama_type`,取值为 `ANIME` / `LIVE`,默认 `LIVE`。
|
||||||
|
- App 新增 `GET /app/feed`,支持 `ANIME,LIVE` 多选类型,不传类型时返回推荐全部。
|
||||||
|
|
||||||
|
## FruitServer A
|
||||||
|
|
||||||
|
1. 数据库
|
||||||
|
- 新增 `sql/migrate-k88-drama-type.sql`,只提交迁移文件,不执行数据库变更。
|
||||||
|
- `sql/schema.sql` 的 drama 建表语句补 `drama_type`,默认 `LIVE`。
|
||||||
|
|
||||||
|
2. 后台管理
|
||||||
|
- `Drama`、`DramaSaveReqVO`、`DramaRespVO` 增加 `dramaType`。
|
||||||
|
- `DramaSaveReqVO` 增加可空 `favoriteCount`、`likeCount`、`heat`;服务层校验非负,`heat` 映射 `view_count`。
|
||||||
|
- create 默认内容类型 LIVE、互动数 0;update 仅覆盖请求非 null 的互动字段。
|
||||||
|
- 后台分页增加 `dramaTypes` 多选过滤,并保持 CommentService 批量回填评论数。
|
||||||
|
|
||||||
|
3. App Feed
|
||||||
|
- `AppDramaCardVO` 增加 `dramaType`,现有 home/discover/detail 仅新增字段。
|
||||||
|
- `GET /app/feed` 支持 `types`、`cursor`、`size`;size 默认 20、最大 50。
|
||||||
|
- 排序为 `recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
|
||||||
|
- cursor 使用最后一条 drama id,服务端反查其 recommendWeight/viewCount/id,再按三元组做 keyset 查询,避免 offset 翻页重漏。
|
||||||
|
- 卡片复用现有 `toCards`,确保 preview、互动数、评论数与首页一致。
|
||||||
|
- 更新 `docs/api-phase1.md` 的 dramaType 与 `/app/feed` 契约。
|
||||||
|
|
||||||
|
4. 测试
|
||||||
|
- `/app/feed`:全部、ANIME、ANIME+LIVE、游标翻页不重不漏。
|
||||||
|
- 后台 update:部分字段更新、heat 写 viewCount、负数与非法 dramaType 报错。
|
||||||
|
|
||||||
|
## ReelShortWeb B
|
||||||
|
|
||||||
|
1. 类型定义补齐 dramaType、favoriteCount、likeCount、commentCount、heat。
|
||||||
|
2. 短剧列表增加内容类型筛选和标签列,播放量列改为热度,增加收藏/点赞/评论列。
|
||||||
|
3. 编辑弹窗增加必填内容类型、互动数据区块;评论数只读。
|
||||||
|
4. 不改审核、发布、删除逻辑。
|
||||||
|
|
||||||
|
## 风险与处理
|
||||||
|
|
||||||
|
- 推荐排序包含可空 recommend_weight,SQL 与 Java 条件必须显式处理 NULLS LAST。
|
||||||
|
- 三字段排序不能只按 id 做 cursor,需以 cursor id 反查上一页最后一条的排序值。
|
||||||
|
- 存量数据不主动修改,迁移默认 LIVE;数据库脚本由 PM 执行。
|
||||||
|
- 全部改动只使用显式文件暂存,不执行 `git add -A`,不改 specs 与 Flutter 客户端。
|
||||||
|
|
||||||
|
## 门禁与 Git
|
||||||
|
|
||||||
|
- FruitServer:`mvn test` 全过;提交 `feat(content): 新增内容类型与首页分类 feed`;`--no-ff` 合回 develop 并 push。
|
||||||
|
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 短剧列表展示并编辑互动数据`;`--no-ff` 合回 develop 并 push。
|
||||||
|
- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自 PROGRESS 追加 K88 留痕。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# K89 点赞/评论按集、收藏/热度按剧实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 收藏、热度继续归属整部剧。
|
||||||
|
- 点赞、评论归属具体剧集:新增 `drama_episode.like_count`,`content_like` 使用 `EPISODE` 目标。
|
||||||
|
- App 卡片、详情、播放响应按当前集装配点赞/评论;后台短剧/剧集页提供对应汇总与只读展示。
|
||||||
|
|
||||||
|
## FruitServer A
|
||||||
|
|
||||||
|
1. 数据库
|
||||||
|
- 新增 `sql/migrate-k89-episode-like.sql`,只提交不执行。
|
||||||
|
- `sql/schema-phase1.sql` 的 drama_episode 增量字段补 like_count,默认 0。
|
||||||
|
|
||||||
|
2. 分集点赞
|
||||||
|
- `DramaEpisode` 增加 likeCount;`EpisodeService` 提供原子增减与批量 SUM。
|
||||||
|
- `LikeService` 增加 `TYPE_EPISODE`,校验已上线剧集的已上线分集,切换时同步分集计数,返回该集最新值。
|
||||||
|
- 保留 DRAMA/COMMENT 点赞兼容。
|
||||||
|
|
||||||
|
3. 分集评论
|
||||||
|
- `CommentService.countByEpisodes` 按 episode_id 批量分组统计未删除评论。
|
||||||
|
- `/app/comments/count` 增加可选 episodeId;传集数返回该集,只有 dramaId 时维持全剧兼容语义。
|
||||||
|
|
||||||
|
4. App 装配
|
||||||
|
- 卡片 preview 集的 likeCount/commentCount 写入卡片;favoriteCount/viewCount 仍取剧。
|
||||||
|
- 详情 episodes[] 每项增加 likeCount/commentCount。
|
||||||
|
- 播放响应增加 episodeId 对应的 likeCount/commentCount。
|
||||||
|
|
||||||
|
5. 后台
|
||||||
|
- EpisodeRespVO 增加本集 like/comment 与所属剧 favorite/heat;Controller.list 批量回填。
|
||||||
|
- EpisodeSaveReqVO 增加可空 likeCount,update 仅覆盖非 null,负数 BAD_REQUEST。
|
||||||
|
- DramaRespVO likeCount 改为分集 SUM,commentCount 保持全剧汇总;分页一次批量聚合避免 N+1。
|
||||||
|
- 更新 API 契约。
|
||||||
|
|
||||||
|
6. 测试
|
||||||
|
- EPISODE 点赞 +1/-1、不越界、目标不存在。
|
||||||
|
- App 卡片按 preview 集、剧级 favorite/heat。
|
||||||
|
- 后台分集列表计数与剧级收藏/热度。
|
||||||
|
- 短剧分页 likeCount 为分集汇总。
|
||||||
|
|
||||||
|
## ReelShortWeb B
|
||||||
|
|
||||||
|
1. EpisodeItem / EpisodeSaveData 增加对应互动字段。
|
||||||
|
2. 剧集表格增加本集点赞、评论,以及所属剧收藏、热度列。
|
||||||
|
3. 编辑弹窗增加本集点赞输入和评论只读;“按集实时统计”。
|
||||||
|
4. 短剧管理保持收藏/热度可编辑,点赞/评论展示为后端只读汇总。
|
||||||
|
|
||||||
|
## 风险与处理
|
||||||
|
|
||||||
|
- 存量 `drama.like_count` 保留不删,不回填分集;新展示以分集 SUM 为准。
|
||||||
|
- App 评论数按 preview 集统计,只有 episodeId 为空的历史评论仍计入全剧汇总。
|
||||||
|
- 评论/点赞接口保持匿名/登录既有安全语义,不新增第三方依赖。
|
||||||
|
- 迁移脚本由 PM 执行;本地门禁不访问真实数据库。
|
||||||
|
|
||||||
|
## 门禁与 Git
|
||||||
|
|
||||||
|
- FruitServer:`mvn test` 全过;提交 `feat(interaction): 点赞与评论按集统计`;`--no-ff` 合回 develop 并 push。
|
||||||
|
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 剧集列表展示分集互动数据`;`--no-ff` 合回 develop 并 push。
|
||||||
|
- 作者统一 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;各自 PROGRESS 追加 K89 留痕。
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# K91 后台观看记录独立菜单、评论分集展示与全部状态筛选
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- FruitServer 新增跨用户观看记录分页接口,并给后台评论补充短剧名、集号与集标题。
|
||||||
|
- ReelShortWeb 新增「观看记录」独立页面,评论/举报状态筛选默认展示全部。
|
||||||
|
- 迁移脚本只提交文件,不执行数据库变更;不改 specs 仓库和 Flutter 客户端。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- FruitServer:`feat/k91-watch-history`,从 `origin/develop` 创建。
|
||||||
|
- ReelShortWeb:`feat/k91-watch-history-page`,从 `origin/develop` 创建。
|
||||||
|
|
||||||
|
## A. FruitServer
|
||||||
|
|
||||||
|
### A1 观看记录分页
|
||||||
|
|
||||||
|
1. 新增 `WatchHistoryQueryReqVO`,字段:
|
||||||
|
`pageIndex/pageSize/keyword/userId/dramaTitle/dramaId/episodeId/source/finished/startTime/endTime`。
|
||||||
|
2. 新增 `AdminWatchHistoryVO`,返回用户、短剧、分集、进度、完成、来源、设备与观看时间。
|
||||||
|
3. 新增 `WatchHistoryAdminService`,直接注入:
|
||||||
|
- `WatchHistoryMapper`
|
||||||
|
- `AppUserMapper`
|
||||||
|
- `DramaMapper`
|
||||||
|
- `DramaEpisodeMapper`
|
||||||
|
4. 查询流程:
|
||||||
|
- `keyword` 先批量命中 `app_user.uid/nickname` 的 id;无命中直接空页。
|
||||||
|
- `dramaTitle` 先批量命中 `drama.title` 的 id;无命中直接空页。
|
||||||
|
- 精确 id 条件与模糊命中集合取交集。
|
||||||
|
- `finished` 在 SQL 层表达,允许区间两侧都落在 `duration_ms = 0` 等边界。
|
||||||
|
- 按 `watched_at DESC, id DESC` 排序。
|
||||||
|
- 对结果页的用户、剧、集各做一次批量查询后装配,禁止逐行查询。
|
||||||
|
5. 新增 `WatchHistoryAdminController`,权限点 `watch-history:list`,保留旧用户详情观看流水接口。
|
||||||
|
|
||||||
|
### A2 评论后台分集信息
|
||||||
|
|
||||||
|
1. `CommentRespVO` 增加 `dramaTitle/episodeNo/episodeTitle`。
|
||||||
|
2. 仅 `CommentService.adminPage` 对当前页批量补剧、集信息。
|
||||||
|
3. `toRespList` 的 C 端路径保持不增加查询。
|
||||||
|
|
||||||
|
### A3 SQL 与契约
|
||||||
|
|
||||||
|
1. 新增幂等迁移 `sql/migrate-k91-watch-history-menu.sql`:
|
||||||
|
- 权限点 `1105 watch-history:list`
|
||||||
|
- 菜单使用 id `34`,挂 parent `15`
|
||||||
|
- `component=views/userManagement/WatchHistoryView`
|
||||||
|
- 超管角色权限与菜单关联
|
||||||
|
2. 同步 `sql/data-phase1.sql`。
|
||||||
|
3. 更新 `docs/api-phase1.md` §2.3、§2.5。
|
||||||
|
|
||||||
|
### A4 测试
|
||||||
|
|
||||||
|
- `WatchHistoryAdminServiceTest` 覆盖:
|
||||||
|
- 默认分页与排序
|
||||||
|
- keyword 用户命中
|
||||||
|
- dramaTitle 剧名命中
|
||||||
|
- finished 真/假 SQL 条件
|
||||||
|
- 时间范围
|
||||||
|
- 命中集合为空直接空页
|
||||||
|
- pageSize 上限收敛为 100
|
||||||
|
- `CommentServiceTest` 覆盖后台页批量补齐剧名、集号、集标题。
|
||||||
|
- 门禁:`mvn test` 全模块通过。
|
||||||
|
|
||||||
|
## B. ReelShortWeb
|
||||||
|
|
||||||
|
### B1 类型/API
|
||||||
|
|
||||||
|
- 扩展 `WatchHistoryItem`:`userId/uid/nickname/episodeTitle`。
|
||||||
|
- 扩展 `CommentItem`:`dramaTitle/episodeNo/episodeTitle`。
|
||||||
|
- `appUser.ts` 新增 `pageWatchHistoryApi(params)`。
|
||||||
|
|
||||||
|
### B2 观看记录页面
|
||||||
|
|
||||||
|
- 新建 `src/views/userManagement/WatchHistoryView.vue`:
|
||||||
|
- 用户、短剧名称、来源、完成状态、时间范围筛选。
|
||||||
|
- 观看时间、用户、短剧、分集、进度、完成、来源、设备列。
|
||||||
|
- 10/20/50 分页。
|
||||||
|
- 复用 App 用户页的 `panel/console-toolbar/pagination/app-user-*` 视觉与格式化函数。
|
||||||
|
|
||||||
|
### B3 评论/举报全部状态
|
||||||
|
|
||||||
|
- 评论与举报状态首项增加 `{ value: '', label: '全部状态', tag: 'info' }`。
|
||||||
|
- 两个筛选默认值改为空串,请求时空串转 `undefined`。
|
||||||
|
- 评论表以「短剧 / 分集」替换短剧 ID 单列:
|
||||||
|
- 显示剧名与集号/集标题。
|
||||||
|
- 缺少剧名时回退短剧 id。
|
||||||
|
- 缺少集号时回退分集 id;历史 `episodeId=0` 显示未关联分集。
|
||||||
|
- 原始短剧 id / 分集 id 通过 tooltip 保留。
|
||||||
|
|
||||||
|
### B4 门禁
|
||||||
|
|
||||||
|
- `pnpm ts:check`
|
||||||
|
- `pnpm build:dev`
|
||||||
|
|
||||||
|
## 风险与待确认
|
||||||
|
|
||||||
|
1. `watch_history` 是月分区表,现有索引为 `(user_id, watched_at)` 与
|
||||||
|
`(drama_id, watched_at)`,没有跨用户的全局 `watched_at` 索引。本次按要求实现跨用户时间排序,
|
||||||
|
不擅自新增索引;数据量增长后由 PM 评估是否增加全局观看时间索引。
|
||||||
|
2. 用户/剧名模糊检索先取 id 集合,符合“命中为空不退化成全表”的要求;
|
||||||
|
超大关键词命中集合的批大小由后续实际数据量评估,本次不擅自加截断以免漏结果。
|
||||||
|
3. 历史评论的 `episode_id=0` 不回填;后台显示“未关联分集”,原始 id 仍保留在 tooltip。
|
||||||
|
4. 迁移脚本只写文件,不执行数据库;菜单落库由 PM 按发布流程执行。
|
||||||
|
5. 任务书建议菜单 id `26`,但当前 `data-phase1.sql` 已将 `26` 用作“创作者中心”目录,
|
||||||
|
`27-30` 为其子菜单。为避免覆盖线上已有菜单树,本次使用下一个空闲 id `34`;
|
||||||
|
父级、路由、组件与权限点均不变。
|
||||||
|
|
||||||
|
## 提交与合并
|
||||||
|
|
||||||
|
- FruitServer 提交:`feat(admin): 新增观看记录分页与评论分集信息`
|
||||||
|
- ReelShortWeb 提交:`feat(admin): 观看记录独立菜单与治理状态全部筛选`
|
||||||
|
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
|
||||||
|
- 作者与提交者:`Zhou Weidong <502333560@qq.com>`。
|
||||||
|
- 禁止 `git add -A`,所有文件逐项暂存。
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# K92 举报工单展示短剧名称与分集
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- `/admin/reports/page` 不再只返回 `targetType / targetId`,而是返回举报人、目标短剧与分集的展示信息。
|
||||||
|
- Web 举报工单列表与处理弹窗使用同一份目标摘要,不再展示裸对象 ID。
|
||||||
|
- 不改既有举报处理逻辑、评论列表接口、观看记录接口或数据库结构。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- FruitServer:`feat/k92-report-target`,从 `origin/develop` 创建。
|
||||||
|
- ReelShortWeb:`feat/k92-report-target-ui`,从 `origin/develop` 创建。
|
||||||
|
|
||||||
|
## A. FruitServer
|
||||||
|
|
||||||
|
### A1 返回模型
|
||||||
|
|
||||||
|
新增 `com.fruitshorts.admin.vo.AdminReportRespVO`,包含:
|
||||||
|
|
||||||
|
- 工单:`id/reasonCode/description/status/handleBy/handleAt/handleNote/createdAt`
|
||||||
|
- 举报人:`reporterId/reporterNickname/reporterUid`
|
||||||
|
- 目标:`targetType/targetId/targetSummary`
|
||||||
|
- 剧集:`dramaId/dramaTitle/episodeId/episodeNo/episodeTitle`
|
||||||
|
- 评论/用户:`commentContent/targetNickname/targetUid`
|
||||||
|
|
||||||
|
### A2 查询和解析
|
||||||
|
|
||||||
|
新增 `com.fruitshorts.admin.service.ReportAdminService`:
|
||||||
|
|
||||||
|
1. 分页查询 `content_report`,条件与 `ReportService.page` 保持一致:
|
||||||
|
`status/targetType/reasonCode/reporterId` 均仅在有文本或非空时加入,空值返回全部;
|
||||||
|
排序保持 `id DESC`。
|
||||||
|
2. 当前页批量查询:
|
||||||
|
- COMMENT 目标评论:一次查询,且必须包含逻辑删除记录,用于判断“评论已删除”。
|
||||||
|
- 评论与 DRAMA 目标剧:一次批量查询 `drama`。
|
||||||
|
- 评论目标分集:一次批量查询 `drama_episode`。
|
||||||
|
- USER 目标用户与全部 reporter:合并 id 后一次批量查询 `app_user`。
|
||||||
|
3. 禁止逐行查询。
|
||||||
|
|
||||||
|
`ContentCommentMapper` 增加显式批量查询方法,绕过 `@TableLogic` 的自动过滤;
|
||||||
|
普通 `selectBatchIds` 仍保持原行为。
|
||||||
|
|
||||||
|
### A3 摘要规则
|
||||||
|
|
||||||
|
- COMMENT:
|
||||||
|
- 正常评论且剧存在:`剧名 · 第 N 集`,有集标题时追加 ` · 集标题`。
|
||||||
|
- 评论删除、逻辑删除、评论不存在或剧不存在:`评论 #targetId`。
|
||||||
|
- DRAMA:
|
||||||
|
- 剧存在:剧名。
|
||||||
|
- 不存在:`短剧 #targetId`。
|
||||||
|
- USER:
|
||||||
|
- 用户存在:`昵称(uid)`。
|
||||||
|
- 不存在:`用户 #targetId`。
|
||||||
|
|
||||||
|
### A4 Controller 与文档
|
||||||
|
|
||||||
|
- `ReportAdminController.page` 改为返回 `Result<PageResult<AdminReportRespVO>>`。
|
||||||
|
- `handle` 继续调用原 `ReportService.handle`,不变更业务行为。
|
||||||
|
- 更新 `docs/api-phase1.md` §2.5。
|
||||||
|
|
||||||
|
### A5 测试
|
||||||
|
|
||||||
|
新增 `ReportAdminServiceTest`,覆盖:
|
||||||
|
|
||||||
|
- COMMENT / DRAMA / USER 三类摘要。
|
||||||
|
- 评论集标题、举报人昵称/uid、目标用户昵称/uid。
|
||||||
|
- 空筛选条件表示全部。
|
||||||
|
- 评论逻辑删除和目标剧不存在时退化文案。
|
||||||
|
- 验证按页批量查询,不产生 N+1。
|
||||||
|
|
||||||
|
门禁:`mvn test` 全模块通过。
|
||||||
|
|
||||||
|
## B. ReelShortWeb
|
||||||
|
|
||||||
|
### B1 类型
|
||||||
|
|
||||||
|
`ReportItem` 补齐后台新增字段,保留原 `targetId` 用于 tooltip 排查。
|
||||||
|
|
||||||
|
### B2 治理页
|
||||||
|
|
||||||
|
1. 「对象」列:
|
||||||
|
- 中文类型标签:评论 / 用户 / 短剧。
|
||||||
|
- 显示 `targetSummary`。
|
||||||
|
- tooltip 显示原始 `#targetId`,评论类型有内容时追加评论内容。
|
||||||
|
2. 删除独立「对象ID」列。
|
||||||
|
3. 「举报人」列显示昵称;无昵称回退 uid;再无回退 `#reporterId`。
|
||||||
|
4. 处理弹窗「工单对象」显示类型标签 + `targetSummary`。
|
||||||
|
|
||||||
|
### B3 门禁
|
||||||
|
|
||||||
|
- `pnpm ts:check`
|
||||||
|
- `pnpm build:dev`
|
||||||
|
- 对改动文件执行定向 ESLint
|
||||||
|
|
||||||
|
## 风险与说明
|
||||||
|
|
||||||
|
1. 评论逻辑删除记录默认被 MyBatis-Plus 过滤;本次使用显式 mapper 查询识别,
|
||||||
|
不改变评论正常查询和删除行为。
|
||||||
|
2. 已删除评论不展示正文,避免后台误读已删除内容;仍保留 `targetId` tooltip 便于排查。
|
||||||
|
3. 目标关联数据已物理删除时按任务书使用 `评论 #id / 短剧 #id / 用户 #id` 退化。
|
||||||
|
4. 本任务无数据库迁移,不改 specs 仓库和 Flutter 客户端。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- FruitServer:`feat(admin): 举报工单返回短剧与分集信息`
|
||||||
|
- ReelShortWeb:`feat(admin): 举报列表展示短剧与分集`
|
||||||
|
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# K93 短剧类型与内容类型支持多选
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 后台短剧基础信息中的「类型」「内容类型」改为至少选一项的多选。
|
||||||
|
- 后台列表筛选、列表展示支持多分类与多内容类型。
|
||||||
|
- App 分类分区和内容类型 feed 使用多标签字段做整词匹配。
|
||||||
|
- 单值字段继续保留,兼容创作者中心、旧接口和现有 App 卡片。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- FruitServer:`feat/k93-drama-multi-tag`,从 `origin/develop` 创建。
|
||||||
|
- ReelShortWeb:`feat/k93-drama-multi-tag-ui`,从 `origin/develop` 创建。
|
||||||
|
|
||||||
|
## A. FruitServer
|
||||||
|
|
||||||
|
### A1 数据库
|
||||||
|
|
||||||
|
- 新增 `sql/migrate-k93-drama-multi-tag.sql`:
|
||||||
|
- `drama.drama_types VARCHAR(32) NOT NULL DEFAULT ''`
|
||||||
|
- `drama.category_ids VARCHAR(255) NOT NULL DEFAULT ''`
|
||||||
|
- 从现有 `drama_type / category_id` 幂等回填。
|
||||||
|
- 同步 `sql/schema.sql` 的 drama 建表字段。
|
||||||
|
- 只提交迁移文件,不执行数据库。
|
||||||
|
|
||||||
|
### A2 实体与 VO
|
||||||
|
|
||||||
|
- `Drama`:增加 `dramaTypes/categoryIds` 逗号字符串。
|
||||||
|
- `DramaSaveReqVO`:
|
||||||
|
- 新增 `List<String> dramaTypes`、`List<Long> categoryIds`,列表优先。
|
||||||
|
- 保留 `dramaType/categoryId` 单值兜底。
|
||||||
|
- `DramaPageReqVO`:增加 `List<Long> categoryIds`,保留 `categoryId`。
|
||||||
|
- `DramaRespVO`:增加 `dramaTypes/categoryIds/categoryNames`,旧单值字段语义不变。
|
||||||
|
|
||||||
|
### A3 保存规则
|
||||||
|
|
||||||
|
- 内容类型列表:normalize、去重、保持提交顺序;主值写第一个,完整列表写 `drama_types`。
|
||||||
|
- 分类列表:过滤 null/非正数、去重、保持提交顺序;主值写第一个或 0,完整列表写 `category_ids`。
|
||||||
|
- 列表为空时回退单值;单值也没有时保留当前值。
|
||||||
|
- 创建时内容类型最终为空则默认 LIVE。
|
||||||
|
- 非法内容类型继续返回 BAD_REQUEST。
|
||||||
|
|
||||||
|
### A4 查询规则
|
||||||
|
|
||||||
|
统一使用整词匹配:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
(',' || column || ',') LIKE '%,value,%'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 后台分页:
|
||||||
|
- `dramaTypes` 非空:`drama_types` token 匹配。
|
||||||
|
- `categoryIds` 非空:`category_ids` token 匹配。
|
||||||
|
- `categoryIds` 为空:回退 `category_id` 等值。
|
||||||
|
- App `listPublishedFeed`:`drama_types` token 匹配。
|
||||||
|
- App feed 游标:marker 的解析后 `dramaTypes` 与任一筛选类型相交才允许继续。
|
||||||
|
- App `listPublished(categoryId)`:`category_ids` token 匹配,使多分类剧出现在每个分类分区。
|
||||||
|
|
||||||
|
### A5 响应装配
|
||||||
|
|
||||||
|
- 解析 `drama_types/category_ids`,空值回退单值。
|
||||||
|
- `categoryNames` 按 categoryIds 顺序映射分类名。
|
||||||
|
- 分类名缺失时回退 `genre`。
|
||||||
|
- 现有 `dramaType/categoryId/category` 保持旧语义。
|
||||||
|
|
||||||
|
### A6 文档与测试
|
||||||
|
|
||||||
|
- 更新 `docs/api-phase1.md` 的后台保存/分页字段、App feed 和分类说明。
|
||||||
|
- 增加服务测试覆盖:
|
||||||
|
- 多类型/多分类去重、顺序与主值写回。
|
||||||
|
- 单值兜底行为不变。
|
||||||
|
- 后台分页 token 条件。
|
||||||
|
- feed 多标签 marker 可被 ANIME/LIVE 命中。
|
||||||
|
- App 分类分区 token 条件。
|
||||||
|
- 响应多值解析和分类名顺序。
|
||||||
|
- 门禁:`mvn test` 全模块通过。
|
||||||
|
|
||||||
|
## B. ReelShortWeb
|
||||||
|
|
||||||
|
### B1 类型
|
||||||
|
|
||||||
|
- `DramaItem` 增加 `dramaTypes/categoryIds/categoryNames`。
|
||||||
|
- `DramaSaveData` 增加 `dramaTypes/categoryIds`。
|
||||||
|
- `DramaPageParams` 增加 `categoryIds`。
|
||||||
|
|
||||||
|
### B2 表单
|
||||||
|
|
||||||
|
- `form.categoryIds: number[]`,默认空数组。
|
||||||
|
- `form.dramaTypes: string[]`,创建默认 `['LIVE']`。
|
||||||
|
- 两个下拉均使用 multiple、collapse-tags、collapse-tags-tooltip、filterable。
|
||||||
|
- 校验至少 1 项。
|
||||||
|
- 编辑时优先数组,缺失回退单值。
|
||||||
|
- 提交数组;`genre` 使用所选分类名以 ` / ` 拼接。
|
||||||
|
|
||||||
|
### B3 筛选与表格
|
||||||
|
|
||||||
|
- 分类筛选改为 `categoryIds: number[]`。
|
||||||
|
- 内容类型筛选改为 `dramaTypes: string[]`。
|
||||||
|
- 查询传数组,重置为空数组。
|
||||||
|
- 类型列显示 `categoryNames.join(' / ')`,回退 `genre`。
|
||||||
|
- 内容类型列逐个渲染标签,缺失时回退 `dramaType`。
|
||||||
|
|
||||||
|
### B4 门禁
|
||||||
|
|
||||||
|
- `pnpm ts:check`
|
||||||
|
- `pnpm build:dev`
|
||||||
|
- 对修改文件执行定向 ESLint
|
||||||
|
|
||||||
|
## 风险与说明
|
||||||
|
|
||||||
|
1. 新代码依赖迁移后的 `drama_types/category_ids` 列,发布顺序必须先执行迁移再启动新版本。
|
||||||
|
2. 逗号列仅存储受控内容类型和数字 id,不允许用户输入;查询值均来自校验后的数组。
|
||||||
|
3. 主值继续保留,降低旧客户端、创作者中心和旧接口的兼容风险。
|
||||||
|
4. 本次不改 specs 与 Flutter 客户端,不执行数据库迁移,不操作真机。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- FruitServer:`feat(content): 短剧类型与内容类型支持多选`
|
||||||
|
- ReelShortWeb:`feat(admin): 短剧类型与内容类型多选`
|
||||||
|
- 两仓库均 `--no-ff` 合回 `develop` 并 push。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# K94 首页内容类型 Feed 与无感切换
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 首页顶栏「漫剧 / 真人剧 / 推荐」接入既有 `/app/feed`。
|
||||||
|
- 每频道独立缓存与分页,切换时先同步展示缓存,再后台静默刷新。
|
||||||
|
- 推荐首屏复用 `/app/home/feed` 打平数据,首次进入不额外转圈。
|
||||||
|
- 无缓存频道只在黑色内容区显示小转圈,顶栏和底栏保持。
|
||||||
|
- 滑动接近末尾自动加载下一页,并在播放器队列中原地追加。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- ReelShort:`feat/k94-home-type-feed`,从 `origin/develop` 创建。
|
||||||
|
- FruitServer 仅文档留痕,不改功能代码。
|
||||||
|
|
||||||
|
## A. 数据层
|
||||||
|
|
||||||
|
### A1 FeedPage
|
||||||
|
|
||||||
|
新增 `lib/core/data/models/feed_page.dart`:
|
||||||
|
|
||||||
|
- `dramas` 使用 `Drama.fromCardJson`。
|
||||||
|
- `nextCursor` 兼容数字/字符串,空值或字符串 `"null"` 转为 null。
|
||||||
|
- `hasMore` 读取布尔值。
|
||||||
|
|
||||||
|
### A2 CatalogApi / Repository
|
||||||
|
|
||||||
|
- `CatalogApi.feed({types, cursor, size})` 请求 `/app/feed`。
|
||||||
|
- types 为空不传;非空用逗号拼接。
|
||||||
|
- cursor 为空不传。
|
||||||
|
- 返回结构异常抛 `ApiException(-1, '返回数据格式异常')`。
|
||||||
|
- `CatalogRepository` 与 HTTP 实现增加 `fetchFeed`。
|
||||||
|
- 所有测试 CatalogRepository 实现同步补齐方法。
|
||||||
|
|
||||||
|
## B. 频道控制器
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
- `lib/pages/home/home_feed_channel.dart`
|
||||||
|
- `lib/core/data/services/home_feed_controller.dart`
|
||||||
|
|
||||||
|
`HomeFeedController` 使用每个频道独立状态:
|
||||||
|
|
||||||
|
- dramas / cursor / hasMore / loaded / loadingMore / requestSeq
|
||||||
|
- 当前 channel,默认 recommend
|
||||||
|
- `seedRecommend`:同步写入推荐首屏,不发请求
|
||||||
|
- `selectChannel`:同步切频道并 notify;无缓存加载第一页,有缓存静默刷新
|
||||||
|
- `refreshCurrent`:失败保留旧列表
|
||||||
|
- `loadMore`:cursor 追加,按 drama.id 去重
|
||||||
|
- 所有响应必须同时满足“仍为当前频道 + requestSeq 最新”才应用
|
||||||
|
- dispose 后停止 notify
|
||||||
|
|
||||||
|
## C. 首页接入
|
||||||
|
|
||||||
|
### C1 顶栏受控
|
||||||
|
|
||||||
|
`HomeFeedHeader`:
|
||||||
|
|
||||||
|
- 删除内部 tab 状态、comingSoon 和 SnackBar。
|
||||||
|
- 新增 `channel` 与 `onChannelSelected`。
|
||||||
|
- 三个 Key 保持不变。
|
||||||
|
- refresh 内部 loading 状态保留。
|
||||||
|
|
||||||
|
### C2 HomePage
|
||||||
|
|
||||||
|
- `_HomePageState` 用 `CatalogController.repository` 创建 HomeFeedController。
|
||||||
|
- Catalog 首次有数据时同步 seed recommend,随后在 post-frame 静默刷新 recommend。
|
||||||
|
- `_HomeTabs` 使用稳定 Key,删除 `ValueKey(catalog.dramas.first.id)`。
|
||||||
|
- 当前频道无数据时,保留 `_HomeTabs` 顶栏/底栏,body 用黑色背景 + 居中
|
||||||
|
`home-feed-channel-loading`。
|
||||||
|
- 当前频道有数据时把频道列表传给 PlayerPage。
|
||||||
|
- 刷新回调改 `refreshCurrent`;分页回调改 `loadMore`。
|
||||||
|
|
||||||
|
### C3 PlayerPage / PlayerLogic
|
||||||
|
|
||||||
|
- PlayerPage 增加 `feedChannel` 和 `onLoadMore`。
|
||||||
|
- 同频道 feed 列表变化:`updateFeed(..., keepDramaId: currentDramaId)`。
|
||||||
|
- channel 变化:`updateFeed(..., keepDramaId: null)`,回到新频道第 1 个。
|
||||||
|
- PlayerLogic 保存 factory/coordinator,支持需要换内容时替换 PlaybackSession。
|
||||||
|
- 空列表清空 queue/visibleIndices 并停止旧播放。
|
||||||
|
- 当前剧仍在新列表且索引不变时,只扩展队列与 episode 列表,不重新 select。
|
||||||
|
- PageView 在 `position >= visibleIndices.length - 3` 时调用 onLoadMore。
|
||||||
|
|
||||||
|
## D. 测试
|
||||||
|
|
||||||
|
新增 `test/core/data/services/home_feed_controller_test.dart`:
|
||||||
|
|
||||||
|
- recommendation seed 不发请求
|
||||||
|
- 三频道请求 types 分别为 `[] / [ANIME] / [LIVE]`
|
||||||
|
- 缓存频道切换不清空并后台刷新
|
||||||
|
- loadMore 追加去重、无更多后不再请求
|
||||||
|
- 串包只接受最新频道请求
|
||||||
|
- refresh 失败保留旧数据
|
||||||
|
|
||||||
|
新增顶栏 widget 测试:
|
||||||
|
|
||||||
|
- 三个 Key 存在
|
||||||
|
- 点击分别回调 anime/live/recommend
|
||||||
|
- 当前 channel 决定选中样式
|
||||||
|
|
||||||
|
PlayerLogic 测试补充:
|
||||||
|
|
||||||
|
- updateFeed 空列表安全
|
||||||
|
- 追加列表保持当前剧
|
||||||
|
- 切频道列表回到第 1 个
|
||||||
|
|
||||||
|
门禁:
|
||||||
|
|
||||||
|
- `dart analyze lib test`
|
||||||
|
- `flutter test --no-pub`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
|
||||||
|
## 风险与控制
|
||||||
|
|
||||||
|
1. PlaybackSession 的 episodes 是构造时绑定的列表;分页原地追加时会同步扩展该列表,
|
||||||
|
避免仅换 queue 而 session 仍指向旧列表。
|
||||||
|
2. 换频道可能释放并重建播放器 session,这是内容源变化;静默刷新/分页在顺序不变时
|
||||||
|
不重新 select 当前剧,避免视频重启。
|
||||||
|
3. 推荐 seed 只保证首帧立即有内容;后台 `/app/feed` 刷新失败时保留 seed。
|
||||||
|
4. 首页仍在 `/app/home/feed` 首次加载期间使用既有启动页;频道切换不再触发整页 splash。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- 提交:`feat(home): 首页按内容类型分频道 Feed 并支持分页`
|
||||||
|
- `--no-ff` 合回 ReelShort `develop` 并 push。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# K95 修复首页频道快速切换卡 Loading
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 频道请求完成后始终写回发起请求的频道缓存,不再因用户切走而丢失结果。
|
||||||
|
- 每频道明确区分 loaded / loading / in-flight / error,无请求时绝不显示首屏转圈。
|
||||||
|
- 首屏和刷新请求增加可注入的 10 秒超时兜底。
|
||||||
|
- PlayerLogic 保证连续 feed 更新串行应用,只让最后一次 feed/session 生效。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- ReelShort:`fix/k95-feed-switch-loading`,从 `origin/develop` 创建。
|
||||||
|
- FruitServer 仅提交计划与 PROGRESS,不改功能代码。
|
||||||
|
|
||||||
|
## A. HomeFeedController
|
||||||
|
|
||||||
|
每个频道状态增加:
|
||||||
|
|
||||||
|
- `List<Drama> dramas`
|
||||||
|
- `String? cursor`
|
||||||
|
- `bool hasMore`
|
||||||
|
- `bool loaded`
|
||||||
|
- `bool loading`
|
||||||
|
- `bool loadingMore`
|
||||||
|
- `String? lastError`
|
||||||
|
- `int requestSeq`
|
||||||
|
- `Future<bool>? inFlight`
|
||||||
|
|
||||||
|
请求规则:
|
||||||
|
|
||||||
|
1. 响应成功/失败都写入发起请求的频道状态。
|
||||||
|
2. 只有频道仍为当前频道时才 `notifyListeners`。
|
||||||
|
3. `requestSeq` 只比较同一频道的新旧请求,不参与跨频道过滤。
|
||||||
|
4. 超时时间通过构造参数注入,默认 10 秒。
|
||||||
|
5. `loadingFirstPage = !loaded && loading`。
|
||||||
|
6. `selectChannel`:
|
||||||
|
- 有缓存:同步显示,未在飞时后台静默刷新
|
||||||
|
- 无缓存且正在飞:等待现有请求,不重复发
|
||||||
|
- 无缓存且有历史错误:自动重试
|
||||||
|
- 从未加载:发起首屏请求
|
||||||
|
7. `refreshCurrent` 同样复用 in-flight,防止并发重复刷新。
|
||||||
|
8. `loadMore` 失败保留 cursor/hasMore,允许下次继续重试。
|
||||||
|
|
||||||
|
## B. PlayerLogic / PlayerPage
|
||||||
|
|
||||||
|
### B1 Feed revision
|
||||||
|
|
||||||
|
- PlayerLogic 增加 `int feedRevision`。
|
||||||
|
- 每次 `updateFeed` 自增 revision,记录最新目标 feed。
|
||||||
|
- `_replaceSession` 与 `selectEpisode` 在 await 后校验 revision,过期流程直接返回。
|
||||||
|
- 连续 updateFeed 使用串行队列:当前应用结束后再应用最新待处理 feed。
|
||||||
|
|
||||||
|
### B2 空队列与频道切换
|
||||||
|
|
||||||
|
- 空 feed 不创建新 session,只清空 queue/visibleIndices 并停播。
|
||||||
|
- 空 → 非空必须恢复 route visible,并 select 第 0 个。
|
||||||
|
- 频道变化通过额外 reset 标志传入 `updateFeed`,即使 drama id 列表相同,
|
||||||
|
也重置到第 1 个并开始播放。
|
||||||
|
|
||||||
|
### B3 PlayerPage 更新判定
|
||||||
|
|
||||||
|
- `didUpdateWidget` 用 `feedDramas` 的 drama id 列表签名判断内容变化。
|
||||||
|
- 同一列表的新 List 实例不重复 updateFeed。
|
||||||
|
- 频道变化始终触发 updateFeed,并传 `resetToFirst=true`。
|
||||||
|
|
||||||
|
## C. 测试
|
||||||
|
|
||||||
|
### HomeFeedController
|
||||||
|
|
||||||
|
- A 挂起 → B 成功 → A 成功:A/B loaded 均为 true,切回 A 立即有数据。
|
||||||
|
- 同频道旧失败响应不得覆盖新成功响应。
|
||||||
|
- 无在飞请求时 `loadingFirstPage=false`。
|
||||||
|
- 永不返回的请求在注入超时后结束 loading、写 lastError、loaded=true。
|
||||||
|
- 失败频道再次 select 自动重试。
|
||||||
|
- loadMore 失败保留原有 hasMore/cursor。
|
||||||
|
|
||||||
|
### PlayerLogic
|
||||||
|
|
||||||
|
- 连续 5 次 updateFeed,最终 queue/session 对应最后一次列表。
|
||||||
|
- 空 → 非空会创建 session 并 select 第 0 个。
|
||||||
|
- 频道变化时相同 id 列表也回到第 1 个。
|
||||||
|
|
||||||
|
门禁:
|
||||||
|
|
||||||
|
- `dart analyze lib test`
|
||||||
|
- `flutter test --no-pub`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
|
||||||
|
## 风险与控制
|
||||||
|
|
||||||
|
1. 跨频道响应写回缓存必须与同频道 seq 校验同时存在;前者解决缓存丢失,后者解决同频道旧请求覆盖新结果。
|
||||||
|
2. Player session 替换以 revision 作为唯一有效性判断,避免旧 close/select 回调干扰新 session。
|
||||||
|
3. 空队列期间不创建 PlaybackSession;恢复非空时重新创建并起播。
|
||||||
|
4. 不改后端、ReelShortWeb、specs;不安装或操作真机。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- 提交:`fix(home): 修复频道快速切换卡 loading`
|
||||||
|
- `--no-ff` 合回 ReelShort `develop` 并 push。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# K95.1 首次切换频道无感显示
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 推荐首屏成功后后台预取 anime/live,避免第一次切换出现阻断式 loading。
|
||||||
|
- 目标频道尚未返回时保留上一频道 PlayerPage 和视频画面,只叠加轻量 loading 遮罩。
|
||||||
|
- 数据到达后原子替换为新频道 feed,并从第 1 个播放。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- ReelShort:`fix/k95.1-seamless-first-switch`,从已包含 K95 的 `origin/develop` 创建。
|
||||||
|
- FruitServer 仅文档留痕,不改功能代码。
|
||||||
|
|
||||||
|
## A. HomeFeedController
|
||||||
|
|
||||||
|
新增 `Future<void> prefetchChannels()`:
|
||||||
|
|
||||||
|
- 仅处理 anime/live。
|
||||||
|
- 只对 `!loaded && !loading` 的频道请求。
|
||||||
|
- 请求结果写回对应频道缓存。
|
||||||
|
- 不切换当前频道、不改变当前列表;仅目标恰为当前频道时 notify。
|
||||||
|
- 失败只写 lastError,不向外抛。
|
||||||
|
- 成功预取设置一次性 `skipNextRefresh`:首次切到该频道直接使用缓存,不立刻重复静默刷新;
|
||||||
|
后续再次切回仍执行正常缓存静默刷新。
|
||||||
|
- 预取期间用户切换到该频道时等待已有 inFlight,不重复请求。
|
||||||
|
|
||||||
|
## B. HomePage / _HomeTabs
|
||||||
|
|
||||||
|
### B1 最近可见 feed
|
||||||
|
|
||||||
|
`_HomeTabsState` 维护:
|
||||||
|
|
||||||
|
- `List<Drama>? _visibleDramas`
|
||||||
|
- `HomeFeedChannel? _visibleChannel`
|
||||||
|
|
||||||
|
当前频道有数据时更新这两个值,并以当前频道渲染 PlayerPage。
|
||||||
|
|
||||||
|
### B2 首次切换
|
||||||
|
|
||||||
|
当前频道无数据且 loading:
|
||||||
|
|
||||||
|
- 继续复用上一频道 `_visibleDramas/_visibleChannel` 渲染同一个 PlayerPage。
|
||||||
|
- header 使用当前选中频道,但 PlayerPage.feedChannel 仍使用可见旧频道,避免队列更新。
|
||||||
|
- Stack 上叠加 `IgnorePointer + ColoredBox(black26) + 24px spinner`,
|
||||||
|
Key 为 `home-feed-channel-loading`。
|
||||||
|
- 数据返回后页面树保持同一层级的 PlayerPage,传入新 feed/channel,触发原子更新和第 1 个起播。
|
||||||
|
- 空结果/失败后才切到「暂无剧目 / 重试」占位。
|
||||||
|
|
||||||
|
### B3 Prefetch 触发
|
||||||
|
|
||||||
|
推荐首屏后台刷新成功后 `unawaited(prefetchChannels())`。
|
||||||
|
|
||||||
|
## C. 测试
|
||||||
|
|
||||||
|
HomeFeedController:
|
||||||
|
|
||||||
|
- prefetch 后首次 select anime 不再发请求。
|
||||||
|
- prefetch 期间选择 anime 不重复请求。
|
||||||
|
- prefetch 失败不影响推荐,也不向外抛。
|
||||||
|
|
||||||
|
Widget:
|
||||||
|
|
||||||
|
- 首切未缓存频道时 PlayerPage 仍在树中且出现 loading 遮罩。
|
||||||
|
- 数据返回后遮罩消失并使用新频道 feed。
|
||||||
|
|
||||||
|
门禁:
|
||||||
|
|
||||||
|
- `dart analyze lib test`
|
||||||
|
- `flutter test --no-pub`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
|
||||||
|
## 风险与控制
|
||||||
|
|
||||||
|
1. 预取完成与 select 同时发生时,select 等待同一 inFlight,避免重复请求。
|
||||||
|
2. 加载遮罩 IgnorePointer,不阻断顶栏/底栏交互。
|
||||||
|
3. PlayerPage 在正常和 loading 状态都放在相同 Stack 层级,保证 State 不因分支切换被销毁。
|
||||||
|
4. 不改后端、ReelShortWeb、specs;不安装或操作真机。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- 提交:`fix(home): 首切频道预取并保留播放画面`
|
||||||
|
- `--no-ff` 合回 ReelShort `develop` 并 push。
|
||||||
|
- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# K95.2 首切频道用封面过渡
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 首页频道切换后立即显示目标短剧封面,视频首帧就绪后再淡入。
|
||||||
|
- 首页模式视频初始化期间不再显示 `player-session-loading` 大转圈。
|
||||||
|
- 非首页播放器及票据加载状态保持现有行为。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- ReelShort:`fix/k95.2-poster-transition`,从最新 `origin/develop` 创建。
|
||||||
|
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
|
||||||
|
|
||||||
|
## A. PlayerVideoSurface
|
||||||
|
|
||||||
|
- 新增可选参数 `posterUrl = ''`。
|
||||||
|
- 未 reveal 时:
|
||||||
|
- `posterUrl` 非空显示 `RemoteImage(path: posterUrl, fit: BoxFit.cover)`,
|
||||||
|
Key 为 `player-video-poster`。
|
||||||
|
- `posterUrl` 为空继续显示黑色 `ColoredBox`,保留
|
||||||
|
`player-fallback-background` Key。
|
||||||
|
- 视频 reveal 后继续通过现有 200ms `AnimatedOpacity` 淡入覆盖封面。
|
||||||
|
|
||||||
|
## B. PlayerPage
|
||||||
|
|
||||||
|
- `PlayerVideoSurface` 传入当前 `drama.posterUrl`。
|
||||||
|
- `player-session-loading` 条件增加 `!widget.homeMode`:
|
||||||
|
- 首页模式视频初始化期间只显示封面。
|
||||||
|
- 非首页播放器继续显示原 session loading。
|
||||||
|
- 首页 `player-ticket-loading` 不变。
|
||||||
|
|
||||||
|
## C. 测试
|
||||||
|
|
||||||
|
- 视频表面:非空 poster 显示 `player-video-poster`,空 poster 保留黑底且无封面。
|
||||||
|
- 首页模式:视频未就绪时不出现 `player-session-loading`。
|
||||||
|
- 非首页模式:视频未就绪时仍出现 `player-session-loading`。
|
||||||
|
- 复跑既有播放器、首页相关测试。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
- `dart analyze lib test`
|
||||||
|
- `flutter test`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShort 提交:`fix(home): 频道切换用封面过渡`
|
||||||
|
- `--no-ff` 合回 `develop` 并 push。
|
||||||
|
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 全程中文;不改后端、ReelShortWeb、specs。
|
||||||
|
- 不安装或操作真机;完成后停住等待验收。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# K96 剧场页服务端分类分页
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- `/app/feed` 支持按分类过滤,并与内容类型过滤叠加。
|
||||||
|
- 剧场页「全部」展示全部已上线短剧,任意分类展示该分类全部已上线短剧。
|
||||||
|
- 列表使用游标分页,切分类优先命中缓存,支持静默刷新、加载更多和失败重试。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- FruitServer:`feat/k96-feed-category`
|
||||||
|
- ReelShort:`feat/k96-theater-feed`
|
||||||
|
|
||||||
|
## A. FruitServer
|
||||||
|
|
||||||
|
1. `AppCatalogController.feed` 增加可选 `Long categoryId` 并传给服务层。
|
||||||
|
2. `AppCatalogService.feed` 增加 `categoryId`,传给
|
||||||
|
`DramaService.listPublishedFeed`。
|
||||||
|
3. `DramaService.listPublishedFeed` 增加 `categoryId`:
|
||||||
|
- 非空时使用 `DramaMultiTagSupport.applyCategoryIdTokens`
|
||||||
|
按 `category_ids` 整词匹配。
|
||||||
|
- 游标 marker 必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
|
||||||
|
4. 补充服务层、控制器契约测试:
|
||||||
|
- 仅 categoryId。
|
||||||
|
- categoryId + types。
|
||||||
|
- 分类游标翻页。
|
||||||
|
- marker 分类不命中返回空。
|
||||||
|
5. 更新 `docs/api-phase1.md` 的 `/app/feed` 参数和分类过滤说明。
|
||||||
|
|
||||||
|
门禁:`mvn test` 全模块通过。
|
||||||
|
|
||||||
|
## B. ReelShort
|
||||||
|
|
||||||
|
### B1. API 与仓库
|
||||||
|
|
||||||
|
- `CatalogApi.feed` 增加可选 `String? categoryId`,非空才拼 query。
|
||||||
|
- `CatalogRepository.fetchFeed` 同步参数,HTTP 实现透传。
|
||||||
|
|
||||||
|
### B2. TheaterFeedController
|
||||||
|
|
||||||
|
- 新增 `lib/core/data/services/theater_feed_controller.dart`。
|
||||||
|
- 按 `String? categoryId` 独立缓存状态:
|
||||||
|
`dramas / cursor / hasMore / loaded / loading / loadingMore / lastError /
|
||||||
|
requestSeq / inFlight`。
|
||||||
|
- `select(id)` 同步切分类;有数据先渲染并静默刷新,无数据时加载。
|
||||||
|
- `loadMore` 游标追加并按 id 去重;`hasMore=false` 停止。
|
||||||
|
- `refreshCurrent` 失败保留旧数据;切换回失败分类自动重试。
|
||||||
|
- 单请求 10 秒超时,响应写回发起分类,requestSeq 仅处理同分类顺序。
|
||||||
|
|
||||||
|
### B3. 剧场接入
|
||||||
|
|
||||||
|
- `DiscoverPage` 创建并持有 TheaterFeedController,初始请求「全部」。
|
||||||
|
- 「全部」和分类 chips 的网格统一读取 `theater.dramas`,不再使用
|
||||||
|
`categories.feed.dramas` / `dramasOf`。
|
||||||
|
- `_selectCategory` 同时更新 `DiscoverLogic.selectedCategoryId` 和
|
||||||
|
`theater.select(categoryId)`。
|
||||||
|
- 滚动剩余不足 400px 时触发 `loadMore`;搜索模式不触发剧场分页。
|
||||||
|
- 下拉刷新并行执行 `categories.load()` 和 `theater.refreshCurrent()`。
|
||||||
|
- 无缓存首屏显示转圈;空、失败和重试保持既有剧场视觉。
|
||||||
|
|
||||||
|
### B4. 测试
|
||||||
|
|
||||||
|
- TheaterFeedController:
|
||||||
|
- 全部使用空 categoryId,分类使用对应 id。
|
||||||
|
- 缓存切回不闪烁。
|
||||||
|
- 分页去重、hasMore=false 停止。
|
||||||
|
- 失败保留旧数据、失败分类切回自动重试。
|
||||||
|
- 剧场 widget:
|
||||||
|
- 点击分类请求对应 categoryId。
|
||||||
|
- 接近底部触发下一页。
|
||||||
|
- 搜索、下拉刷新与既有布局不回归。
|
||||||
|
|
||||||
|
门禁:`dart analyze lib test` 零告警、`flutter test` 全过、
|
||||||
|
`flutter build apk --debug` 成功。
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- FruitServer 提交:`feat(app): 推荐流支持分类过滤`
|
||||||
|
- ReelShort 提交:`feat(theater): 剧场分类列表服务端分页`
|
||||||
|
- 两仓库作者:`Zhou Weidong <502333560@qq.com>`,禁止 `git add -A`。
|
||||||
|
- 各自 `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 全程中文;不改 specs、ReelShortWeb,不改首页 K94/K95 已交付逻辑。
|
||||||
|
- 不安装或操作真机;完成后停住等待验收。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# K97 修复 iOS 评论输入弹窗键盘避让
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- iOS 评论输入浮层实时跟随 `MediaQuery.viewInsets.bottom`,确保输入框与发表按钮
|
||||||
|
位于键盘上方。
|
||||||
|
- Android 保留既有 MethodChannel + 80ms 防抖 pinned 策略,不恢复底部 SafeArea,
|
||||||
|
避免键盘收起时 48px 抖动回归。
|
||||||
|
- 聚焦、`TextInput.show` 兜底和发表成功提示逻辑不变。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- ReelShort:`fix/k97-ios-comment-keyboard`,从最新 `origin/develop` 创建。
|
||||||
|
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
|
||||||
|
|
||||||
|
## A. 平台区分
|
||||||
|
|
||||||
|
- `_isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android`。
|
||||||
|
- Android:
|
||||||
|
- 继续监听 `KeyboardInsetNotifier`。
|
||||||
|
- 继续使用 80ms 防抖后的 `_pinnedKeyboardInset`。
|
||||||
|
- iOS / 其他平台:
|
||||||
|
- 不读取 pinned/channel 值。
|
||||||
|
- `build` 中直接读取 `MediaQuery.viewInsetsOf(context).bottom`。
|
||||||
|
- inset 超过屏幕高度 60% 时按 0 处理,负值归零。
|
||||||
|
|
||||||
|
## B. 面板布局
|
||||||
|
|
||||||
|
- 面板主体使用 `AnimatedPadding`,只设置 bottom:
|
||||||
|
- `duration: 160ms`
|
||||||
|
- `curve: Curves.easeOutCubic`
|
||||||
|
- Android 使用 pinned 值。
|
||||||
|
- iOS / 其他平台使用实时 viewInsets。
|
||||||
|
- Material 仍铺到屏幕底部,用于键盘收起后的实色填充。
|
||||||
|
- Android 不包裹 SafeArea。
|
||||||
|
- iOS / 其他平台包裹 `SafeArea(top: false)`,保留 home indicator 底部避让。
|
||||||
|
|
||||||
|
## C. 测试
|
||||||
|
|
||||||
|
- iOS + `viewInsets.bottom=300`:
|
||||||
|
- 键盘避让 AnimatedPadding 的 bottom 为 300。
|
||||||
|
- `comment-composer-post` 底边位于屏幕高度减 300 之上。
|
||||||
|
- iOS + `viewInsets=0`:padding 为 0。
|
||||||
|
- Android 既有 pinned/channel、焦点、弹出、关闭、发布用例保持通过。
|
||||||
|
- 所有平台 override 测试结束恢复
|
||||||
|
`debugDefaultTargetPlatformOverride = null`。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
- `dart analyze lib test`
|
||||||
|
- `flutter test`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
- `flutter build ios --simulator --no-codesign`
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShort 提交:`fix(comment): 修复 iOS 评论输入弹窗键盘避让`
|
||||||
|
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
|
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 全程中文;不改后端、ReelShortWeb、specs。
|
||||||
|
- 不安装或操作真机;完成后停住等待验收。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# K98 修复登录与手机号登录键盘遮挡
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 登录/注册页在邮箱或密码框聚焦后,自动滚动到键盘上方。
|
||||||
|
- 手机号登录弹窗的手机号/验证码框同样保持可见。
|
||||||
|
- Android `adjustNothing` 下通过既有 MethodChannel 获取键盘高度,iOS 使用实时
|
||||||
|
`MediaQuery.viewInsets`。
|
||||||
|
|
||||||
|
## 分支
|
||||||
|
|
||||||
|
- ReelShort:`fix/k98-auth-keyboard`,从最新 `origin/develop` 创建。
|
||||||
|
- FruitServer 仅写计划与 PROGRESS 留痕,不改后端功能代码。
|
||||||
|
|
||||||
|
## A. 有效键盘高度
|
||||||
|
|
||||||
|
在 `keyboard_inset.dart` 增加:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
double effectiveKeyboardInset(
|
||||||
|
BuildContext context,
|
||||||
|
KeyboardInsetNotifier notifier, {
|
||||||
|
double maxRatio = 0.6,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- 取 MediaQuery viewInsets 与 Android channel 逻辑像素值的最大值。
|
||||||
|
- Web/非 Android 不读取 channel。
|
||||||
|
- 超过屏幕高度 60% 时按 0 处理,负值/非法值归零。
|
||||||
|
|
||||||
|
## B. AuthPage
|
||||||
|
|
||||||
|
1. `initState` 启动 `KeyboardInsetNotifier`,监听并用 setState 刷新。
|
||||||
|
2. `SingleChildScrollView` 增加 `ScrollController`。
|
||||||
|
3. 底部 padding 使用固定底部留白 + effectiveKeyboardInset。
|
||||||
|
4. 邮箱/密码 FocusNode 监听焦点;聚焦或键盘高度变化后:
|
||||||
|
- 通过 FocusNode.context 的 RenderBox 获取字段全局底边。
|
||||||
|
- 与屏幕高度 - 键盘高度 - 24 比较。
|
||||||
|
- 被遮挡时用 200ms easeOutCubic animateTo 补足滚动。
|
||||||
|
5. `resizeToAvoidBottomInset` 保持 false。
|
||||||
|
|
||||||
|
## C. PhoneAuthSheet
|
||||||
|
|
||||||
|
1. 接入 KeyboardInsetNotifier 和 ScrollController。
|
||||||
|
2. 底部 padding 改用 effectiveKeyboardInset。
|
||||||
|
3. 手机号/验证码 FocusNode 增加与 AuthPage 相同的焦点/键盘变化滚动校正。
|
||||||
|
4. 保持现有 SingleChildScrollView、弹窗和返回键行为。
|
||||||
|
|
||||||
|
## D. 测试
|
||||||
|
|
||||||
|
- iOS viewInsets=300:登录密码框、注册密码框均在键盘上方。
|
||||||
|
- Android platform + MethodChannel 键盘高度:密码框仍在键盘上方。
|
||||||
|
- iOS:手机号弹窗验证码框在键盘上方。
|
||||||
|
- 所有平台 override 测试返回前显式恢复
|
||||||
|
`debugDefaultTargetPlatformOverride = null`。
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
- `dart analyze lib test`
|
||||||
|
- `flutter test`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShort 提交:`fix(auth): 登录注册与手机号登录键盘避让`
|
||||||
|
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。
|
||||||
|
- `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 全程中文;不改后端、ReelShortWeb、specs。
|
||||||
|
- 不改评论输入框已验证的 Android pinned 策略。
|
||||||
|
- 不安装或操作真机;完成后停住等待验收。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 2026-10-21 | 短剧保存免费集数同步分集
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
后台短剧编辑改 `freeEpisodes` / `unlockCoin` 后,同步 `drama_episode.is_free` / `unlock_coin`,与定价页 `PriceService.save()` 规则一致。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 抽出 `EpisodePriceSync.syncByDramaRule(dramaId, freeEpisodes, unlockCoin)`,供 `DramaService.update` 与 `PriceService.save` 共用。
|
||||||
|
2. `DramaService.update` 在 `updateById` 后,仅当免费集数或解锁金币相对更新前有变化时调用同步。
|
||||||
|
3. TDD:先写 `DramaServiceTest` 复现失败,再改实现转绿。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 仅 `fruitshorts-module-content`
|
||||||
|
- 不改 Controller / VO / 契约 / 前端
|
||||||
|
|
||||||
|
## 门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn -pl fruitshorts-module-content test -Dtest=DramaServiceTest
|
||||||
|
mvn -pl fruitshorts-module-content test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分支 / 提交
|
||||||
|
|
||||||
|
- `feature/drama-free-episodes-sync` ← `develop`
|
||||||
|
- `test(content): 覆盖短剧免费集数同步分集价格`
|
||||||
|
- `fix(content): 短剧保存免费集数时同步分集免费状态`
|
||||||
|
- `merge --no-ff` 回 develop 并 push
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 2026-10-21 | 短剧保存始终同步分集价格(幂等纠偏)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
承接 K104。短剧 `free_episodes=9` 重存后,分集 5~9 仍收费。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
`DramaService.update()` 仅在 `freeEpisodes` / `unlockCoin` 相对更新前有变化时调用 `EpisodePriceSync`。历史数据已漂移时,同值重存不触发同步。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 测试:覆盖「规则字段未变、分集已漂移」仍纠正。
|
||||||
|
2. `DramaService.update()`:每次保存都调用 `EpisodePriceSync.syncByDramaRule(drama)`。
|
||||||
|
3. `EpisodePriceSync`:仅对有差异的行走 `updateById`(已具备,保持)。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
只改 `fruitshorts-module-content` 的 `DramaService` / `EpisodePriceSync` / `DramaServiceTest`。不改 Controller / VO / 契约 / 前端。
|
||||||
|
|
||||||
|
## 提交
|
||||||
|
|
||||||
|
1. `test(content): 覆盖免费集数未变但分集漂移的修复场景`
|
||||||
|
2. `fix(content): 短剧保存时始终同步分集价格`
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 客户端收藏与观看历史分页
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- `/app/favorites` 改为 `PageResult<AppDramaCardVO>`,默认第 1 页、每页 20,最大 100。
|
||||||
|
- `/app/watch/recent` 默认返回 `PageResult<ProgressRespVO>`;请求带 `limit` 时保留一版旧
|
||||||
|
`List<ProgressRespVO>` 响应。
|
||||||
|
- 观看历史补充短剧标题、封面、集号和总集数,剧/集均批量查询。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 收藏关系使用 `selectPage`,按 `created_at DESC, drama_id DESC` 稳定排序;
|
||||||
|
只统计已上线短剧收藏,再按当前页 ID 批量加载短剧。
|
||||||
|
2. 观看进度使用 `selectPage`,按 `updated_at DESC, drama_id DESC` 稳定排序。
|
||||||
|
3. 当前页的 dramaId / episodeId 去重排序后,分别执行一次 IN 查询并装配展示字段。
|
||||||
|
4. Controller 归一化 pageIndex/pageSize;观看历史根据是否传 `limit` 分流新旧响应。
|
||||||
|
5. 更新 API 契约 §1.5,补 service/controller 测试。
|
||||||
|
|
||||||
|
## 风险与约束
|
||||||
|
|
||||||
|
- `limit` 分支仅用于旧客户端兼容,新分页响应与旧 List 响应不能混用。
|
||||||
|
- 已下线短剧不计入收藏 total,也不出现在收藏页。
|
||||||
|
- 观看历史展示字段找不到对应剧或集时保持 null,不影响历史记录返回。
|
||||||
|
- 不修改客户端、不改表结构、不启停服务、不操作真机。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 转码产物查询接口
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 媒体维度查询 rendition 与帧图产物。
|
||||||
|
- 分集维度聚合转码任务、rendition、帧图状态。
|
||||||
|
- `/admin/dashboard/transcode` 改为明确 VO,OpenAPI 不再出现匿名 Object。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
1. `MediaTranscodeOutputService` 统一读取媒体、任务、rendition 与 thumbnail。
|
||||||
|
2. rendition 按高度、视频码率、质量倒序,id 正序保证稳定。
|
||||||
|
3. thumbnail 优先读取 `thumbnails.vtt`,解析 cue 后分页;无记录返回 `EMPTY`,
|
||||||
|
记录失败返回 `FAILED`,不把缺产物当服务错误。
|
||||||
|
4. 分集聚合对不存在、无视频、无媒资、无任务返回明确 status 和空列表。
|
||||||
|
5. dashboard 使用强类型 queue/failed task VO,保留现有字段语义并补 total。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
- `GET /admin/media/{id}/renditions`
|
||||||
|
- `GET /admin/media/{id}/thumbnails?pageIndex=1&pageSize=100`
|
||||||
|
- `GET /admin/episodes/{id}/transcode`
|
||||||
|
- `GET /admin/dashboard/transcode`(响应类型由 Map 收敛为 VO)
|
||||||
|
|
||||||
|
缩略图响应内嵌 `PageResult<MediaThumbnailFrameVO>`,同时返回 sprite/VTT 元数据;
|
||||||
|
空产物 status 为 `EMPTY`,无绑定分集为 `NO_EPISODE`,损坏 VTT 为 `INVALID`。
|
||||||
|
|
||||||
|
## TDD
|
||||||
|
|
||||||
|
- 先提交媒体有/无产物、不存在、VTT 解析与分页边界测试。
|
||||||
|
- 再提交接口、VO、聚合服务和 dashboard 类型收敛实现。
|
||||||
|
- 门禁:`mvn -pl fruitshorts-module-media test`。
|
||||||
|
|
||||||
|
## 红线
|
||||||
|
|
||||||
|
- 不改 `/app` 契约、specs、ReelShortWeb、Flutter。
|
||||||
|
- 不改 P1 入队与手动重转逻辑。
|
||||||
|
- 不启停服务,不操作真机。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 后台转码链路打通
|
||||||
|
|
||||||
|
## 现状取证
|
||||||
|
|
||||||
|
- 上传完成时 `MediaService.complete` 已为视频调用 `TranscodeService.enqueue`。
|
||||||
|
- 分集创建/替换视频地址时 `EpisodeService` 已调用
|
||||||
|
`EpisodeTranscodeBindingService.bind`,会复用并绑定同一媒体的未完成任务。
|
||||||
|
- 转码失败原因已写入 `transcode_task.error_msg`,最终状态写入 `status`。
|
||||||
|
- 缺口是后台手动触发/重转接口,以及对应幂等和链路测试。
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
1. 保留上传完成和改片自动入队逻辑,补测试锁定行为。
|
||||||
|
2. `TranscodeService` 增加手动触发:
|
||||||
|
- QUEUED/RUNNING 直接返回现有任务;
|
||||||
|
- FAILED 重置原任务为 QUEUED;
|
||||||
|
- SUCCESS 才创建新任务,紧接着重复触发会复用新 QUEUED 任务。
|
||||||
|
3. 新增 `POST /admin/media/{id}/transcode`,校验媒资存在,复用 `episode:upload`。
|
||||||
|
4. 返回任务 id 与状态,便于后台提示。
|
||||||
|
|
||||||
|
实现时对同一媒资行使用 `FOR UPDATE`,让重复请求在事务内串行判定;
|
||||||
|
平台不存在的媒资保持 `DATA_NOT_FOUND`,不产生任务或 500。
|
||||||
|
|
||||||
|
## TDD 测试矩阵
|
||||||
|
|
||||||
|
| 场景 | 断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| 视频上传完成 | `MediaService.complete` 调用一次 `enqueue(assetId, 0, 0, sourceUrl)` |
|
||||||
|
| 图片上传完成 | 不创建转码任务 |
|
||||||
|
| 手动触发成功媒资 | 新建一个 QUEUED 任务,重复调用复用同一任务 |
|
||||||
|
| 手动触发进行中媒资 | 直接返回 QUEUED/RUNNING 任务,不新增记录 |
|
||||||
|
| 手动触发失败媒资 | 原任务重置为 QUEUED,保留任务 id |
|
||||||
|
| 媒资不存在 | 返回既有 `DATA_NOT_FOUND`,控制器不产生 500 |
|
||||||
|
| worker 最终失败 | 任务状态 FAILED,`error_msg` 保留 ffmpeg 原因 |
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 先提交失败测试,再提交实现。
|
||||||
|
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`
|
||||||
|
- 不启停任何服务,不执行数据库变更,不改 Web/Flutter/specs/API 文档。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 转码档位配置、雪碧图与播放缩略图轴
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. 后台可配置转码档位与雪碧图参数,worker 对之后的新任务在 30 秒内自动生效。
|
||||||
|
2. 转码成功后生成雪碧图与 VTT,登记到 `media_thumbnail`。
|
||||||
|
3. 播放接口与详情剧集返回 `thumbnailsUrl`,无产物时为 `null`。
|
||||||
|
|
||||||
|
## 配置模型
|
||||||
|
|
||||||
|
- `sys_config.config_key = media.transcode.profile`,值为 JSON。
|
||||||
|
- 配置包含总开关、分片秒数、码率阶梯与雪碧图参数。
|
||||||
|
- 缺省配置等价于现有 360p/480p/720p/1080p 四档。
|
||||||
|
- 后台 GET 缺省或历史配置非法时返回 yml 等价默认值;PUT 非法时返回统一 400,且不写库。
|
||||||
|
- worker 读取配置缓存 30 秒;缺省、解析失败或校验失败时回退 yml 默认阶梯。
|
||||||
|
|
||||||
|
## 权限取舍
|
||||||
|
|
||||||
|
现有权限点没有 `media:transcode`。本次两个接口复用最小可用的
|
||||||
|
`episode:upload`,避免新增权限点导致已有角色需要补授权;不新增菜单或权限 SQL。
|
||||||
|
|
||||||
|
## 数据与产物
|
||||||
|
|
||||||
|
- 新增 `media_thumbnail`,按 `episode_id` 唯一,记录 VTT、雪碧图前缀、采样参数、
|
||||||
|
尺寸、帧数、时长、状态、错误与总大小。
|
||||||
|
- 雪碧图目录固定在 `transcoded/{taskId}/thumbnails/`:
|
||||||
|
`thumbnails.vtt` + `sprite_001.jpg` 等多张图。
|
||||||
|
- VTT cue 链接使用相对路径 `sprite_XXX.jpg#xywh=x,y,w,h`,同一 VTT 目录下可直接解析。
|
||||||
|
- 缩略图失败只把 `media_thumbnail.status` 置 `FAILED` 并记录错误;主 HLS 仍成功。
|
||||||
|
|
||||||
|
## 播放
|
||||||
|
|
||||||
|
- `PlayRespVO` 和 `AppEpisodeVO` 增加 `thumbnailsUrl`。
|
||||||
|
- 查询 `media_thumbnail` 仅读取 `READY` 产物;objectKey 经
|
||||||
|
`MediaStorageService.toPublicUrl` 后使用现有 `PlaybackSigner` 签名。
|
||||||
|
- 详情剧集列表批量查询缩略图,禁止 N+1。
|
||||||
|
- 不改变现有 HLS/原文件回落与 qualities 列表。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- worker:配置解析、非法配置回退 yml、30 秒缓存、VTT 坐标、多雪碧图索引、
|
||||||
|
缩略图失败不阻断主转码。
|
||||||
|
- admin:GET 默认值、PUT 非法不落库、PUT 成功落库。
|
||||||
|
- playback:有 READY 缩略图返回签名 `thumbnailsUrl`,无产物返回 `null`。
|
||||||
|
- 门禁:`mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am test`。
|
||||||
|
|
||||||
|
## 风险 / 边界
|
||||||
|
|
||||||
|
- 本次只对合并后的新转码任务生效,不回跑历史任务。
|
||||||
|
- 配置中的顶层 `enabled` 会持久化并返回;本单不改变既有队列消费开关语义,
|
||||||
|
worker 仍以 `worker-enabled` 决定是否消费任务。
|
||||||
|
- 雪碧图生成依赖 ffmpeg `tile` 滤镜;真实产物由 worker 门禁和 PM 后续环境验收,
|
||||||
|
本任务不启动服务、不改客户端。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 转码分集绑定与运行态修复
|
||||||
|
|
||||||
|
## 已确认现场
|
||||||
|
|
||||||
|
admin-api/app-api/worker 均运行旧包;worker 未运行;`media_thumbnail` 表缺失;
|
||||||
|
新上传任务 drama_id/episode_id 为 0;旧 worker jar 缺 MediaThumbnail。
|
||||||
|
|
||||||
|
## 代码修复
|
||||||
|
|
||||||
|
1. 分集保存后按视频 URL 的 objectKey 查找 `media_asset`。
|
||||||
|
2. media 域新增绑定服务:
|
||||||
|
- 将 dramaId/episodeId 回填到对应 `transcode_task`;
|
||||||
|
- QUeued/RUNNING/SUCCESS 任务复用,不重复入队;
|
||||||
|
- FAILED/CANCELED 或不存在时新建任务;
|
||||||
|
- 已成功但 episodeId=0 的 rendition 同步回填。
|
||||||
|
3. `claimNext` 只认领 `episode_id > 0`,零绑定任务保留 QUEUED。
|
||||||
|
4. 缩略图登记使用 REQUIRES_NEW 独立事务;生成或登记失败只记 warn/error_msg,
|
||||||
|
主任务仍 SUCCESS。
|
||||||
|
5. 对 episode_id=0 且源文件不存在的明显测试垃圾标记 CANCELED,不删除。
|
||||||
|
|
||||||
|
## 迁移与运行
|
||||||
|
|
||||||
|
1. 确认 `media_thumbnail` 不存在后执行幂等 patch。
|
||||||
|
2. `mvn -DskipTests package` 全量重建。
|
||||||
|
3. 检查三个 jar 分别含:
|
||||||
|
- admin-api:MediaTranscodeProfile;
|
||||||
|
- app-api:PlayRespVO.thumbnailsUrl 对应新类;
|
||||||
|
- worker:MediaThumbnail / MediaThumbnailMapper。
|
||||||
|
4. 重启 8080/8081/worker,日志写 `logs/*.log`。
|
||||||
|
5. 冒烟 admin 配置、App 详情 thumbnailsUrl、worker 轮询。
|
||||||
|
6. 上传真实视频,创建绑定分集,等待 worker 消费,验证任务、四档 HLS、
|
||||||
|
media_thumbnail、VTT 与 sprite HTTP 200。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 旧 QUEUED 中无法绑定且源文件不存在的测试任务将被取消并记录 id。
|
||||||
|
- 真实视频端到端验证会写入运行数据库和媒资目录。
|
||||||
|
- 不修改 Web/Flutter,不操作真机。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 转码任务认领与媒资 ID 校验
|
||||||
|
|
||||||
|
## 现场结论
|
||||||
|
|
||||||
|
- `media_asset.id=21` 确实存在,source_url 对应分集 id=13。
|
||||||
|
- 分集 13 的 video_asset_id=0,旧手动触发只拿媒资,不知道分集绑定,
|
||||||
|
因而创建了 media_asset_id=21、episode_id=0 的 QUEUED 任务。
|
||||||
|
- claim 只筛选 episode_id>0,因此该任务永久跳过且没有原因日志。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
1. POST /admin/media/{id}/transcode 严格只按 media_asset.id 查询;
|
||||||
|
不再把候选 id 当分集 id 兜底或补登记。
|
||||||
|
2. 媒资存在时按 source_url 反查分集;找到后绑定 drama_id/episode_id。
|
||||||
|
3. 找不到分集时拒绝建任务,避免生成永远无法认领的 episode_id=0 记录。
|
||||||
|
4. claim 增加 media_asset_id/source_url 条件,并记录被跳过任务 id 与原因。
|
||||||
|
5. RUNNING 无更新阈值收敛到 5 分钟,覆盖 worker 重启后的孤儿任务。
|
||||||
|
6. 一次性 patch 将 media_asset_id 不存在且 episode_id=0 的 QUEUED/RUNNING 标记 FAILED。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 非法 media id 拒绝、未绑定分集拒绝、合法 URL 自动绑定。
|
||||||
|
- claim 跳过原因、进度更新、僵尸任务恢复。
|
||||||
|
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 转码媒资 ID 精度与存量兜底修复
|
||||||
|
|
||||||
|
## 取证
|
||||||
|
|
||||||
|
- 示例分集 `2113150431755636737` 的 `video_asset_id` 为
|
||||||
|
`2113150398670966786`,与 `media_asset.id` 完全一致。
|
||||||
|
- JS `Number('2113150398670966786')` 序列化为 `2113150398670966800`,
|
||||||
|
超出安全整数后发生精度丢失,导致手动转码查询了错误 ID。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
1. 聚合响应的 episodeId/mediaAssetId/taskId 使用字符串。
|
||||||
|
2. 手动转码仍由后端 Long 接收精确路径 ID。
|
||||||
|
3. 媒资不存在时通过 content 模块的端口按候选 ID 查找分集:
|
||||||
|
- 分集 id;
|
||||||
|
- video_asset_id;
|
||||||
|
- transcode_task.id。
|
||||||
|
4. 找到分集后按 video_url/source_url/objectKey 反查媒资;
|
||||||
|
文件存在但媒资缺失时补登记并回填分集 video_asset_id。
|
||||||
|
5. 无可用视频时保持明确空态/错误语义。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 超 2^53 ID 字符串契约。
|
||||||
|
- 聚合无媒资空态。
|
||||||
|
- video_url 反查命中。
|
||||||
|
- 文件存在时补登记。
|
||||||
|
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 转码保持原始宽高比
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 横屏源按档位短边作为高度,竖屏源按档位短边作为宽度。
|
||||||
|
- 源短边小于档位短边时按源短边输出,不放大。
|
||||||
|
- 输出宽高均为偶数,SAR 固定为 1。
|
||||||
|
- rendition 的 width/height 使用 ffprobe 探测到的实际值。
|
||||||
|
|
||||||
|
## 实施
|
||||||
|
|
||||||
|
1. ffprobe 读取源显示方向尺寸,旋转 90/270 度时交换宽高。
|
||||||
|
2. `effectiveShort = min(sourceShort, rungShort)` 并向下取偶数。
|
||||||
|
3. 横屏使用 `scale=-2:<effectiveShort>:flags=lanczos,setsar=1`;
|
||||||
|
竖屏使用 `scale=<effectiveShort>:-2:flags=lanczos,setsar=1`。
|
||||||
|
4. 转码完成后 ffprobe variant playlist,写入实际输出尺寸。
|
||||||
|
5. master playlist 的 RESOLUTION 同步使用实际尺寸。
|
||||||
|
|
||||||
|
## 不放大策略
|
||||||
|
|
||||||
|
选用“用源尺寸输出”:高于源短边的档位仍保留原 quality 标识和码率,
|
||||||
|
但分辨率封顶到源短边,避免所有档位都被跳过导致无产物。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 先补横屏、竖屏、小源码片、偶数、SAR=1 与实际尺寸回写测试。
|
||||||
|
- 门禁:`mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 转码错误截断与进度上报
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
- ffmpeg 错误输出超过 `error_msg varchar(500)`,失败回写 UPDATE 再次失败,
|
||||||
|
任务停在 RUNNING/0。
|
||||||
|
- worker 未解析 ffmpeg progress,前端只能看到 0。
|
||||||
|
- 缺少 RUNNING 超时收口。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
1. SQL patch 将 transcode_task/media_thumbnail 的 error_msg 改为 TEXT,可重复执行。
|
||||||
|
2. 新增统一错误消息截断,最多保留 500 字符并附总长度;即使补丁尚未执行,
|
||||||
|
也不会超过旧 varchar(500) 限制。
|
||||||
|
3. ffmpeg 使用 `-progress` 临时文件,worker 节流为约 1 秒写 transcode_task.progress。
|
||||||
|
4. rendition 阶段映射 0-90%,封面/缩略图 90-99%,成功后 100。
|
||||||
|
5. 失败保留最后进度;RUNNING 超过 10 分钟无 updated_at 变化标记 FAILED。
|
||||||
|
6. 手动重转复用最新任务,SUCCESS/FAILED/CANCELED 原地重置为 QUEUED、progress=0。
|
||||||
|
7. 成功写 rendition 前替换同媒资旧产物,避免重复重转唯一键冲突。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 进度写入只允许 RUNNING 状态,避免覆盖已完成/失败状态。
|
||||||
|
- 历史僵尸任务由 worker 下一轮自动收口,无需手工 SQL。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 错误截断、进度更新、僵尸重置、重复触发幂等测试。
|
||||||
|
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 转码 rendition 幂等与失败落库
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- 成功事务只按 media_asset_id 删除旧 rendition。
|
||||||
|
- 同一分集替换视频后 media_asset_id 改变、episode_id 不变,
|
||||||
|
旧行未被删除,触发 uk_rendition(episode_id, quality, format)。
|
||||||
|
- rendition 插入异常后任务未被可靠置为 FAILED。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
1. 成功事务删除条件分组为:
|
||||||
|
`media_asset_id = 本次媒体 OR (episode_id > 0 AND episode_id = 本次分集)`。
|
||||||
|
2. 缩略图登记按 episode_id 删除旧行后重新插入。
|
||||||
|
3. 主流程异常调用 failFinal,直接写 FAILED、错误、finished_at,保留 progress。
|
||||||
|
4. failFinal 自身异常在 worker 中 catch 并打印 ERROR,不静默。
|
||||||
|
5. error 截断同时保留头部与尾部,长度仍 <= 500。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 不同 media_asset_id、同 episode_id 的重复转码清理条件。
|
||||||
|
- thumbnail 旧行替换。
|
||||||
|
- 失败落库字段与尾段错误保留。
|
||||||
|
- `mvn -q -DskipTests=false test -pl ... -am`。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 标签管理(替换短剧内容类型)
|
||||||
|
|
||||||
|
## 决定
|
||||||
|
|
||||||
|
不做「语言开关」总控。分类和剧单已经用 `name` / `nameEn` / `nameId` 三列。标签沿用同一套,避免两套国际化。语言总控要改短剧标题、简介、剧集标题,单独开一轮。
|
||||||
|
|
||||||
|
## 本轮
|
||||||
|
|
||||||
|
- 短剧编辑去掉「内容类型」。库里的 `drama_type` 先留着,推荐流的 `ANIME` / `LIVE` 筛选不受影响,后台不再改它。
|
||||||
|
- 内容管理下新增「标签管理」。
|
||||||
|
- 标签字段:中文名、英文名、印尼语名、排序、启用状态。三个名称都必填。
|
||||||
|
- 短剧 `tag_id` 单选,可空。编辑页用下拉,只列出启用中的标签。
|
||||||
|
- 已有短剧按原内容类型回填:`ANIME` → 漫剧,`LIVE` → 真人剧。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
- `GET/POST /admin/content-tags`,`PUT/DELETE /admin/content-tags/{id}`,权限沿用 `drama:list` / `create` / `update` / `delete`。
|
||||||
|
- 短剧保存增加 `tagId`。不传则保持原值;传 `0` 表示清空。
|
||||||
|
- 短剧列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# INBOX
|
||||||
|
|
||||||
|
> 本轮任务:K100 后台剧集多选上传(分片 + 断点续传 + 进度展示)。**[已处理]**
|
||||||
|
> 开工前置:先读 `AGENTS.md`、`CODING_STANDARDS.md`、`GIT_WORKFLOW.md` 本文件,再读
|
||||||
|
> `ReelShortWeb/src/utils/uploader.ts`、`src/api/admin/media.ts`、`src/views/contentManagement/EpisodeManagementView.vue`。
|
||||||
|
|
||||||
|
### Codex 回执(K100)
|
||||||
|
|
||||||
|
- ReelShortWeb 分支:`feature/episode-batch-upload` → `--no-ff` 合入 `develop` 并 push
|
||||||
|
- 提交:`feat(admin): 剧集多选分片上传与断点续传`
|
||||||
|
- 门禁:`pnpm lint:eslint` 零错误;`pnpm build:dev` 成功
|
||||||
|
- 未验证:PM 手工多选/暂停/刷新续传/批量建集(8080+3005)
|
||||||
|
- 风险:续传需重选相同本地文件;暂停不 abort 进行中分片
|
||||||
|
|
||||||
|
## 现状(已勘察,不要重复排查)
|
||||||
|
|
||||||
|
- 后端分片上传已具备且不改:`POST /admin/media/upload/init` → `POST /admin/media/upload/chunk` →
|
||||||
|
`POST /admin/media/upload/complete`;断点查询 `GET /admin/media/upload/{uploadId}`
|
||||||
|
返回 `receivedChunks/totalChunks/chunkSize/percent/status/assetId/url`。
|
||||||
|
- 前端已有 `src/utils/uploader.ts`:`uploadFile()` 分片并发 3、`resumeUpload()` 按服务端已收片数续传、
|
||||||
|
`probeVideoDuration()` 探测时长。**这两个函数已可用,本次在其上做批量队列 UI**。
|
||||||
|
- 当前剧集管理 `EpisodeManagementView.vue` 只有一个 `el-upload` 单文件入口(`http-request="uploadVideo"`),
|
||||||
|
**不支持多选、没有进度条、没有断点续传入口**,`onSession` 也没有落盘。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在剧集管理页提供「批量上传」:一次选多个视频 → 队列分片上传 → 每个文件独立进度 → 失败可重试 →
|
||||||
|
刷新/重进页面能续传 → 上传完成后批量创建分集。
|
||||||
|
|
||||||
|
## A. 上传队列(新建 `src/views/contentManagement/upload/useUploadQueue.ts`)
|
||||||
|
|
||||||
|
1. 输入:`File[]` + `dramaId` + 可选 `bizType`
|
||||||
|
2. 每项状态机:`waiting → uploading → merging → done / failed / paused`;对外暴露 `items`、`overallPercent`、
|
||||||
|
`start()`、`pause()`、`retry(id)`、`retryAll()`、`remove(id)`、`clear()`
|
||||||
|
3. 队列并发:同时最多 2 个文件上传(文件内分片并发沿用 `uploadFile` 的 3)
|
||||||
|
4. 进度:文件内 `onProgress` 回调更新单项百分比;总进度 = 各文件字节加权,不是简单平均
|
||||||
|
5. 失败隔离:单文件失败不影响其它文件,错误原因记在该项 `error`
|
||||||
|
6. 暂停语义(本轮简化,不做 abort):暂停后不再派发新任务,已在传的分片让其完成
|
||||||
|
7. 上传完成后 `completeUploadApi` 拿到 `assetId/url/durationSec` 写回该项
|
||||||
|
|
||||||
|
## B. 断点续传(关键)
|
||||||
|
|
||||||
|
1. 上传开始(拿到 `uploadId`)即写入 `localStorage`:键 `reel_upload_sessions`,
|
||||||
|
值数组项含 `{ uploadId, fileName, fileSize, lastModified, dramaId, bizType, createdAt }`
|
||||||
|
2. 页面进入时读取该键,过滤 `dramaId` 匹配且 24 小时内的会话,弹出提示「发现 N 个未完成上传,是否续传」
|
||||||
|
3. 续传前用 `file.name + size + lastModified` 匹配本地重新选择的文件;
|
||||||
|
**文件匹配不上(换机器/改名)则不自动续传,提示后由用户手动重选**
|
||||||
|
4. 续传走现有 `resumeUpload(file, uploadId)`;`GET /admin/media/upload/{uploadId}` 返回 404 或已 completed 的会话要从 localStorage 清理
|
||||||
|
5. 上传成功、用户移除、用户放弃续传三种情况都要清理对应记录
|
||||||
|
|
||||||
|
## C. 批量创建分集
|
||||||
|
|
||||||
|
1. 上传完成列表提供「批量创建分集」按钮
|
||||||
|
2. 默认值:按文件名自然排序(去扩展名后再比较,`第2集` 排在 `第10集` 之前);
|
||||||
|
集号 = 该剧当前最大集号 + 1 起顺延;标题取文件名去扩展名;是否免费沿用现有新建默认值
|
||||||
|
3. 弹窗内每行可改:集号、标题、是否免费;冲突集号在提交前本地校验并标红
|
||||||
|
4. 提交:逐条调用现有 `createEpisodeApi`(`src/api/admin/episode.ts`),
|
||||||
|
逐条显示成功/失败,失败的保留在弹窗内可重试;全部结束后刷新剧集列表
|
||||||
|
5. **不新增后端接口**,契约不动
|
||||||
|
|
||||||
|
## D. 组件拆分(红线:单文件 ≤ 500 行)
|
||||||
|
|
||||||
|
- `src/views/contentManagement/upload/BatchUploadDialog.vue`:多选、队列列表、总进度、暂停/重试/移除
|
||||||
|
- `src/views/contentManagement/upload/UploadTaskRow.vue`:单行(文件名、大小、进度条、状态、操作)
|
||||||
|
- `src/views/contentManagement/upload/CreateEpisodesDialog.vue`:批量建分集表单
|
||||||
|
- `src/views/contentManagement/upload/useUploadQueue.ts`:队列逻辑(纯逻辑单元,不写 UI)
|
||||||
|
- `EpisodeManagementView.vue` 只加一个「批量上传」入口按钮,并保持原单文件上传入口可用
|
||||||
|
|
||||||
|
## E. 细节要求
|
||||||
|
|
||||||
|
- 文件类型白名单:`mp4/mov/mkv/webm`(`accept="video/*"` + `before-upload` 二次校验)
|
||||||
|
- 单文件大小上限从现有常量取值,超限直接标红不入队
|
||||||
|
- 所有文案走 `src/locales/zh-CN.ts` / `en.ts`,禁止硬编码中文
|
||||||
|
- 进度与错误展示用项目现有样式变量,不新引入 UI 依赖
|
||||||
|
- 上传期间:关闭页面前用 `beforeunload` 提示存在未完成上传
|
||||||
|
|
||||||
|
## F. 门禁
|
||||||
|
|
||||||
|
- `pnpm lint:eslint` 零错误
|
||||||
|
- `pnpm build:dev` 构建成功
|
||||||
|
- 手工验证(PM 会复核):本地起 8080 + 3005,多选 3 个视频 → 三条并行进度 → 中途暂停 → 刷新页面 → 出现续传提示 → 续传完成 → 批量建分集成功
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- ReelShortWeb:从 `develop` 拉 `feature/episode-batch-upload` → 提交
|
||||||
|
`feat(admin): 剧集多选分片上传与断点续传` → `--no-ff` 合回 `develop` 并 push
|
||||||
|
- 作者 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;源码首行 `// Author: fruit.com.cn`
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 先写计划 `docs/plans/2026-10-21-K100-episode-batch-upload.md`,再改代码
|
||||||
|
- 每跑一个命令,把「命令 + 原始输出 + 解读」追加到 `docs/plans/PROGRESS.md`
|
||||||
|
- 不改 FruitServer、不改 ReelShort 客户端、不改接口契约
|
||||||
|
- 禁止 `git add -A`;不要操作真机
|
||||||
|
- 完成后停住等 PM 验收,回执需贴门禁原始输出
|
||||||
+25903
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,50 @@ package com.fruitshorts;
|
|||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||||
|
import io.swagger.v3.oas.annotations.info.Info;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@OpenAPIDefinition(
|
||||||
|
info = @Info(
|
||||||
|
title = "FruitShorts 运营后台接口(/admin/**)",
|
||||||
|
version = "1.0.0",
|
||||||
|
description = "接口契约以 FruitServer/docs/api-phase1.md 为准"
|
||||||
|
),
|
||||||
|
tags = {
|
||||||
|
@Tag(name = "后台-管理员账号", description = "后台管理员账号管理"),
|
||||||
|
@Tag(name = "后台-App 用户", description = "App 用户查询与处置"),
|
||||||
|
@Tag(name = "后台-登录与个人", description = "管理端登录、菜单、个人资料与改密"),
|
||||||
|
@Tag(name = "后台-轮播图管理", description = "轮播图维护与排序"),
|
||||||
|
@Tag(name = "后台-评论审核", description = "评论检索与审核"),
|
||||||
|
@Tag(name = "后台-系统配置", description = "系统参数配置"),
|
||||||
|
@Tag(name = "后台-创作者管理", description = "创作者入驻审核与账号开通"),
|
||||||
|
@Tag(name = "创作者中心", description = "创作者工作台(作品、通知、看板)"),
|
||||||
|
@Tag(name = "创作者中心-内容与收费", description = "创作者内容编辑、提交审核与分集收费"),
|
||||||
|
@Tag(name = "后台-数据看板", description = "运营大盘与转码看板"),
|
||||||
|
@Tag(name = "后台-字典", description = "分类与状态字典"),
|
||||||
|
@Tag(name = "后台-离线下载管理", description = "下载授权查询、吊销与清理"),
|
||||||
|
@Tag(name = "后台-短剧分类", description = "短剧分类维护"),
|
||||||
|
@Tag(name = "后台-短剧管理", description = "短剧 CRUD 与审核发布流转"),
|
||||||
|
@Tag(name = "后台-权益管理", description = "权益补发与回收"),
|
||||||
|
@Tag(name = "后台-剧集管理", description = "剧集 CRUD 与排序"),
|
||||||
|
@Tag(name = "后台-媒资管理", description = "分片上传与媒资详情"),
|
||||||
|
@Tag(name = "后台-菜单管理", description = "菜单树维护"),
|
||||||
|
@Tag(name = "后台-操作日志", description = "操作日志检索"),
|
||||||
|
@Tag(name = "后台-订单管理", description = "订单检索、补发与退款"),
|
||||||
|
@Tag(name = "后台-商品配置", description = "商品(金币包)配置与上下架"),
|
||||||
|
@Tag(name = "后台-系统公告", description = "系统公告发布与管理(与 C 端口径一致)"),
|
||||||
|
@Tag(name = "后台-权限点", description = "权限点查询"),
|
||||||
|
@Tag(name = "后台-分集收费", description = "分集价格配置"),
|
||||||
|
@Tag(name = "后台-对账", description = "对账任务与手动对账"),
|
||||||
|
@Tag(name = "后台-举报工单", description = "举报工单处理"),
|
||||||
|
@Tag(name = "后台-角色分类", description = "角色分类维护"),
|
||||||
|
@Tag(name = "后台-角色管理", description = "角色与角色权限"),
|
||||||
|
@Tag(name = "后台-敏感词库", description = "敏感词维护"),
|
||||||
|
@Tag(name = "后台-内容提交审核", description = "创作者内容提交审核"),
|
||||||
|
@Tag(name = "后台-金币钱包", description = "金币流水与人工补币"),
|
||||||
|
}
|
||||||
|
)
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@MapperScan("com.fruitshorts.**.dal.mapper")
|
@MapperScan("com.fruitshorts.**.dal.mapper")
|
||||||
public class FruitShortsApplication {
|
public class FruitShortsApplication {
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||||
|
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台接口的时间格式统一成 `yyyy-MM-dd HH:mm:ss`。
|
||||||
|
*
|
||||||
|
* <p>application.yml 里的 `spring.jackson.date-format` 只作用于 java.util.Date,
|
||||||
|
* LocalDateTime 仍会输出 ISO 的 `2026-10-27T14:14:12.644`,后台表格里会露出一串带 T 的原文。
|
||||||
|
* 该配置只放在 admin-api,C 端保持 ISO,避免影响客户端解析。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class AdminJacksonTimeConfig {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Jackson2ObjectMapperBuilderCustomizer adminLocalDateTimeFormat() {
|
||||||
|
return builder -> builder.serializers(new LocalDateTimeSerializer(DATE_TIME));
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Tag(name = "管理员管理")
|
@Tag(name = "后台-管理员账号")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/admin-users")
|
@RequestMapping("/admin/admin-users")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -35,7 +35,7 @@ public class AdminUserController {
|
|||||||
|
|
||||||
private final AdminUserService adminUserService;
|
private final AdminUserService adminUserService;
|
||||||
|
|
||||||
@Operation(summary = "管理员分页检索")
|
@Operation(summary = "管理员分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('system:admin-user')")
|
@PreAuthorize("@ss.hasPermission('system:admin-user')")
|
||||||
public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) {
|
public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) {
|
||||||
@@ -49,7 +49,7 @@ public class AdminUserController {
|
|||||||
return Result.ok(adminUserService.get(id));
|
return Result.ok(adminUserService.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新增管理员")
|
@Operation(summary = "新建管理员")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('system:admin-user')")
|
@PreAuthorize("@ss.hasPermission('system:admin-user')")
|
||||||
@OperationLog(module = "后台管理", action = "新增管理员")
|
@OperationLog(module = "后台管理", action = "新增管理员")
|
||||||
@@ -75,7 +75,7 @@ public class AdminUserController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "修改管理员状态")
|
@Operation(summary = "启用 / 停用管理员")
|
||||||
@PutMapping("/{id}/status")
|
@PutMapping("/{id}/status")
|
||||||
@PreAuthorize("@ss.hasPermission('system:admin-user')")
|
@PreAuthorize("@ss.hasPermission('system:admin-user')")
|
||||||
@OperationLog(module = "后台管理", action = "修改管理员状态")
|
@OperationLog(module = "后台管理", action = "修改管理员状态")
|
||||||
|
|||||||
+19
-8
@@ -4,6 +4,7 @@ package com.fruitshorts.admin.controller;
|
|||||||
|
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.admin.service.AppSessionSystemService;
|
||||||
import com.fruitshorts.admin.vo.AppUserWatchHistoryVO;
|
import com.fruitshorts.admin.vo.AppUserWatchHistoryVO;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
@@ -13,6 +14,7 @@ import com.fruitshorts.content.service.EpisodeService;
|
|||||||
import com.fruitshorts.framework.log.OperationLog;
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
||||||
|
import com.fruitshorts.user.dal.entity.AppUserSession;
|
||||||
import com.fruitshorts.playback.service.WatchProgressService;
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
import com.fruitshorts.user.dal.entity.AppUserBanRecord;
|
import com.fruitshorts.user.dal.entity.AppUserBanRecord;
|
||||||
import com.fruitshorts.user.service.AppUserAdminService;
|
import com.fruitshorts.user.service.AppUserAdminService;
|
||||||
@@ -34,7 +36,7 @@ import java.util.Map;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/** 后台用户管理。 */
|
/** 后台用户管理。 */
|
||||||
@Tag(name = "App 用户管理")
|
@Tag(name = "后台-App 用户")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/app-users")
|
@RequestMapping("/admin/app-users")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -44,22 +46,23 @@ public class AppUserAdminController {
|
|||||||
private final WatchProgressService watchProgressService;
|
private final WatchProgressService watchProgressService;
|
||||||
private final DramaService dramaService;
|
private final DramaService dramaService;
|
||||||
private final EpisodeService episodeService;
|
private final EpisodeService episodeService;
|
||||||
|
private final AppSessionSystemService sessionSystem;
|
||||||
|
|
||||||
@Operation(summary = "用户分页检索(uid/昵称/手机号/邮箱/状态/国家/注册来源)")
|
@Operation(summary = "App 用户分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||||
public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) {
|
public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) {
|
||||||
return Result.ok(appUserAdminService.page(req));
|
return Result.ok(appUserAdminService.page(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "用户详情")
|
@Operation(summary = "App 用户详情")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||||
public Result<AppUserAdminRespVO> detail(@PathVariable Long id) {
|
public Result<AppUserAdminRespVO> detail(@PathVariable Long id) {
|
||||||
return Result.ok(appUserAdminService.detail(id));
|
return Result.ok(appUserAdminService.detail(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "封禁与申诉记录")
|
@Operation(summary = "封禁 / 处置记录")
|
||||||
@GetMapping("/{id}/ban-records")
|
@GetMapping("/{id}/ban-records")
|
||||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||||
public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) {
|
public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) {
|
||||||
@@ -67,7 +70,7 @@ public class AppUserAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */
|
/** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */
|
||||||
@Operation(summary = "观看流水明细(客服核对用,带剧名与集号)")
|
@Operation(summary = "观看流水明细(客服核对用)")
|
||||||
@GetMapping("/{id}/watch-history")
|
@GetMapping("/{id}/watch-history")
|
||||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||||
public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id,
|
public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id,
|
||||||
@@ -83,11 +86,15 @@ public class AppUserAdminController {
|
|||||||
Map<Long, DramaEpisode> episodes = episodeService
|
Map<Long, DramaEpisode> episodes = episodeService
|
||||||
.listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList())
|
.listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList())
|
||||||
.stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a));
|
.stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a));
|
||||||
return Result.ok(rows.stream().map(row -> toVo(row, dramas, episodes)).toList());
|
List<AppUserSession> sessions = sessionSystem.groupByUser(List.of(id))
|
||||||
|
.getOrDefault(id, List.of());
|
||||||
|
return Result.ok(rows.stream()
|
||||||
|
.map(row -> toVo(row, dramas, episodes, sessionSystem.match(sessions, row.getDeviceId())))
|
||||||
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppUserWatchHistoryVO toVo(WatchHistory row, Map<Long, Drama> dramas,
|
private AppUserWatchHistoryVO toVo(WatchHistory row, Map<Long, Drama> dramas,
|
||||||
Map<Long, DramaEpisode> episodes) {
|
Map<Long, DramaEpisode> episodes, AppUserSession session) {
|
||||||
AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO();
|
AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO();
|
||||||
vo.setId(row.getId());
|
vo.setId(row.getId());
|
||||||
vo.setDramaId(row.getDramaId());
|
vo.setDramaId(row.getDramaId());
|
||||||
@@ -100,6 +107,10 @@ public class AppUserAdminController {
|
|||||||
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
|
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
|
||||||
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
|
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
|
||||||
vo.setFinished(duration > 0 && position + 1000L >= duration);
|
vo.setFinished(duration > 0 && position + 1000L >= duration);
|
||||||
|
if (session != null) {
|
||||||
|
vo.setPlatform(session.getPlatform());
|
||||||
|
vo.setOsVersion(session.getOsVersion());
|
||||||
|
}
|
||||||
Drama drama = dramas.get(row.getDramaId());
|
Drama drama = dramas.get(row.getDramaId());
|
||||||
if (drama != null) {
|
if (drama != null) {
|
||||||
vo.setDramaTitle(drama.getTitle());
|
vo.setDramaTitle(drama.getTitle());
|
||||||
@@ -115,7 +126,7 @@ public class AppUserAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */
|
/** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */
|
||||||
@Operation(summary = "修改用户状态(normal/limited/banned,封禁立即吊销全部会话)")
|
@Operation(summary = "处置用户状态(封禁会立即吊销全部会话)")
|
||||||
@PutMapping("/{id}/status")
|
@PutMapping("/{id}/status")
|
||||||
@PreAuthorize("@ss.hasPermission('app-user:update')")
|
@PreAuthorize("@ss.hasPermission('app-user:update')")
|
||||||
@OperationLog(module = "用户管理", action = "修改用户状态")
|
@OperationLog(module = "用户管理", action = "修改用户状态")
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import com.fruitshorts.system.service.AppVersionService;
|
||||||
|
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
|
||||||
|
import com.fruitshorts.system.vo.AppVersionRespVO;
|
||||||
|
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 后台 - App 版本管理。 */
|
||||||
|
@Tag(name = "后台-App 版本")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/app-versions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppVersionAdminController {
|
||||||
|
|
||||||
|
private final AppVersionService appVersionService;
|
||||||
|
|
||||||
|
@Operation(summary = "版本分页查询")
|
||||||
|
@GetMapping("/page")
|
||||||
|
@PreAuthorize("@ss.hasPermission('app-version:list')")
|
||||||
|
public Result<PageResult<AppVersionRespVO>> page(@Validated AppVersionQueryReqVO req) {
|
||||||
|
return Result.ok(appVersionService.page(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "版本详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('app-version:list')")
|
||||||
|
public Result<AppVersionRespVO> detail(@PathVariable Long id) {
|
||||||
|
return Result.ok(appVersionService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "新建版本(默认发布)")
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('app-version:create')")
|
||||||
|
@OperationLog(module = "版本管理", action = "新建版本")
|
||||||
|
public Result<Long> create(@Valid @RequestBody AppVersionSaveReqVO req) {
|
||||||
|
return Result.ok(appVersionService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "批量新建版本(一次事务)")
|
||||||
|
@PostMapping("/batch")
|
||||||
|
@PreAuthorize("@ss.hasPermission('app-version:create')")
|
||||||
|
@OperationLog(module = "版本管理", action = "批量新建版本")
|
||||||
|
public Result<java.util.List<Long>> batchCreate(
|
||||||
|
@NotEmpty(message = "请至少传一条")
|
||||||
|
@Size(max = 16, message = "单次不超过 16 条")
|
||||||
|
@Valid @RequestBody java.util.List<@Valid AppVersionSaveReqVO> reqs) {
|
||||||
|
return Result.ok(appVersionService.batchCreate(reqs));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "编辑版本")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('app-version:update')")
|
||||||
|
@OperationLog(module = "版本管理", action = "编辑版本")
|
||||||
|
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody AppVersionSaveReqVO req) {
|
||||||
|
appVersionService.update(id, req);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "发布 / 下架")
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@PreAuthorize("@ss.hasPermission('app-version:update')")
|
||||||
|
@OperationLog(module = "版本管理", action = "版本上下架")
|
||||||
|
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
|
||||||
|
appVersionService.updateStatus(id, status);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除版本")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('app-version:delete')")
|
||||||
|
@OperationLog(module = "版本管理", action = "删除版本")
|
||||||
|
public Result<Void> delete(@PathVariable Long id) {
|
||||||
|
appVersionService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-5
@@ -25,19 +25,20 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Tag(name = "后台认证")
|
@Tag(name = "后台-登录与个人")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/auth")
|
@RequestMapping("/admin/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
private final WebUtils webUtils;
|
||||||
|
|
||||||
@Operation(summary = "后台登录,返回 token")
|
@Operation(summary = "管理端登录")
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@OperationLog(module = "认证", action = "登录")
|
@OperationLog(module = "认证", action = "登录")
|
||||||
public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) {
|
public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) {
|
||||||
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
|
return Result.ok(authService.login(req, webUtils.clientIp(request)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户的菜单路由树,结构与前端 element-plus-admin 的动态路由一致。 */
|
/** 当前用户的菜单路由树,结构与前端 element-plus-admin 的动态路由一致。 */
|
||||||
@@ -54,14 +55,14 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** JWT 无状态,登出由前端清除 token 即可。 */
|
/** JWT 无状态,登出由前端清除 token 即可。 */
|
||||||
@Operation(summary = "登出(JWT 无状态,前端清除 token 即可)")
|
@Operation(summary = "登出")
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
@OperationLog(module = "认证", action = "退出登录")
|
@OperationLog(module = "认证", action = "退出登录")
|
||||||
public Result<Void> logout() {
|
public Result<Void> logout() {
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "修改密码")
|
@Operation(summary = "修改自己的登录密码")
|
||||||
@PostMapping("/password")
|
@PostMapping("/password")
|
||||||
@OperationLog(module = "认证", action = "修改密码")
|
@OperationLog(module = "认证", action = "修改密码")
|
||||||
public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) {
|
public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) {
|
||||||
|
|||||||
+5
-5
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Tag(name = "轮播图管理")
|
@Tag(name = "后台-轮播图管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/banners")
|
@RequestMapping("/admin/banners")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -35,7 +35,7 @@ public class BannerController {
|
|||||||
|
|
||||||
private final BannerService bannerService;
|
private final BannerService bannerService;
|
||||||
|
|
||||||
@Operation(summary = "轮播图分页检索(按位置/状态过滤)")
|
@Operation(summary = "轮播图分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('banner:list')")
|
@PreAuthorize("@ss.hasPermission('banner:list')")
|
||||||
public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position,
|
public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position,
|
||||||
@@ -52,7 +52,7 @@ public class BannerController {
|
|||||||
return Result.ok(bannerService.get(id));
|
return Result.ok(bannerService.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新增轮播图")
|
@Operation(summary = "新建轮播图")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('banner:create')")
|
@PreAuthorize("@ss.hasPermission('banner:create')")
|
||||||
@OperationLog(module = "内容管理", action = "新增轮播图")
|
@OperationLog(module = "内容管理", action = "新增轮播图")
|
||||||
@@ -78,7 +78,7 @@ public class BannerController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "修改轮播图状态")
|
@Operation(summary = "上架 / 下架轮播图")
|
||||||
@PutMapping("/{id}/status")
|
@PutMapping("/{id}/status")
|
||||||
@PreAuthorize("@ss.hasPermission('banner:update')")
|
@PreAuthorize("@ss.hasPermission('banner:update')")
|
||||||
@OperationLog(module = "内容管理", action = "修改轮播图状态")
|
@OperationLog(module = "内容管理", action = "修改轮播图状态")
|
||||||
@@ -87,7 +87,7 @@ public class BannerController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "调整轮播图排序")
|
@Operation(summary = "调整轮播排序")
|
||||||
@PutMapping("/sort")
|
@PutMapping("/sort")
|
||||||
@PreAuthorize("@ss.hasPermission('banner:update')")
|
@PreAuthorize("@ss.hasPermission('banner:update')")
|
||||||
public Result<Void> sort(@RequestBody Map<String, List<Long>> body) {
|
public Result<Void> sort(@RequestBody Map<String, List<Long>> body) {
|
||||||
|
|||||||
+5
-5
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 后台评论审核。 */
|
/** 后台评论审核。 */
|
||||||
@Tag(name = "评论治理")
|
@Tag(name = "后台-评论审核")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/comments")
|
@RequestMapping("/admin/comments")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -35,8 +35,8 @@ public class CommentAdminController {
|
|||||||
|
|
||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
|
|
||||||
/** 检索评论:默认按状态过滤,`status=pending` 即审核队列。 */
|
/** 检索评论:status 为空时返回全部状态,传 pending 即审核队列。 */
|
||||||
@Operation(summary = "评论检索(status=pending 即审核队列)")
|
@Operation(summary = "评论检索(status 为空返回全部状态)")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('comment:list')")
|
@PreAuthorize("@ss.hasPermission('comment:list')")
|
||||||
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
|
public Result<PageResult<CommentRespVO>> page(CommentQueryReqVO req) {
|
||||||
@@ -44,14 +44,14 @@ public class CommentAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 审核留证:机审与人审的完整链路。 */
|
/** 审核留证:机审与人审的完整链路。 */
|
||||||
@Operation(summary = "审核留证(机审与人审完整链路)")
|
@Operation(summary = "评论审核留证(机审 + 人审链路)")
|
||||||
@GetMapping("/{id}/audit-records")
|
@GetMapping("/{id}/audit-records")
|
||||||
@PreAuthorize("@ss.hasPermission('comment:list')")
|
@PreAuthorize("@ss.hasPermission('comment:list')")
|
||||||
public Result<List<ContentAuditRecord>> auditRecords(@PathVariable Long id) {
|
public Result<List<ContentAuditRecord>> auditRecords(@PathVariable Long id) {
|
||||||
return Result.ok(commentService.auditRecords(id));
|
return Result.ok(commentService.auditRecords(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "审核评论(通过/驳回,带原因码与说明)")
|
@Operation(summary = "审核评论(通过 / 驳回)")
|
||||||
@PutMapping("/{id}/audit")
|
@PutMapping("/{id}/audit")
|
||||||
@PreAuthorize("@ss.hasPermission('comment:audit')")
|
@PreAuthorize("@ss.hasPermission('comment:audit')")
|
||||||
@OperationLog(module = "内容治理", action = "审核评论")
|
@OperationLog(module = "内容治理", action = "审核评论")
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Tag(name = "系统配置")
|
@Tag(name = "后台-系统配置")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/configs")
|
@RequestMapping("/admin/configs")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -34,7 +34,7 @@ public class ConfigController {
|
|||||||
return Result.ok(configService.list());
|
return Result.ok(configService.list());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "批量修改系统配置")
|
@Operation(summary = "保存系统配置")
|
||||||
@PutMapping
|
@PutMapping
|
||||||
@PreAuthorize("@ss.hasPermission('system:role')")
|
@PreAuthorize("@ss.hasPermission('system:role')")
|
||||||
@OperationLog(module = "后台管理", action = "修改系统设置")
|
@OperationLog(module = "后台管理", action = "修改系统设置")
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.content.service.ContentTagService;
|
||||||
|
import com.fruitshorts.content.vo.ContentTagRespVO;
|
||||||
|
import com.fruitshorts.content.vo.ContentTagSaveReqVO;
|
||||||
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 短剧标签管理。短剧编辑时单选其中一个启用标签。 */
|
||||||
|
@Tag(name = "后台-标签")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/content-tags")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContentTagController {
|
||||||
|
|
||||||
|
private final ContentTagService contentTagService;
|
||||||
|
|
||||||
|
@Operation(summary = "标签列表")
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
|
public Result<List<ContentTagRespVO>> list(
|
||||||
|
@RequestParam(required = false) Boolean enabledOnly) {
|
||||||
|
if (Boolean.TRUE.equals(enabledOnly)) {
|
||||||
|
return Result.ok(contentTagService.listEnabled());
|
||||||
|
}
|
||||||
|
return Result.ok(contentTagService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "新建标签")
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:create')")
|
||||||
|
@OperationLog(module = "内容管理", action = "新增标签")
|
||||||
|
public Result<Long> create(@Valid @RequestBody ContentTagSaveReqVO req) {
|
||||||
|
return Result.ok(contentTagService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "编辑标签")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
|
@OperationLog(module = "内容管理", action = "编辑标签")
|
||||||
|
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ContentTagSaveReqVO req) {
|
||||||
|
contentTagService.update(id, req);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除标签")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:delete')")
|
||||||
|
@OperationLog(module = "内容管理", action = "删除标签")
|
||||||
|
public Result<Void> delete(@PathVariable Long id) {
|
||||||
|
contentTagService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -31,7 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 后台创作者:检索、详情、资质、入驻审核。 */
|
/** 后台创作者:检索、详情、资质、入驻审核。 */
|
||||||
@Tag(name = "创作者管理")
|
@Tag(name = "后台-创作者管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/creators")
|
@RequestMapping("/admin/creators")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -40,7 +40,7 @@ public class CreatorAdminController {
|
|||||||
private final CreatorService creatorService;
|
private final CreatorService creatorService;
|
||||||
private final CreatorAccountService creatorAccountService;
|
private final CreatorAccountService creatorAccountService;
|
||||||
|
|
||||||
@Operation(summary = "创作者分页检索")
|
@Operation(summary = "创作者分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('creator:list')")
|
@PreAuthorize("@ss.hasPermission('creator:list')")
|
||||||
public Result<PageResult<Creator>> page(CreatorQueryReqVO req) {
|
public Result<PageResult<Creator>> page(CreatorQueryReqVO req) {
|
||||||
@@ -55,14 +55,14 @@ public class CreatorAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 资质文件:审核前要看证件原件。 */
|
/** 资质文件:审核前要看证件原件。 */
|
||||||
@Operation(summary = "资质文件(审核前查看证件原件)")
|
@Operation(summary = "创作者资质文件")
|
||||||
@GetMapping("/{id}/documents")
|
@GetMapping("/{id}/documents")
|
||||||
@PreAuthorize("@ss.hasPermission('creator:list')")
|
@PreAuthorize("@ss.hasPermission('creator:list')")
|
||||||
public Result<List<CreatorDocument>> documents(@PathVariable Long id) {
|
public Result<List<CreatorDocument>> documents(@PathVariable Long id) {
|
||||||
return Result.ok(creatorService.documents(id));
|
return Result.ok(creatorService.documents(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "入驻审核(通过/驳回/冻结)")
|
@Operation(summary = "创作者入驻审核")
|
||||||
@PutMapping("/{id}/audit")
|
@PutMapping("/{id}/audit")
|
||||||
@PreAuthorize("@ss.hasPermission('creator:audit')")
|
@PreAuthorize("@ss.hasPermission('creator:audit')")
|
||||||
@OperationLog(module = "创作者管理", action = "入驻审核")
|
@OperationLog(module = "创作者管理", action = "入驻审核")
|
||||||
@@ -71,7 +71,7 @@ public class CreatorAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */
|
/** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */
|
||||||
@Operation(summary = "手工录入创作者主体(C 端申请未实现前的打通用口)")
|
@Operation(summary = "手工录入创作者主体")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('creator:audit')")
|
@PreAuthorize("@ss.hasPermission('creator:audit')")
|
||||||
@OperationLog(module = "创作者管理", action = "新增创作者")
|
@OperationLog(module = "创作者管理", action = "新增创作者")
|
||||||
@@ -80,7 +80,7 @@ public class CreatorAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */
|
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */
|
||||||
@Operation(summary = "开通创作者账号(仅审核通过可开,幂等)")
|
@Operation(summary = "开通创作者账号(仅审核通过可开通,幂等)")
|
||||||
@PostMapping("/{id}/account")
|
@PostMapping("/{id}/account")
|
||||||
@PreAuthorize("@ss.hasPermission('creator:account')")
|
@PreAuthorize("@ss.hasPermission('creator:account')")
|
||||||
@OperationLog(module = "创作者管理", action = "开通创作者账号")
|
@OperationLog(module = "创作者管理", action = "开通创作者账号")
|
||||||
|
|||||||
+6
-6
@@ -40,7 +40,7 @@ public class CreatorCenterController {
|
|||||||
private final CreatorDashboardService creatorDashboardService;
|
private final CreatorDashboardService creatorDashboardService;
|
||||||
|
|
||||||
/** 当前创作者的主体信息与入驻状态。 */
|
/** 当前创作者的主体信息与入驻状态。 */
|
||||||
@Operation(summary = "当前创作者的主体信息与入驻状态")
|
@Operation(summary = "我的创作者资料与入驻状态")
|
||||||
@GetMapping("/profile")
|
@GetMapping("/profile")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||||
public Result<CreatorCenterProfileVO> profile() {
|
public Result<CreatorCenterProfileVO> profile() {
|
||||||
@@ -48,7 +48,7 @@ public class CreatorCenterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 我的作品列表(含最近一次提交的审核进度)。 */
|
/** 我的作品列表(含最近一次提交的审核进度)。 */
|
||||||
@Operation(summary = "我的作品列表(含最近一次提交的审核进度)")
|
@Operation(summary = "我的作品列表(含最近一次审核进度)")
|
||||||
@GetMapping("/dramas/page")
|
@GetMapping("/dramas/page")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||||
public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
|
public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
|
||||||
@@ -56,7 +56,7 @@ public class CreatorCenterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */
|
/** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */
|
||||||
@Operation(summary = "我的作品详情(他人作品返回 403)")
|
@Operation(summary = "我的作品详情")
|
||||||
@GetMapping("/dramas/{id}")
|
@GetMapping("/dramas/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||||
public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) {
|
public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) {
|
||||||
@@ -64,7 +64,7 @@ public class CreatorCenterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */
|
/** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */
|
||||||
@Operation(summary = "我的通知(入驻审核 + 内容审核结果)")
|
@Operation(summary = "我的通知(入驻与内容审核结果)")
|
||||||
@GetMapping("/notices")
|
@GetMapping("/notices")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||||
public Result<List<CreatorCenterNoticeVO>> notices() {
|
public Result<List<CreatorCenterNoticeVO>> notices() {
|
||||||
@@ -72,7 +72,7 @@ public class CreatorCenterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */
|
/** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */
|
||||||
@Operation(summary = "我的数据看板(播放/完播/收藏/评论/解锁收入,只读)")
|
@Operation(summary = "我的数据看板(播放 / 完播 / 收藏 / 评论 / 解锁收入)")
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:stat:view')")
|
@PreAuthorize("@ss.hasPermission('creator-center:stat:view')")
|
||||||
public Result<Map<String, Object>> dashboard() {
|
public Result<Map<String, Object>> dashboard() {
|
||||||
@@ -83,7 +83,7 @@ public class CreatorCenterController {
|
|||||||
* 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。
|
* 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。
|
||||||
* 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。
|
* 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "代看目标下拉数据源(仅持 creator-center:impersonate 可调)")
|
@Operation(summary = "代看可选创作者列表")
|
||||||
@GetMapping("/impersonation/targets")
|
@GetMapping("/impersonation/targets")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:impersonate')")
|
@PreAuthorize("@ss.hasPermission('creator-center:impersonate')")
|
||||||
public Result<List<CreatorCenterTargetVO>> impersonationTargets() {
|
public Result<List<CreatorCenterTargetVO>> impersonationTargets() {
|
||||||
|
|||||||
+11
-11
@@ -36,7 +36,7 @@ import java.util.List;
|
|||||||
* 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析,
|
* 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析,
|
||||||
* 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。
|
* 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。
|
||||||
*/
|
*/
|
||||||
@Tag(name = "创作者作品")
|
@Tag(name = "创作者中心-内容与收费")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/creator-center")
|
@RequestMapping("/admin/creator-center")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -54,7 +54,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑作品信息(仅草稿 / 已下线可改)。 */
|
/** 编辑作品信息(仅草稿 / 已下线可改)。 */
|
||||||
@Operation(summary = "编辑作品信息(仅草稿/已下线可改)")
|
@Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)")
|
||||||
@PutMapping("/dramas/{id}")
|
@PutMapping("/dramas/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||||
@OperationLog(module = "创作者中心", action = "编辑作品")
|
@OperationLog(module = "创作者中心", action = "编辑作品")
|
||||||
@@ -72,7 +72,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 新增分集(集号不传自动排到最后)。 */
|
/** 新增分集(集号不传自动排到最后)。 */
|
||||||
@Operation(summary = "新增分集(不传集号自动排到最后)")
|
@Operation(summary = "新增分集")
|
||||||
@PostMapping("/dramas/{id}/episodes")
|
@PostMapping("/dramas/{id}/episodes")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||||
@OperationLog(module = "创作者中心", action = "新增分集")
|
@OperationLog(module = "创作者中心", action = "新增分集")
|
||||||
@@ -81,7 +81,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑分集(时长 / 封面 / 片源 / 排序)。 */
|
/** 编辑分集(时长 / 封面 / 片源 / 排序)。 */
|
||||||
@Operation(summary = "编辑分集(时长/封面/片源/排序)")
|
@Operation(summary = "编辑分集(时长 / 封面 / 片源)")
|
||||||
@PutMapping("/dramas/{id}/episodes/{episodeId}")
|
@PutMapping("/dramas/{id}/episodes/{episodeId}")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||||
@OperationLog(module = "创作者中心", action = "编辑分集")
|
@OperationLog(module = "创作者中心", action = "编辑分集")
|
||||||
@@ -102,7 +102,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 批量排序:body 里按新顺序给出分集 id。 */
|
/** 批量排序:body 里按新顺序给出分集 id。 */
|
||||||
@Operation(summary = "批量排序分集")
|
@Operation(summary = "分集批量排序")
|
||||||
@PutMapping("/dramas/{id}/episodes/sort")
|
@PutMapping("/dramas/{id}/episodes/sort")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||||
@OperationLog(module = "创作者中心", action = "调整分集顺序")
|
@OperationLog(module = "创作者中心", action = "调整分集顺序")
|
||||||
@@ -112,7 +112,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */
|
/** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */
|
||||||
@Operation(summary = "提交审核(CREATE/UPDATE/PUBLISH/OFFLINE)")
|
@Operation(summary = "提交审核(CREATE / UPDATE / PUBLISH / OFFLINE)")
|
||||||
@PostMapping("/dramas/{id}/submit")
|
@PostMapping("/dramas/{id}/submit")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||||
@OperationLog(module = "创作者中心", action = "提交审核")
|
@OperationLog(module = "创作者中心", action = "提交审核")
|
||||||
@@ -121,7 +121,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */
|
/** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */
|
||||||
@Operation(summary = "撤回提交(撤回后作品退回草稿)")
|
@Operation(summary = "撤回自己的提交")
|
||||||
@PostMapping("/submissions/{id}/withdraw")
|
@PostMapping("/submissions/{id}/withdraw")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||||
@OperationLog(module = "创作者中心", action = "撤回提交")
|
@OperationLog(module = "创作者中心", action = "撤回提交")
|
||||||
@@ -130,7 +130,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 我的提交历史(审核进度)。 */
|
/** 我的提交历史(审核进度)。 */
|
||||||
@Operation(summary = "我的提交历史(审核进度)")
|
@Operation(summary = "我的提交历史")
|
||||||
@GetMapping("/dramas/{id}/submissions")
|
@GetMapping("/dramas/{id}/submissions")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||||
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
|
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
|
||||||
@@ -138,7 +138,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */
|
/** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */
|
||||||
@Operation(summary = "挂载分集视频产物(产物必须是本人上传)")
|
@Operation(summary = "把上传产物挂到自己的分集上")
|
||||||
@PostMapping("/dramas/{id}/episodes/{episodeId}/attach")
|
@PostMapping("/dramas/{id}/episodes/{episodeId}/attach")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:upload')")
|
@PreAuthorize("@ss.hasPermission('creator-center:upload')")
|
||||||
@OperationLog(module = "创作者中心", action = "挂载分集视频")
|
@OperationLog(module = "创作者中心", action = "挂载分集视频")
|
||||||
@@ -149,7 +149,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 分集收费与试看秒数:读现状。 */
|
/** 分集收费与试看秒数:读现状。 */
|
||||||
@Operation(summary = "分集收费与试看秒数:读取")
|
@Operation(summary = "分集收费与试看秒数(读)")
|
||||||
@GetMapping("/dramas/{id}/prices")
|
@GetMapping("/dramas/{id}/prices")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||||
public Result<PriceRespVO> priceDetail(@PathVariable Long id) {
|
public Result<PriceRespVO> priceDetail(@PathVariable Long id) {
|
||||||
@@ -157,7 +157,7 @@ public class CreatorContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */
|
/** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */
|
||||||
@Operation(summary = "分集收费与试看秒数:保存(改完立即影响付费判定)")
|
@Operation(summary = "分集收费与试看秒数(保存)")
|
||||||
@PutMapping("/dramas/{id}/prices")
|
@PutMapping("/dramas/{id}/prices")
|
||||||
@PreAuthorize("@ss.hasPermission('creator-center:price:update')")
|
@PreAuthorize("@ss.hasPermission('creator-center:price:update')")
|
||||||
@OperationLog(module = "创作者中心", action = "配置分集价格")
|
@OperationLog(module = "创作者中心", action = "配置分集价格")
|
||||||
|
|||||||
+23
-4
@@ -3,6 +3,9 @@
|
|||||||
package com.fruitshorts.admin.controller;
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
import com.fruitshorts.admin.service.DashboardService;
|
import com.fruitshorts.admin.service.DashboardService;
|
||||||
|
import com.fruitshorts.admin.vo.DashboardDistributionRespVO;
|
||||||
|
import com.fruitshorts.admin.vo.DashboardTrendRespVO;
|
||||||
|
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -20,7 +23,7 @@ import java.util.Map;
|
|||||||
* 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里;
|
* 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里;
|
||||||
* 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。
|
* 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。
|
||||||
*/
|
*/
|
||||||
@Tag(name = "数据看板")
|
@Tag(name = "后台-数据看板")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/dashboard")
|
@RequestMapping("/admin/dashboard")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -29,7 +32,7 @@ public class DashboardAdminController {
|
|||||||
private final DashboardService dashboardService;
|
private final DashboardService dashboardService;
|
||||||
|
|
||||||
/** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */
|
/** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */
|
||||||
@Operation(summary = "总览:用户/内容/播放/收入/成本")
|
@Operation(summary = "运营大盘总览(用户 / 内容 / 播放 / 收入 / 成本)")
|
||||||
@GetMapping("/overview")
|
@GetMapping("/overview")
|
||||||
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
||||||
public Result<Map<String, Object>> overview() {
|
public Result<Map<String, Object>> overview() {
|
||||||
@@ -37,10 +40,26 @@ public class DashboardAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 转码看板:队列积压与失败任务明细。 */
|
/** 转码看板:队列积压与失败任务明细。 */
|
||||||
@Operation(summary = "转码看板:队列积压与失败任务明细")
|
@Operation(summary = "转码看板(队列积压与失败任务)")
|
||||||
@GetMapping("/transcode")
|
@GetMapping("/transcode")
|
||||||
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
||||||
public Result<Map<String, Object>> transcode() {
|
public Result<TranscodeDashboardRespVO> transcode() {
|
||||||
return Result.ok(dashboardService.transcode());
|
return Result.ok(dashboardService.transcode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 近 7 天每日趋势(用户注册 / 播放事件 / 转码任务)。 */
|
||||||
|
@Operation(summary = "大盘趋势(近 7 天每日数据,给折线图用)")
|
||||||
|
@GetMapping("/trend")
|
||||||
|
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
||||||
|
public Result<DashboardTrendRespVO> trend() {
|
||||||
|
return Result.ok(dashboardService.trend());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分布:短剧状态 / 转码状态 / 片源覆盖 / 待处理审核。 */
|
||||||
|
@Operation(summary = "大盘分布(给饼图用)")
|
||||||
|
@GetMapping("/distribution")
|
||||||
|
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
||||||
|
public Result<DashboardDistributionRespVO> distribution() {
|
||||||
|
return Result.ok(dashboardService.distribution());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -21,7 +21,7 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* 字典接口:题材、内容状态等下拉数据。
|
* 字典接口:题材、内容状态等下拉数据。
|
||||||
*/
|
*/
|
||||||
@Tag(name = "数据字典")
|
@Tag(name = "后台-字典")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/dicts")
|
@RequestMapping("/admin/dicts")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -29,7 +29,7 @@ public class DictController {
|
|||||||
|
|
||||||
private final DramaCategoryService dramaCategoryService;
|
private final DramaCategoryService dramaCategoryService;
|
||||||
|
|
||||||
@Operation(summary = "题材字典(下拉数据)")
|
@Operation(summary = "短剧分类字典")
|
||||||
@GetMapping("/drama-category")
|
@GetMapping("/drama-category")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:list')")
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
public Result<List<DramaCategoryRespVO>> dramaCategories() {
|
public Result<List<DramaCategoryRespVO>> dramaCategories() {
|
||||||
|
|||||||
+5
-5
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** 后台下载授权管理。 */
|
/** 后台下载授权管理。 */
|
||||||
@Tag(name = "下载管理")
|
@Tag(name = "后台-离线下载管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/downloads")
|
@RequestMapping("/admin/downloads")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -30,14 +30,14 @@ public class DownloadAdminController {
|
|||||||
|
|
||||||
private final DownloadService downloadService;
|
private final DownloadService downloadService;
|
||||||
|
|
||||||
@Operation(summary = "下载授权分页检索(含剧名与集号)")
|
@Operation(summary = "下载授权分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('download:list')")
|
@PreAuthorize("@ss.hasPermission('download:list')")
|
||||||
public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) {
|
public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) {
|
||||||
return Result.ok(downloadService.page(req));
|
return Result.ok(downloadService.page(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "授权与任务分布统计")
|
@Operation(summary = "下载统计")
|
||||||
@GetMapping("/stats")
|
@GetMapping("/stats")
|
||||||
@PreAuthorize("@ss.hasPermission('download:list')")
|
@PreAuthorize("@ss.hasPermission('download:list')")
|
||||||
public Result<Map<String, Object>> stats() {
|
public Result<Map<String, Object>> stats() {
|
||||||
@@ -45,7 +45,7 @@ public class DownloadAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 吊销单条授权:用户侧下次联网即失效。 */
|
/** 吊销单条授权:用户侧下次联网即失效。 */
|
||||||
@Operation(summary = "吊销授权(用户侧下次联网即失效)")
|
@Operation(summary = "吊销单条下载授权")
|
||||||
@PutMapping("/{id}/revoke")
|
@PutMapping("/{id}/revoke")
|
||||||
@PreAuthorize("@ss.hasPermission('download:revoke')")
|
@PreAuthorize("@ss.hasPermission('download:revoke')")
|
||||||
@OperationLog(module = "下载管理", action = "吊销下载授权")
|
@OperationLog(module = "下载管理", action = "吊销下载授权")
|
||||||
@@ -55,7 +55,7 @@ public class DownloadAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 手动触发过期清理,正常情况下由定时任务每天执行。 */
|
/** 手动触发过期清理,正常情况下由定时任务每天执行。 */
|
||||||
@Operation(summary = "手动清理过期授权(默认每天凌晨自动执行)")
|
@Operation(summary = "手动触发过期授权清理")
|
||||||
@PostMapping("/cleanup")
|
@PostMapping("/cleanup")
|
||||||
@PreAuthorize("@ss.hasPermission('download:revoke')")
|
@PreAuthorize("@ss.hasPermission('download:revoke')")
|
||||||
@OperationLog(module = "下载管理", action = "清理过期下载授权")
|
@OperationLog(module = "下载管理", action = "清理过期下载授权")
|
||||||
|
|||||||
+5
-5
@@ -27,7 +27,7 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。
|
* 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。
|
||||||
*/
|
*/
|
||||||
@Tag(name = "短剧类型")
|
@Tag(name = "后台-短剧分类")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/drama-categories")
|
@RequestMapping("/admin/drama-categories")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -35,14 +35,14 @@ public class DramaCategoryController {
|
|||||||
|
|
||||||
private final DramaCategoryService dramaCategoryService;
|
private final DramaCategoryService dramaCategoryService;
|
||||||
|
|
||||||
@Operation(summary = "短剧类型列表")
|
@Operation(summary = "分类列表")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("@ss.hasPermission('drama:list')")
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
public Result<List<DramaCategoryRespVO>> list() {
|
public Result<List<DramaCategoryRespVO>> list() {
|
||||||
return Result.ok(dramaCategoryService.list());
|
return Result.ok(dramaCategoryService.list());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新增短剧类型")
|
@Operation(summary = "新建分类")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('drama:create')")
|
@PreAuthorize("@ss.hasPermission('drama:create')")
|
||||||
@OperationLog(module = "内容管理", action = "新增短剧类型")
|
@OperationLog(module = "内容管理", action = "新增短剧类型")
|
||||||
@@ -50,7 +50,7 @@ public class DramaCategoryController {
|
|||||||
return Result.ok(dramaCategoryService.create(req));
|
return Result.ok(dramaCategoryService.create(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "编辑短剧类型")
|
@Operation(summary = "编辑分类")
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:update')")
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
@OperationLog(module = "内容管理", action = "编辑短剧类型")
|
@OperationLog(module = "内容管理", action = "编辑短剧类型")
|
||||||
@@ -59,7 +59,7 @@ public class DramaCategoryController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除短剧类型")
|
@Operation(summary = "删除分类")
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:delete')")
|
@PreAuthorize("@ss.hasPermission('drama:delete')")
|
||||||
@OperationLog(module = "内容管理", action = "删除短剧类型")
|
@OperationLog(module = "内容管理", action = "删除短剧类型")
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.content.service.DramaClassificationService;
|
||||||
|
import com.fruitshorts.content.vo.DramaBriefVO;
|
||||||
|
import com.fruitshorts.content.vo.DramaClassificationRespVO;
|
||||||
|
import com.fruitshorts.content.vo.DramaClassificationSaveReqVO;
|
||||||
|
import com.fruitshorts.content.vo.DramaIdsReqVO;
|
||||||
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营分类管理。分类与短剧类型(drama-categories)是两套独立概念,
|
||||||
|
* 分类可自定义增删,并关联任意多部短剧。
|
||||||
|
*/
|
||||||
|
@Tag(name = "后台-运营分类")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/drama-classifications")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DramaClassificationController {
|
||||||
|
|
||||||
|
private final DramaClassificationService classificationService;
|
||||||
|
|
||||||
|
@Operation(summary = "分类列表(带关联数量)")
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
|
public Result<List<DramaClassificationRespVO>> list() {
|
||||||
|
return Result.ok(classificationService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "新建分类")
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:create')")
|
||||||
|
@OperationLog(module = "内容管理", action = "新增分类")
|
||||||
|
public Result<Long> create(@Valid @RequestBody DramaClassificationSaveReqVO req) {
|
||||||
|
return Result.ok(classificationService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "编辑分类")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
|
@OperationLog(module = "内容管理", action = "编辑分类")
|
||||||
|
public Result<Void> update(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody DramaClassificationSaveReqVO req) {
|
||||||
|
classificationService.update(id, req);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除分类")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:delete')")
|
||||||
|
@OperationLog(module = "内容管理", action = "删除分类")
|
||||||
|
public Result<Void> delete(@PathVariable Long id) {
|
||||||
|
classificationService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分类下已关联的短剧")
|
||||||
|
@GetMapping("/{id}/dramas")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
|
public Result<PageResult<DramaBriefVO>> dramas(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
|
@RequestParam(defaultValue = "10") int pageSize) {
|
||||||
|
return Result.ok(classificationService.pageDramas(id, keyword, pageIndex, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "尚未关联到该分类的短剧")
|
||||||
|
@GetMapping("/{id}/dramas/candidates")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
|
public Result<PageResult<DramaBriefVO>> candidates(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
|
@RequestParam(defaultValue = "10") int pageSize) {
|
||||||
|
return Result.ok(classificationService.pageCandidates(id, keyword, pageIndex, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "批量关联短剧")
|
||||||
|
@PostMapping("/{id}/dramas")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
|
@OperationLog(module = "内容管理", action = "分类关联短剧")
|
||||||
|
public Result<Integer> bindDramas(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody DramaIdsReqVO req) {
|
||||||
|
return Result.ok(classificationService.bindDramas(id, req.getDramaIds()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "移除分类下的短剧")
|
||||||
|
@DeleteMapping("/{id}/dramas/{dramaId}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
|
@OperationLog(module = "内容管理", action = "分类移除短剧")
|
||||||
|
public Result<Void> unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) {
|
||||||
|
classificationService.unbindDrama(id, dramaId);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-8
@@ -6,12 +6,14 @@ import com.fruitshorts.common.pojo.PageResult;
|
|||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
|
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.content.vo.DramaPageReqVO;
|
import com.fruitshorts.content.vo.DramaPageReqVO;
|
||||||
import com.fruitshorts.content.vo.DramaRespVO;
|
import com.fruitshorts.content.vo.DramaRespVO;
|
||||||
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
||||||
import com.fruitshorts.content.vo.DramaStatusReqVO;
|
import com.fruitshorts.content.vo.DramaStatusReqVO;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
|
||||||
import com.fruitshorts.framework.log.OperationLog;
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -27,27 +29,34 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Tag(name = "短剧管理")
|
@Tag(name = "后台-短剧管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/dramas")
|
@RequestMapping("/admin/dramas")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class DramaController {
|
public class DramaController {
|
||||||
|
|
||||||
private final DramaService dramaService;
|
private final DramaService dramaService;
|
||||||
|
private final CommentService commentService;
|
||||||
|
private final EpisodeService episodeService;
|
||||||
|
|
||||||
@Operation(summary = "短剧分页检索(含热门、状态筛选)")
|
@Operation(summary = "短剧分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:list')")
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
|
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
|
||||||
return Result.ok(dramaService.page(req));
|
PageResult<DramaRespVO> page = dramaService.page(req);
|
||||||
|
fillInteractionCounts(page.getList());
|
||||||
|
return Result.ok(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "短剧详情")
|
@Operation(summary = "短剧详情")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:list')")
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
public Result<DramaRespVO> get(@PathVariable Long id) {
|
public Result<DramaRespVO> get(@PathVariable Long id) {
|
||||||
return Result.ok(dramaService.get(id));
|
DramaRespVO drama = dramaService.get(id);
|
||||||
|
fillInteractionCounts(List.of(drama));
|
||||||
|
return Result.ok(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新建短剧")
|
@Operation(summary = "新建短剧")
|
||||||
@@ -95,7 +104,7 @@ public class DramaController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "驳回短剧")
|
@Operation(summary = "审核驳回")
|
||||||
@PostMapping("/{id}/reject")
|
@PostMapping("/{id}/reject")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:review')")
|
@PreAuthorize("@ss.hasPermission('drama:review')")
|
||||||
@OperationLog(module = "内容管理", action = "驳回短剧")
|
@OperationLog(module = "内容管理", action = "驳回短剧")
|
||||||
@@ -104,7 +113,7 @@ public class DramaController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "短剧上线")
|
@Operation(summary = "上线")
|
||||||
@PostMapping("/{id}/publish")
|
@PostMapping("/{id}/publish")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:review')")
|
@PreAuthorize("@ss.hasPermission('drama:review')")
|
||||||
@OperationLog(module = "内容管理", action = "短剧上线")
|
@OperationLog(module = "内容管理", action = "短剧上线")
|
||||||
@@ -113,7 +122,7 @@ public class DramaController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "短剧下线")
|
@Operation(summary = "下线")
|
||||||
@PostMapping("/{id}/offline")
|
@PostMapping("/{id}/offline")
|
||||||
@PreAuthorize("@ss.hasPermission('drama:review')")
|
@PreAuthorize("@ss.hasPermission('drama:review')")
|
||||||
@OperationLog(module = "内容管理", action = "短剧下线")
|
@OperationLog(module = "内容管理", action = "短剧下线")
|
||||||
@@ -133,4 +142,17 @@ public class DramaController {
|
|||||||
dramaService.changeStatus(id, action, req == null ? null : req.getReason(),
|
dramaService.changeStatus(id, action, req == null ? null : req.getReason(),
|
||||||
SecurityUtils.getUserId(), SecurityUtils.getUsername());
|
SecurityUtils.getUserId(), SecurityUtils.getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void fillInteractionCounts(List<DramaRespVO> dramas) {
|
||||||
|
if (dramas == null || dramas.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Long> ids = dramas.stream().map(DramaRespVO::getId).toList();
|
||||||
|
Map<Long, Long> likeCounts = episodeService.sumLikeCountsByDramas(ids);
|
||||||
|
Map<Long, Long> commentCounts = commentService.countByDramas(ids);
|
||||||
|
dramas.forEach(drama -> {
|
||||||
|
drama.setLikeCount(likeCounts.getOrDefault(drama.getId(), 0L));
|
||||||
|
drama.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.content.service.DramaPlaylistCategoryService;
|
||||||
|
import com.fruitshorts.content.vo.DramaBriefVO;
|
||||||
|
import com.fruitshorts.content.vo.DramaIdsReqVO;
|
||||||
|
import com.fruitshorts.content.vo.DramaPlaylistCategoryRespVO;
|
||||||
|
import com.fruitshorts.content.vo.DramaPlaylistCategorySaveReqVO;
|
||||||
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 剧单分类管理:分类直接关联短剧,没有「剧单」这一层。
|
||||||
|
* 与「类型」「分类管理」均无关。
|
||||||
|
*/
|
||||||
|
@Tag(name = "后台-剧单分类")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/drama-playlist-categories")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DramaPlaylistCategoryController {
|
||||||
|
|
||||||
|
private final DramaPlaylistCategoryService categoryService;
|
||||||
|
|
||||||
|
@Operation(summary = "剧单分类列表")
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
|
public Result<List<DramaPlaylistCategoryRespVO>> list() {
|
||||||
|
return Result.ok(categoryService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "新建剧单分类")
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:create')")
|
||||||
|
@OperationLog(module = "内容管理", action = "新增剧单分类")
|
||||||
|
public Result<Long> create(@Valid @RequestBody DramaPlaylistCategorySaveReqVO req) {
|
||||||
|
return Result.ok(categoryService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "编辑剧单分类")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
|
@OperationLog(module = "内容管理", action = "编辑剧单分类")
|
||||||
|
public Result<Void> update(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody DramaPlaylistCategorySaveReqVO req) {
|
||||||
|
categoryService.update(id, req);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除剧单分类")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:delete')")
|
||||||
|
@OperationLog(module = "内容管理", action = "删除剧单分类")
|
||||||
|
public Result<Void> delete(@PathVariable Long id) {
|
||||||
|
categoryService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分类下已关联的短剧")
|
||||||
|
@GetMapping("/{id}/dramas")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
|
public Result<PageResult<DramaBriefVO>> dramas(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
|
@RequestParam(defaultValue = "10") int pageSize) {
|
||||||
|
return Result.ok(categoryService.pageDramas(id, keyword, pageIndex, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "尚未关联到该分类的短剧")
|
||||||
|
@GetMapping("/{id}/dramas/candidates")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||||
|
public Result<PageResult<DramaBriefVO>> candidates(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
|
@RequestParam(defaultValue = "10") int pageSize) {
|
||||||
|
return Result.ok(categoryService.pageCandidates(id, keyword, pageIndex, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "批量关联短剧")
|
||||||
|
@PostMapping("/{id}/dramas")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
|
@OperationLog(module = "内容管理", action = "剧单分类关联短剧")
|
||||||
|
public Result<Integer> bindDramas(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody DramaIdsReqVO req) {
|
||||||
|
return Result.ok(categoryService.bindDramas(id, req.getDramaIds()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "移除分类下的短剧")
|
||||||
|
@DeleteMapping("/{id}/dramas/{dramaId}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('drama:update')")
|
||||||
|
@OperationLog(module = "内容管理", action = "剧单分类移除短剧")
|
||||||
|
public Result<Void> unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) {
|
||||||
|
categoryService.unbindDrama(id, dramaId);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/** 后台权益:检索、人工补发、回收。 */
|
/** 后台权益:检索、人工补发、回收。 */
|
||||||
@Tag(name = "权益管理")
|
@Tag(name = "后台-权益管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/entitlements")
|
@RequestMapping("/admin/entitlements")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -31,7 +31,7 @@ public class EntitlementAdminController {
|
|||||||
|
|
||||||
private final EntitlementService entitlementService;
|
private final EntitlementService entitlementService;
|
||||||
|
|
||||||
@Operation(summary = "权益分页检索")
|
@Operation(summary = "权益分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('entitlement:list')")
|
@PreAuthorize("@ss.hasPermission('entitlement:list')")
|
||||||
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
|
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
|
||||||
@@ -43,7 +43,7 @@ public class EntitlementAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 客服补偿:直接给用户某集/整剧的播放权益。 */
|
/** 客服补偿:直接给用户某集/整剧的播放权益。 */
|
||||||
@Operation(summary = "人工补发权益(客服补偿)")
|
@Operation(summary = "补发权益(客服补偿)")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('entitlement:update')")
|
@PreAuthorize("@ss.hasPermission('entitlement:update')")
|
||||||
@OperationLog(module = "订单管理", action = "人工补发权益")
|
@OperationLog(module = "订单管理", action = "人工补发权益")
|
||||||
@@ -55,7 +55,7 @@ public class EntitlementAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 回收权益:退款、违规处置后用。 */
|
/** 回收权益:退款、违规处置后用。 */
|
||||||
@Operation(summary = "回收权益(退款/违规处置用,播放判定实时生效)")
|
@Operation(summary = "回收权益(退款 / 违规处置)")
|
||||||
@PutMapping("/{id}/revoke")
|
@PutMapping("/{id}/revoke")
|
||||||
@PreAuthorize("@ss.hasPermission('entitlement:update')")
|
@PreAuthorize("@ss.hasPermission('entitlement:update')")
|
||||||
@OperationLog(module = "订单管理", action = "回收权益")
|
@OperationLog(module = "订单管理", action = "回收权益")
|
||||||
|
|||||||
+28
-9
@@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
package com.fruitshorts.admin.controller;
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.content.vo.EpisodeRespVO;
|
import com.fruitshorts.content.vo.EpisodeRespVO;
|
||||||
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
||||||
import com.fruitshorts.content.vo.EpisodeSortReqVO;
|
import com.fruitshorts.content.vo.EpisodeSortReqVO;
|
||||||
import com.fruitshorts.framework.log.OperationLog;
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -24,24 +28,39 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Tag(name = "剧集管理")
|
@Tag(name = "后台-剧集管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/episodes")
|
@RequestMapping("/admin/episodes")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class EpisodeController {
|
public class EpisodeController {
|
||||||
|
|
||||||
private final EpisodeService episodeService;
|
private final EpisodeService episodeService;
|
||||||
|
private final CommentService commentService;
|
||||||
|
private final DramaService dramaService;
|
||||||
|
|
||||||
@Operation(summary = "某剧分集列表")
|
@Operation(summary = "剧集列表(分页)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("@ss.hasPermission('episode:list')")
|
@PreAuthorize("@ss.hasPermission('episode:list')")
|
||||||
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId,
|
public Result<PageResult<EpisodeRespVO>> list(@RequestParam Long dramaId,
|
||||||
@RequestParam(required = false) String status) {
|
@RequestParam(required = false) String status,
|
||||||
return Result.ok(episodeService.listByDrama(dramaId, status));
|
@RequestParam(required = false) Integer pageIndex,
|
||||||
|
@RequestParam(required = false) Integer pageSize) {
|
||||||
|
PageResult<EpisodeRespVO> page = episodeService.pageByDrama(dramaId, status, pageIndex, pageSize);
|
||||||
|
List<EpisodeRespVO> episodes = page.getList();
|
||||||
|
Map<Long, Long> commentCounts = commentService.countByEpisodes(
|
||||||
|
episodes.stream().map(EpisodeRespVO::getId).toList());
|
||||||
|
Drama drama = dramaService.getExisting(dramaId);
|
||||||
|
episodes.forEach(episode -> {
|
||||||
|
episode.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
|
||||||
|
episode.setFavoriteCount(drama.getFavoriteCount());
|
||||||
|
episode.setHeat(drama.getViewCount());
|
||||||
|
});
|
||||||
|
return Result.ok(new PageResult<>(page.getTotal(), episodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新增分集")
|
@Operation(summary = "新建剧集")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||||
@OperationLog(module = "内容管理", action = "新增剧集")
|
@OperationLog(module = "内容管理", action = "新增剧集")
|
||||||
@@ -49,7 +68,7 @@ public class EpisodeController {
|
|||||||
return Result.ok(episodeService.create(req));
|
return Result.ok(episodeService.create(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "编辑分集")
|
@Operation(summary = "编辑剧集")
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||||
@OperationLog(module = "内容管理", action = "编辑剧集")
|
@OperationLog(module = "内容管理", action = "编辑剧集")
|
||||||
@@ -58,7 +77,7 @@ public class EpisodeController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除分集")
|
@Operation(summary = "删除剧集")
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||||
@OperationLog(module = "内容管理", action = "删除剧集")
|
@OperationLog(module = "内容管理", action = "删除剧集")
|
||||||
@@ -67,7 +86,7 @@ public class EpisodeController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "批量排序分集")
|
@Operation(summary = "剧集排序")
|
||||||
@PutMapping("/sort")
|
@PutMapping("/sort")
|
||||||
@PreAuthorize("@ss.hasPermission('episode:sort')")
|
@PreAuthorize("@ss.hasPermission('episode:sort')")
|
||||||
@OperationLog(module = "内容管理", action = "调整剧集顺序")
|
@OperationLog(module = "内容管理", action = "调整剧集顺序")
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.admin.service.EpisodeTranscodeAdminService;
|
||||||
|
import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 后台剧集转码聚合查询。 */
|
||||||
|
@Tag(name = "后台-剧集转码")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/episodes")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EpisodeTranscodeController {
|
||||||
|
|
||||||
|
private final EpisodeTranscodeAdminService service;
|
||||||
|
|
||||||
|
@Operation(summary = "查询分集转码聚合信息")
|
||||||
|
@GetMapping("/{id}/transcode")
|
||||||
|
@PreAuthorize("@ss.hasPermission('episode:list')")
|
||||||
|
public Result<EpisodeTranscodeRespVO> detail(@PathVariable Long id) {
|
||||||
|
return Result.ok(service.detail(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.admin.service.FavoriteAdminService;
|
||||||
|
import com.fruitshorts.admin.vo.AdminFavoriteVO;
|
||||||
|
import com.fruitshorts.admin.vo.FavoriteQueryReqVO;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 后台收藏只读查询入口。 */
|
||||||
|
@Tag(name = "后台-收藏列表")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/favorites")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FavoriteAdminController {
|
||||||
|
private final FavoriteAdminService service;
|
||||||
|
|
||||||
|
/** 按用户、设备、内容和时间分页查询收藏。 */
|
||||||
|
@Operation(summary = "收藏列表分页查询")
|
||||||
|
@GetMapping("/page")
|
||||||
|
@PreAuthorize("@ss.hasPermission('favorite:list')")
|
||||||
|
public Result<PageResult<AdminFavoriteVO>> page(@Valid FavoriteQueryReqVO req) {
|
||||||
|
return Result.ok(service.page(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import com.fruitshorts.system.service.LegalDocumentService;
|
||||||
|
import com.fruitshorts.system.vo.LegalDocumentVO;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台维护服务条款和隐私协议。公开页面由客户端服务按语言输出。
|
||||||
|
*/
|
||||||
|
@Tag(name = "后台-协议")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/legal-documents")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LegalDocumentAdminController {
|
||||||
|
|
||||||
|
private final LegalDocumentService legalDocumentService;
|
||||||
|
|
||||||
|
@Operation(summary = "协议列表(服务条款、隐私协议,各含中英印尼语)")
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
|
||||||
|
public Result<List<LegalDocumentVO>> list() {
|
||||||
|
return Result.ok(legalDocumentService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "保存协议")
|
||||||
|
@PutMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('system:role')")
|
||||||
|
@OperationLog(module = "后台管理", action = "修改服务条款或隐私协议")
|
||||||
|
public Result<Void> save(@RequestBody List<LegalDocumentVO> items) {
|
||||||
|
legalDocumentService.save(items);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 557 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user