Compare commits
494
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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,6 @@ logs/
|
|||||||
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
|
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
|
||||||
data/uploads/*
|
data/uploads/*
|
||||||
!data/uploads/.gitkeep
|
!data/uploads/.gitkeep
|
||||||
|
|
||||||
|
# 部署用的真实环境变量(含密钥),只提交 .env.example
|
||||||
|
.env
|
||||||
@@ -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 # 本机一键冒烟
|
||||||
|
```
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# 先只拷各模块 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 || true
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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,218 @@
|
|||||||
|
# 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
|
||||||
|
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
volumes:
|
||||||
|
# 媒资落盘:三个服务共享同一个卷
|
||||||
|
- ./data/uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
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: ""
|
||||||
|
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}}
|
||||||
|
volumes:
|
||||||
|
# 与两个入口同一个媒资根,否则找不到源文件
|
||||||
|
- ./data/uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
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"
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
|
# 黑盒探活:从外部探测接口可用性与耗时
|
||||||
|
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:
|
||||||
+404
-25
@@ -16,6 +16,29 @@
|
|||||||
|
|
||||||
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。
|
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。
|
||||||
|
|
||||||
|
**ID 字段一律以字符串下发(2026-10-16 J1 起,全局生效)**
|
||||||
|
|
||||||
|
所有 ID 字段(主键与外键,例如 `id` / `dramaId` / `episodeId` / `userId` / `orderId` …)
|
||||||
|
在 JSON 里都是**字符串**,不是数字:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "message": "ok", "data": { "id": "2111012753262120962", "dramaId": "5" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:S0 之后主键是雪花 id,19 位十进制数已超过 JS 的 `Number.MAX_SAFE_INTEGER`
|
||||||
|
(`9007199254740991`)。裸数字下发会被浏览器/IEEE754 客户端四舍五入
|
||||||
|
(实测库里的 `2111012753262120962` 在浏览器里变成 `2111012753262121000`),
|
||||||
|
页面再拿它回查就是 `200002 数据不存在`。
|
||||||
|
|
||||||
|
约定与边界:
|
||||||
|
|
||||||
|
- **id 回传时按字符串传**(路径变量 / 查询参数 / 请求体都接受字符串,服务端自己转 `Long`);
|
||||||
|
- 实现方式:服务端**全局**把 `Long` / `long` 序列化成字符串(`fruitshorts-framework` 的
|
||||||
|
`JacksonLongToStringConfig`),**各模块不再逐字段加注解**;
|
||||||
|
- **例外:分页 `total` 仍是数字** —— 它是条数不是 id,前端分页组件要数字;
|
||||||
|
`Integer` / `int` 字段(`pageIndex` / `pageSize` / 各类计数如 `totalEpisodes`、状态码 `code`)
|
||||||
|
也**保持数字**。
|
||||||
|
|
||||||
**鉴权**:`Authorization: Bearer <token>`。
|
**鉴权**:`Authorization: Bearer <token>`。
|
||||||
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
|
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
|
||||||
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
|
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
|
||||||
@@ -74,6 +97,44 @@ POST /app/auth/register
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**邮箱 + 密码注册(C 端第一批口径)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /app/auth/register
|
||||||
|
{
|
||||||
|
"targetType": "EMAIL", "target": "User@Example.com ", "password": "nonton2026",
|
||||||
|
"deviceId": "d-1", "countryCode": "ID", "agreementVersion": "2026-10-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 规则 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| `targetType=EMAIL` | **`code` 可以不传**(邮箱+密码直接注册);传了就走一次验证码校验(邮箱验证能力保留) |
|
||||||
|
| `targetType=PHONE` | 维持原状:`code` **必填** |
|
||||||
|
| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` |
|
||||||
|
| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) |
|
||||||
|
| 注册频控 | 同 IP / 同设备 24h 默认上限分别为 5 / 3 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
|
||||||
|
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
|
||||||
|
|
||||||
|
频控口径落库在 `app_user.register_ip` / `app_user.register_device_id` 上(按 24h 计数,不新建计数表)。
|
||||||
|
|
||||||
|
注册频控阈值存储在 `sys_config`,可在后台「系统配置」修改:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app.register.max-per-ip-per-day
|
||||||
|
app.register.max-per-device-per-day
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时优先读取 `sys_config`。键缺失或值不是整数时,回退 `application.yml`
|
||||||
|
中的 `fruitshorts.auth.register-max-per-ip-per-day` /
|
||||||
|
`fruitshorts.auth.register-max-per-device-per-day`,缺省为 5 / 3;
|
||||||
|
值小于等于 0 表示对应维度不限制。
|
||||||
|
开发种子配置为 0 / 0,生产建议恢复为 5 / 3。
|
||||||
|
|
||||||
|
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
|
||||||
|
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
|
||||||
|
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
|
||||||
|
|
||||||
响应(登录、刷新同结构):
|
响应(登录、刷新同结构):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -104,10 +165,88 @@ POST /app/auth/register
|
|||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集 |
|
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
|
||||||
| GET | `/app/discover/feed` | 发现页分区 |
|
| GET | `/app/discover/feed` | 发现页分区 |
|
||||||
|
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
|
||||||
| GET | `/app/categories` | 分类列表 |
|
| GET | `/app/categories` | 分类列表 |
|
||||||
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
|
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
|
||||||
|
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
|
||||||
|
|
||||||
|
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
|
||||||
|
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
|
||||||
|
>
|
||||||
|
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串)。
|
||||||
|
> `favoriteCount` 取整部剧;`likeCount` / `commentCount` 取卡片 `preview` 当前集的
|
||||||
|
> `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`);
|
||||||
|
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
|
||||||
|
>
|
||||||
|
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
|
||||||
|
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
|
||||||
|
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
|
||||||
|
|
||||||
|
#### 推荐类型流(`GET /app/feed`)
|
||||||
|
|
||||||
|
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
|
||||||
|
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
|
||||||
|
- `categoryId` 可选;不传返回全部分类。传入后按 `category_ids` 整词匹配,
|
||||||
|
可与 `types` 叠加,只返回同时命中内容类型和分类的已上线短剧。
|
||||||
|
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
|
||||||
|
游标短剧必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
|
||||||
|
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
|
||||||
|
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
|
||||||
|
|
||||||
|
#### 短剧详情响应(`GET /app/dramas/{id}`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0, "message": "ok",
|
||||||
|
"data": {
|
||||||
|
"id": "5",
|
||||||
|
"title": "命运的相遇",
|
||||||
|
"synopsis": "一次意外的相遇,让两个世界的人走到一起。",
|
||||||
|
"cover": "/files/drama/202610/cover.jpg",
|
||||||
|
"genre": "都市 · 情感",
|
||||||
|
"category": "都市",
|
||||||
|
"totalEpisodes": 80,
|
||||||
|
"freeEpisodes": 10,
|
||||||
|
"unlockCoin": 30,
|
||||||
|
"isHot": true,
|
||||||
|
"viewCount": "12400",
|
||||||
|
"likeCount": "3200",
|
||||||
|
"favoriteCount": "1800",
|
||||||
|
"commentCount": "96",
|
||||||
|
"episodes": [
|
||||||
|
{
|
||||||
|
"id": "2111012753262120962",
|
||||||
|
"number": 1,
|
||||||
|
"title": "第 1 集",
|
||||||
|
"duration": "03:00",
|
||||||
|
"durationSec": 180,
|
||||||
|
"isFree": true,
|
||||||
|
"locked": false,
|
||||||
|
"unlockCoin": 0,
|
||||||
|
"likeCount": "45",
|
||||||
|
"commentCount": "8",
|
||||||
|
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
详情里的互动数量:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `likeCount` | Long(JSON 字符串) | 剧级历史汇总,保留兼容;新客户端按集使用 `episodes[].likeCount` |
|
||||||
|
| `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 |
|
||||||
|
| `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 |
|
||||||
|
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
|
||||||
|
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
|
||||||
|
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
|
||||||
|
|
||||||
|
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
|
||||||
|
客户端需要转成数字再展示(与 `viewCount` 相同)。
|
||||||
|
|
||||||
### 1.4 播放(已实现,链路已重做)
|
### 1.4 播放(已实现,链路已重做)
|
||||||
|
|
||||||
@@ -125,7 +264,9 @@ POST /app/auth/register
|
|||||||
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
|
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
|
||||||
"qualities": ["AUTO"],
|
"qualities": ["AUTO"],
|
||||||
"unlockCoin": 0,
|
"unlockCoin": 0,
|
||||||
"previewSeconds": 0
|
"previewSeconds": 0,
|
||||||
|
"likeCount": "45",
|
||||||
|
"commentCount": "8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -179,8 +320,9 @@ POST /app/auth/register
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 |
|
| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 |
|
||||||
| GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 |
|
| GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 |
|
||||||
|
| GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 |
|
||||||
| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) |
|
| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) |
|
||||||
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `COMMENT` |
|
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 |
|
||||||
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
|
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
|
||||||
|
|
||||||
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
|
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
|
||||||
@@ -196,24 +338,155 @@ POST /app/auth/register
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/app/wallet` | 金币余额 + 最近流水 |
|
| GET | `/app/wallet` | 金币余额 + 最近流水 |
|
||||||
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
|
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
|
||||||
|
| GET | `/app/products` | 充值商品列表(只返回上架商品,`type` 可选 `COIN_PACK`/`VIP`) |
|
||||||
|
| POST | `/app/orders` | 创建充值订单(`body` 传 `productId`,返回 `CREATED` 订单,等待支付) |
|
||||||
| GET | `/app/orders` | 我的订单 |
|
| GET | `/app/orders` | 我的订单 |
|
||||||
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据。**本轮只留端口不接商店**,会返回 `200003 尚未配置` |
|
| POST | `/app/orders/verify` | 提交 Google Play 票据:服务端校验 → 建单 → 发权益(详见下方说明) |
|
||||||
|
|
||||||
|
> `GET /app/products` 只返回 `status=1`(上架)的商品,按 `sort` 升序、`id` 降序。
|
||||||
|
> `POST /app/orders` 请求体 `{ "productId": 123 }`,商品下架时返回 `400 商品已下架`;
|
||||||
|
> 订单 `coinAmount = coinAmount + bonusCoin`(与商店票据口径一致),状态为 `CREATED`。
|
||||||
|
> VIP 商品下单后交付时发放 `TYPE_VIP` 权益(`dramaId=0`、`episodeId=0`,永久有效),
|
||||||
|
> 播放/下载的权益判定在 `EntitlementPort` 中增加「存在有效 VIP 权益即放行」的判定。
|
||||||
|
|
||||||
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
|
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
|
||||||
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
|
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
|
||||||
|
|
||||||
### 1.9 创作者(下一批)
|
**`POST /app/orders/verify`(B1-1:Google Play 票据校验闭环)**
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "platform": "GOOGLE_PLAY", "productId": "coin_550", "receipt": "<purchaseToken>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `platform` | `GOOGLE` / `GOOGLE_PLAY`(两者等价,入库统一记 `GOOGLE`);Apple 走 B1-2 |
|
||||||
|
| `productId` | 商店商品 id(Google Play 的 `purchases.products.get` 必需),与本平台 `product.store_product_id` 对得上才发权益 |
|
||||||
|
| `receipt` | Google Play 的 `purchaseToken` |
|
||||||
|
|
||||||
|
行为与错误码:
|
||||||
|
|
||||||
|
| 场景 | 返回 |
|
||||||
|
| --- | --- |
|
||||||
|
| 校验通过 | `code=0`,`data` 是订单(`status=DELIVERED`、`coinAmount`=商品金币+赠送、`storeTransactionId`=Google `orderId`) |
|
||||||
|
| 同一票据**重复**提交(同用户) | `code=0` 返回**既有订单**,不重复发币、不重复建单 |
|
||||||
|
| 票据已绑定**其他账号** | `403 该票据已绑定其他账号,不能重复使用`(防跨用户重放) |
|
||||||
|
| 票据无效 / 未购买 / 商品不一致 / 缺 orderId | `200003` + 具体原因;**不产生订单、不发权益** |
|
||||||
|
| 商品未在后台配置 | `200002 该商品未在后台配置` |
|
||||||
|
| 平台不在支持范围 | `400 不支持的商店平台` |
|
||||||
|
| 商店未配置(默认 `verifier=disabled`) | `200003 Apple / Google 商店校验尚未配置`(与 B1-1 之前的行为一致) |
|
||||||
|
|
||||||
|
幂等键是 `(platform, store_transaction_id)`(DB 唯一索引 `uk_orders_store_txn`),
|
||||||
|
重复/并发提交由数据库兜底;票据审计写 `store_receipt`(唯一索引 `uk_store_receipt(platform, transaction_id)`),
|
||||||
|
B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service account JSON)走配置/环境变量,不入库。
|
||||||
|
|
||||||
|
**恢复购买**:客户端换设备后重新提交同一个 `purchaseToken`(或调用 `GET /app/orders` 拉已购列表),
|
||||||
|
服务端按 `(platform, storeTransactionId)` 识别"权益已发过",**返回既有订单、不重复发币**;
|
||||||
|
若商店侧有购买而本地没有订单(例如换机前没同步),会**按校验结果补单落库**再发放,不需要人工介入。
|
||||||
|
|
||||||
|
### 1.9 创作者入驻、启动配置与公告(C 端,已实现)
|
||||||
|
|
||||||
|
> 说明 1:本节原来把 `/app/orders`、`/app/wallet`、`/app/episodes/{id}/unlock` 也列在这里,
|
||||||
|
> 那三条**属「金币与订单」,已在 §1.8 实现并冻结**,因此已从本节移出。
|
||||||
|
>
|
||||||
|
> 说明 2(**口径变更 2026-10-16**):**创作者中心(工作台 / 作品管理 / 数据看板)由 Web 端承载** ——
|
||||||
|
> 创作者登录后台管理使用,见 §2.10;**App 客户端不提供创作者中心**。
|
||||||
|
> 因此本节**已移除** `GET /app/creator/works`、`GET /app/creator/stats` 两条原定义。
|
||||||
|
> C 端只保留「入驻申请」+「启动配置」+「系统公告」,下面三条即全部。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 鉴权 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| POST | `/app/creator/apply` | 登录 | 创作者入驻申请(**幂等**:重复提交返回当前状态,不重复建档) |
|
||||||
|
| GET | `/app/config` | **匿名** | 启动配置(协议版本、最低版本、开关;**只下发白名单键**) |
|
||||||
|
| GET | `/app/notices` | **匿名** | 系统公告(无数据返回空列表,不是 404) |
|
||||||
|
|
||||||
|
#### 1.9.1 `GET /app/config`(匿名)
|
||||||
|
|
||||||
|
客户端**登录前**就要拿,用于:协议版本校验、强制更新判断、功能开关。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0, "message": "ok",
|
||||||
|
"data": {
|
||||||
|
"agreementVersion": "2026-10-01",
|
||||||
|
"minAppVersion": "1.0.0",
|
||||||
|
"forceUpdate": false,
|
||||||
|
"noticeEnabled": true,
|
||||||
|
"coinUnlockEnabled": true,
|
||||||
|
"downloadEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 键(对客户端字段) | 后台配置键 | 契约默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `agreementVersion` | `app.agreement.version` | `2026-10-01` | 当前协议版本;`/app/creator/apply` 必须与它一致 |
|
||||||
|
| `minAppVersion` | `app.min.version` | `1.0.0` | 低于它客户端应提示升级 |
|
||||||
|
| `forceUpdate` | `app.force.update` | `false` | 是否强制更新 |
|
||||||
|
| `noticeEnabled` | `app.notice.enabled` | `true` | 公告入口开关 |
|
||||||
|
| `coinUnlockEnabled` | `app.coin.unlock.enabled` | `true` | 金币解锁开关 |
|
||||||
|
| `downloadEnabled` | `app.download.enabled` | `true` | 离线下载开关 |
|
||||||
|
|
||||||
|
**口径**:**只下发上述白名单键**(服务端硬编码白名单),后台配置里的其它键(可能含内部开关)
|
||||||
|
**一律不下发**;库里缺键时用「契约默认值」兜底,**不返回 null 让客户端自己猜默认值**。
|
||||||
|
|
||||||
|
#### 1.9.2 `GET /app/notices`(匿名)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /app/notices?pageIndex=1&pageSize=20
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `pageIndex` | int | 否 | 默认 1 |
|
||||||
|
| `pageSize` | int | 否 | 默认 20,最大 50 |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0, "message": "ok",
|
||||||
|
"data": {
|
||||||
|
"total": 1,
|
||||||
|
"list": [
|
||||||
|
{ "id": 2110123456789, "title": "开服公告", "content": "…", "type": "SYSTEM",
|
||||||
|
"publishAt": "2026-10-16 10:00:00", "expireAt": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**口径**:只返回 `status=1`(启用)且 `target ∈ {ALL, USER}`、`publish_at <= 现在`、
|
||||||
|
`expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。
|
||||||
|
**表里没有数据时返回 `total=0, list=[]`(HTTP 与 code 都是成功),不是 404。**
|
||||||
|
|
||||||
|
#### 1.9.3 `POST /app/creator/apply`(登录)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "星野工作室", "type": "COMPANY",
|
||||||
|
"contactName": "张三", "contactMobile": "08123456789", "contactEmail": "creator@example.com",
|
||||||
|
"countryCode": "ID", "agreementVersion": "2026-10-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 校验 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/app/orders` | 我的订单 |
|
| `name` | ✅ | 1–128 字 |
|
||||||
| GET | `/app/wallet` | 金币余额与流水 |
|
| `type` | 否 | `INDIVIDUAL`(默认)/ `COMPANY` |
|
||||||
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集 |
|
| `contactName` / `contactMobile` / `contactEmail` | 否 | 只是联系方式,不参与鉴权 |
|
||||||
| POST | `/app/creator/apply` | 创作者入驻 |
|
| `countryCode` | 否 | 默认 `ID` |
|
||||||
| GET | `/app/creator/works` | 我的作品与审核状态 |
|
| `agreementVersion` | ✅ | **必须等于 `/app/config` 的 `agreementVersion`**,否则 `400` |
|
||||||
| GET | `/app/creator/stats` | 创作者数据看板 |
|
|
||||||
| GET | `/app/config` | 启动配置(协议版本、开关、最低版本) |
|
**幂等口径(硬要求)**:以「**C 端账号唯一**」防重(DB 唯一索引 `uk_creator_user(user_id) where user_id > 0`):
|
||||||
| GET | `/app/notices` | 系统公告 |
|
|
||||||
|
| 情况 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 首次申请 | 建档,`status=pending`,返回 `{id, code, status, statusText, auditNote, appliedAt}` |
|
||||||
|
| 已申请(pending) | **返回当前状态**,不重复建档、不改动已有记录 |
|
||||||
|
| 已通过(approved) | 返回 `status=approved`(**创作者工作台在 Web 端**,见 §2.10;App 端只提示"已通过,请到创作者后台使用") |
|
||||||
|
| 被驳回 / 冻结 | 返回当前状态与 `auditNote`(是否允许重新申请由运营在后台处理,本期不做二次申请) |
|
||||||
|
| 并发重复提交 | 唯一索引兜底:捕获冲突后**读回已有记录返回**,不报 500 |
|
||||||
|
|
||||||
## 2. 后台管理接口(/admin/**)
|
## 2. 后台管理接口(/admin/**)
|
||||||
|
|
||||||
@@ -260,10 +533,10 @@ POST /app/auth/register
|
|||||||
|
|
||||||
| 方法 | 路径 | 权限点 | 说明 |
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态筛选) |
|
| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态、`dramaTypes[]`、`categoryIds[]` 多选筛选) |
|
||||||
| GET | `/admin/dramas/{id}` | `drama:list` | 详情 |
|
| GET | `/admin/dramas/{id}` | `drama:list` | 详情 |
|
||||||
| POST | `/admin/dramas` | `drama:create` | 新建短剧 |
|
| POST | `/admin/dramas` | `drama:create` | 新建短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
|
||||||
| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧 |
|
| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
|
||||||
| DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 |
|
| DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 |
|
||||||
| POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 |
|
| POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 |
|
||||||
| POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 |
|
| POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 |
|
||||||
@@ -272,6 +545,15 @@ POST /app/auth/register
|
|||||||
| POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 |
|
| POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 |
|
||||||
| GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 |
|
| GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 |
|
||||||
|
|
||||||
|
多选字段说明:
|
||||||
|
|
||||||
|
- 保存优先使用 `dramaTypes[]` / `categoryIds[]`,去重并保持提交顺序;
|
||||||
|
`drama_type` / `category_id` 写第一个值,供旧客户端与旧接口兼容。
|
||||||
|
- 仅传 `dramaType` / `categoryId` 时保持原单值行为。
|
||||||
|
- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`;
|
||||||
|
分类名缺失时回退 `genre`。
|
||||||
|
- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。
|
||||||
|
|
||||||
**剧集**
|
**剧集**
|
||||||
|
|
||||||
| 方法 | 路径 | 权限点 | 说明 |
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
@@ -323,6 +605,7 @@ POST /app/auth/register
|
|||||||
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
|
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
|
||||||
| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 |
|
| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 |
|
||||||
| GET | `/admin/app-users/{id}/watch-history` | `app-user:list` | 观看流水明细(带剧名/集号,客服核对用) |
|
| GET | `/admin/app-users/{id}/watch-history` | `app-user:list` | 观看流水明细(带剧名/集号,客服核对用) |
|
||||||
|
| GET | `/admin/watch-history/page` | `watch-history:list` | 跨用户观看记录分页:keyword、userId、dramaTitle、dramaId、episodeId、source、finished、startTime、endTime |
|
||||||
| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned |
|
| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned |
|
||||||
|
|
||||||
状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。
|
状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。
|
||||||
@@ -340,15 +623,18 @@ POST /app/auth/register
|
|||||||
|
|
||||||
| 方法 | 路径 | 权限点 | 说明 |
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/admin/comments/page` | `comment:list` | 检索评论,`status=pending` 即审核队列 |
|
| GET | `/admin/comments/page` | `comment:list` | 检索评论;`status` 为空返回全部,`pending` 即审核队列 |
|
||||||
| GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 |
|
| GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 |
|
||||||
| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) |
|
| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) |
|
||||||
| DELETE | `/admin/comments/{id}` | `comment:audit` | 删除评论 |
|
| DELETE | `/admin/comments/{id}` | `comment:audit` | 删除评论 |
|
||||||
| GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因) |
|
| GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因),返回举报人、剧名、集号、集标题与目标摘要 |
|
||||||
| PUT | `/admin/reports/{id}/handle` | `report:handle` | 处理工单;结论成立会联动驳回被举报评论,可选择同时封禁被举报用户 |
|
| PUT | `/admin/reports/{id}/handle` | `report:handle` | 处理工单;结论成立会联动驳回被举报评论,可选择同时封禁被举报用户 |
|
||||||
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
|
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
|
||||||
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
|
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
|
||||||
|
|
||||||
|
后台评论行除 `dramaId/episodeId` 外,还返回 `dramaTitle/episodeNo/episodeTitle`;
|
||||||
|
剧或集已删除时对应展示字段留空。后台评论与举报的 `status` 空值均表示全部状态。
|
||||||
|
|
||||||
### 2.6 订单与权益(已实现)
|
### 2.6 订单与权益(已实现)
|
||||||
|
|
||||||
| 方法 | 路径 | 权限点 | 说明 |
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
@@ -363,6 +649,23 @@ POST /app/auth/register
|
|||||||
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
|
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
|
||||||
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) |
|
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) |
|
||||||
|
|
||||||
|
**对账口径(B1-2 起生效,字段名不变,新增两个字段)**
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `totalCount` | 当期本地有效订单数(排除 `CREATED`/`CLOSED`) |
|
||||||
|
| `matchedCount` | 其中能在 `store_receipt` 找到同一 `(platform, transaction_id)` 的笔数 |
|
||||||
|
| `diffCount` | 差异笔数 = 本地缺票据 + 票据无本地订单 + **商店已作废但本地无单** |
|
||||||
|
| `amountSum` | 本地订单金额合计(**本地口径**,不是商店结算口径) |
|
||||||
|
| `storeAmountSum` | 商店侧结算金额。Developer API 不提供(要向 Play Console 财务报告取)→ **无数据源时为 `null`** |
|
||||||
|
| `notes` | 说明差异构成、本次退款回收情况、以及"缺哪些数据源"。**契约强制:取不到的指标用 `null` + `notes` 说明,不许用 0 冒充** |
|
||||||
|
| `status` | 商店侧调用失败时为 `FAILED`(不会写成 `SUCCESS` + 一堆 0) |
|
||||||
|
|
||||||
|
`POST /admin/reconcile/run` 同时会**拉取商店已作废交易并回收权益与金币**(退款/拒付回收),
|
||||||
|
回收是幂等的:同一笔作废交易重复出现在时间窗里,只回收一次。
|
||||||
|
口径细节:Google 一次性商品的作废是**整笔**,拿不到部分金额,因此按整单回收;
|
||||||
|
找不到本地订单的作废交易会计入 `diffCount` 并在 `notes` 里点名。
|
||||||
|
|
||||||
### 2.7 创作者与内容审核(已实现)
|
### 2.7 创作者与内容审核(已实现)
|
||||||
|
|
||||||
| 方法 | 路径 | 权限点 | 说明 |
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
@@ -578,17 +881,93 @@ POST /app/auth/register
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/admin/creator-center/impersonation/targets` | `creator-center:impersonate` | 代看目标下拉的数据源(id / 名称 / 状态) |
|
| GET | `/admin/creator-center/impersonation/targets` | `creator-center:impersonate` | 代看目标下拉的数据源(id / 名称 / 状态) |
|
||||||
|
|
||||||
|
### 2.11 商品(金币包)配置(已实现)
|
||||||
|
|
||||||
|
> 背景:`product` 表 16 个字段早就建好,但后台一直没有入口,运营只能改库;而 C 端 `/app/orders/verify`
|
||||||
|
> 依赖 `store_product_id` 对齐商店商品(对不上就是 `200002 该商品未在后台配置`)。本节补齐 CRUD。
|
||||||
|
> **不改表结构。**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/admin/products/page` | `product:list` | 分页检索(`type` / `status` / `platform` / `keyword`,分页沿用 `{total,list}`) |
|
||||||
|
| GET | `/admin/products/{id}` | `product:list` | 商品详情 |
|
||||||
|
| POST | `/admin/products` | `product:update` | 新建 |
|
||||||
|
| PUT | `/admin/products/{id}` | `product:update` | 编辑 |
|
||||||
|
| PUT | `/admin/products/{id}/status` | `product:update` | 上架 / 下架(`status`:1 上架、0 下架) |
|
||||||
|
| DELETE | `/admin/products/{id}` | `product:update` | 删除(逻辑删除) |
|
||||||
|
|
||||||
|
**字段**(与表一致,响应里额外带 `statusText` / `typeText`,前端不再写死中文):
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `code` | 商品编码,**全局唯一** |
|
||||||
|
| `name` | 展示名 |
|
||||||
|
| `type` | `COIN_PACK`(金币包)/ `VIP`(会员,第二阶段) |
|
||||||
|
| `platform` | `ALL` / `GOOGLE` / `APPLE` |
|
||||||
|
| `storeProductId` | 商店商品 id,**同一 `platform` 下唯一**(空串表示未绑定,允许多个) |
|
||||||
|
| `coinAmount` / `bonusCoin` | 金币数与赠送(`type=COIN_PACK` 时 `coinAmount > 0`) |
|
||||||
|
| `priceAmount` / `currency` / `displayPrice` | 价格与展示价 |
|
||||||
|
| `regionCodes` | 生效区域,逗号分隔,空=全区域 |
|
||||||
|
| `vipDays` | 会员天数(`type=VIP` 时 `> 0`) |
|
||||||
|
| `sort` / `status` | 排序 / 上下架 |
|
||||||
|
| `effectiveAt` / `expireAt` | 生效与失效时间(可空) |
|
||||||
|
|
||||||
|
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**(本节不再单独规定)。
|
||||||
|
|
||||||
|
**校验与错误码**:
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `code` 已存在 | `200001 商品编码已存在:{code}` |
|
||||||
|
| `storeProductId` 在同 `platform` 下已存在 | `200001 该平台下商店商品 id 已被占用:{id}` |
|
||||||
|
| `type=COIN_PACK` 但 `coinAmount <= 0` | `400 金币包的金币数量必须大于 0` |
|
||||||
|
| `type=VIP` 但 `vipDays <= 0` | `400 会员商品的会员天数必须大于 0` |
|
||||||
|
| **`type=VIP` 且要启用**(`status=1`) | `400 会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)` |
|
||||||
|
| 商品不存在 | `200002 商品不存在` |
|
||||||
|
|
||||||
|
> **VIP 口径(2026-10-16 定,与 B1-1「VIP 订阅本轮不做」一致)**:**允许创建、不允许启用**。
|
||||||
|
> 运营可以先录好商品与商店 product id,等 B2 上线后直接上下架;**启用**才是会误导客户端的动作,拦在这里。
|
||||||
|
|
||||||
|
### 2.12 系统公告管理(已实现)
|
||||||
|
|
||||||
|
> 背景:`ops_notice` 表 0 行、契约原先写着"没有产出方";E1 已交付 C 端 `GET /app/notices`(匿名只读)。
|
||||||
|
> 本节补齐后台写入。**不改表结构。**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/admin/notices/page` | `notice:list` | 分页检索(`status` / `target` / `type` / `keyword`) |
|
||||||
|
| GET | `/admin/notices/{id}` | `notice:list` | 公告详情 |
|
||||||
|
| POST | `/admin/notices` | `notice:update` | 新建 |
|
||||||
|
| PUT | `/admin/notices/{id}` | `notice:update` | 编辑 |
|
||||||
|
| PUT | `/admin/notices/{id}/status` | `notice:update` | 发布(`status=1`)/ 下架(`status=0`) |
|
||||||
|
| DELETE | `/admin/notices/{id}` | `notice:update` | 删除(逻辑删除) |
|
||||||
|
|
||||||
|
**字段**:`title` / `content` / `target`(`ALL` / `USER` / `CREATOR`)/ `type`(`SYSTEM` / `AUDIT` / `PAYMENT` / `ACTIVITY`)/
|
||||||
|
`status` / `publishAt` / `expireAt`;响应另带 `statusText` / `targetText` / `typeText`。
|
||||||
|
|
||||||
|
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**。
|
||||||
|
|
||||||
|
**与 C 端口径严格对齐(同一条查询实现)**:C 端 `GET /app/notices` 只返回
|
||||||
|
`status=1` 且 `target ∈ {ALL, USER}`、`publish_at` 为空或 `<= now`、`expire_at` 为空或未过期的公告,
|
||||||
|
按 `publish_at desc, id desc` 排序。**后台发布即 C 端可见;下架或过期后 C 端不可见。**
|
||||||
|
(实现上收敛在 `OpsNoticeService.pageForApp(...)` 一处,避免两端条件漂移。)
|
||||||
|
|
||||||
|
**筛选与错误码**:`title` 必填(1–128);`content` 最长 2000;公告不存在 → `200002 公告不存在`;
|
||||||
|
`status` 只接受 0/1,其他值 → `400`。
|
||||||
|
|
||||||
## 3. 契约与实现状态对照
|
## 3. 契约与实现状态对照
|
||||||
|
|
||||||
|
> 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。
|
||||||
|
|
||||||
| 模块 | 表结构 | 接口 | 代码 |
|
| 模块 | 表结构 | 接口 | 代码 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现 |
|
| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现;**S0 雪花主键**、**S1 Swagger 在线文档** 已完成 |
|
||||||
| 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) |
|
| 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) |
|
||||||
| 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) |
|
| 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) |
|
||||||
| 04 播放器与播放控制 | 已建 | 已冻结 | 票据 + 签名 + 进度 已实现;多码率依赖转码 worker |
|
| 04 播放器与播放控制 | 已建 | 已冻结 | **已实现**:票据 + 签名 + 进度;多码率 HLS 由转码 worker(C1)产出 |
|
||||||
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
|
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
|
||||||
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;分享埋点在下一批 |
|
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** |
|
||||||
| 07 创作者中心 | 已建 | 已冻结 | **已实现**:创作者工作台(我的作品 / 审核进度 / 通知)、内容编辑与分集管理、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;Web 端页面见 2.10,不含提现 / 结算 |
|
| 07 创作者中心 | 已建 | 已冻结 | **已实现**(**在后台管理 Web 端**):创作者工作台(我的作品 / 审核进度 / 通知)、内容编辑与分集管理、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;Web 端页面见 2.10,不含提现 / 结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
|
||||||
| 08 平台运营后台 | 已建 | 已冻结 | **已实现**:后台接口(用户管理、内容治理、订单与权益、创作者与内容审核、数据看板)与后台页面(内容治理 / 订单与权益 / 创作者审核 / 数据看板,见 2.9)全部落地 |
|
| 08 平台运营后台 | 已建 | 已冻结 | **已实现**:后台接口(用户管理、内容治理、订单与权益、创作者与内容审核、数据看板)与后台页面(内容治理 / 订单与权益 / 创作者审核 / 数据看板,见 2.9)全部落地 |
|
||||||
| 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) |
|
| 09 分集收费与支付 | 已建 | 已冻结 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,口径为"预留") |
|
||||||
| 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 |
|
| 10 质量、数据与上线 | 已建 | 已冻结 | **转码 worker(C1)已实现**;**Redis 缓存与限流(C2)、埋点(C3)、压测 未做** |
|
||||||
@@ -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,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 验收,回执需贴门禁原始输出
|
||||||
+24742
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 {
|
||||||
|
|||||||
+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 = "修改管理员状态")
|
||||||
|
|||||||
+6
-6
@@ -34,7 +34,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
|
||||||
@@ -45,21 +45,21 @@ public class AppUserAdminController {
|
|||||||
private final DramaService dramaService;
|
private final DramaService dramaService;
|
||||||
private final EpisodeService episodeService;
|
private final EpisodeService episodeService;
|
||||||
|
|
||||||
@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 +67,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,
|
||||||
@@ -115,7 +115,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 = "修改用户状态")
|
||||||
|
|||||||
+4
-4
@@ -25,7 +25,7 @@ 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
|
||||||
@@ -33,7 +33,7 @@ public class AuthController {
|
|||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
|
||||||
@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) {
|
||||||
@@ -54,14 +54,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 = "修改系统设置")
|
||||||
|
|||||||
+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() {
|
||||||
|
|||||||
+9
-9
@@ -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
|
||||||
@@ -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 = "调整分集顺序")
|
||||||
@@ -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 = "配置分集价格")
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,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 +29,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,7 +37,7 @@ 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<Map<String, Object>> transcode() {
|
||||||
|
|||||||
+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 = "删除短剧类型")
|
||||||
|
|||||||
+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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+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 = "调整剧集顺序")
|
||||||
|
|||||||
+5
-5
@@ -28,7 +28,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
/**
|
/**
|
||||||
* 媒资上传(本地存储版):init 建会话 → chunk 传分片 → complete 合并出地址。
|
* 媒资上传(本地存储版):init 建会话 → chunk 传分片 → complete 合并出地址。
|
||||||
*/
|
*/
|
||||||
@Tag(name = "媒资上传")
|
@Tag(name = "后台-媒资管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/media")
|
@RequestMapping("/admin/media")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -36,7 +36,7 @@ public class MediaController {
|
|||||||
|
|
||||||
private final MediaService mediaService;
|
private final MediaService mediaService;
|
||||||
|
|
||||||
@Operation(summary = "建上传会话(返回 uploadId / chunkSize / totalChunks)")
|
@Operation(summary = "初始化分片上传")
|
||||||
@PostMapping("/upload/init")
|
@PostMapping("/upload/init")
|
||||||
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
||||||
public Result<UploadInitRespVO> init(@Valid @RequestBody UploadInitReqVO req) {
|
public Result<UploadInitRespVO> init(@Valid @RequestBody UploadInitReqVO req) {
|
||||||
@@ -45,7 +45,7 @@ public class MediaController {
|
|||||||
return Result.ok(mediaService.init(req, SecurityUtils.getUserId()));
|
return Result.ok(mediaService.init(req, SecurityUtils.getUserId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "上传分片(可重复传同一片)")
|
@Operation(summary = "上传分片")
|
||||||
@PostMapping("/upload/chunk")
|
@PostMapping("/upload/chunk")
|
||||||
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
||||||
public Result<Integer> uploadChunk(@RequestParam String uploadId,
|
public Result<Integer> uploadChunk(@RequestParam String uploadId,
|
||||||
@@ -56,7 +56,7 @@ public class MediaController {
|
|||||||
return Result.ok(index);
|
return Result.ok(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "合并分片出媒资(同一 uploadId 幂等)")
|
@Operation(summary = "完成上传")
|
||||||
@PostMapping("/upload/complete")
|
@PostMapping("/upload/complete")
|
||||||
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
||||||
public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) {
|
public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) {
|
||||||
@@ -65,7 +65,7 @@ public class MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 断点续传:查上传会话进度(只能查自己发起的会话)。 */
|
/** 断点续传:查上传会话进度(只能查自己发起的会话)。 */
|
||||||
@Operation(summary = "断点续传进度查询(只能查自己发起的会话)")
|
@Operation(summary = "查询上传进度(断点续传)")
|
||||||
@GetMapping("/upload/{uploadId}")
|
@GetMapping("/upload/{uploadId}")
|
||||||
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
|
||||||
public Result<UploadProgressRespVO> progress(@PathVariable String uploadId) {
|
public Result<UploadProgressRespVO> progress(@PathVariable String uploadId) {
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Tag(name = "菜单管理")
|
@Tag(name = "后台-菜单管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/menus")
|
@RequestMapping("/admin/menus")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -38,7 +38,7 @@ public class MenuController {
|
|||||||
return Result.ok(menuService.listAll());
|
return Result.ok(menuService.listAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新增菜单")
|
@Operation(summary = "新建菜单")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('system:menu')")
|
@PreAuthorize("@ss.hasPermission('system:menu')")
|
||||||
@OperationLog(module = "后台管理", action = "新增菜单")
|
@OperationLog(module = "后台管理", action = "新增菜单")
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
// 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.OpsNoticeService;
|
||||||
|
import com.fruitshorts.system.vo.NoticeQueryReqVO;
|
||||||
|
import com.fruitshorts.system.vo.NoticeRespVO;
|
||||||
|
import com.fruitshorts.system.vo.NoticeSaveReqVO;
|
||||||
|
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.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台系统公告管理。
|
||||||
|
*
|
||||||
|
* <p>发布后的可见性与 C 端 `GET /app/notices` **共用同一套筛选实现**
|
||||||
|
* (`OpsNoticeService.pageForApp`):`status=1` + `target ∈ {ALL, USER}` + 已到发布时间 + 未过期。
|
||||||
|
*/
|
||||||
|
@Tag(name = "后台-系统公告")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/notices")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NoticeAdminController {
|
||||||
|
|
||||||
|
private final OpsNoticeService opsNoticeService;
|
||||||
|
|
||||||
|
@Operation(summary = "公告分页查询")
|
||||||
|
@GetMapping("/page")
|
||||||
|
@PreAuthorize("@ss.hasPermission('notice:list')")
|
||||||
|
public Result<PageResult<NoticeRespVO>> page(@Validated NoticeQueryReqVO req) {
|
||||||
|
return Result.ok(opsNoticeService.pageForAdmin(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "公告详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('notice:list')")
|
||||||
|
public Result<NoticeRespVO> detail(@PathVariable Long id) {
|
||||||
|
return Result.ok(opsNoticeService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "新建公告(默认发布,C 端立即可见;返回新公告 id,按 §0 以字符串下发)")
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('notice:update')")
|
||||||
|
@OperationLog(module = "系统公告", action = "新建公告")
|
||||||
|
public Result<Long> create(@Valid @RequestBody NoticeSaveReqVO req) {
|
||||||
|
return Result.ok(opsNoticeService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "编辑公告")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('notice:update')")
|
||||||
|
@OperationLog(module = "系统公告", action = "编辑公告")
|
||||||
|
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody NoticeSaveReqVO req) {
|
||||||
|
opsNoticeService.update(id, req);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "发布 / 下架(下架后 C 端立即不可见)")
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@PreAuthorize("@ss.hasPermission('notice:update')")
|
||||||
|
@OperationLog(module = "系统公告", action = "公告上下架")
|
||||||
|
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
|
||||||
|
opsNoticeService.updateStatus(id, status);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除公告")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('notice:update')")
|
||||||
|
@OperationLog(module = "系统公告", action = "删除公告")
|
||||||
|
public Result<Void> delete(@PathVariable Long id) {
|
||||||
|
opsNoticeService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@Tag(name = "操作日志")
|
@Tag(name = "后台-操作日志")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/operation-logs")
|
@RequestMapping("/admin/operation-logs")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,7 +24,7 @@ public class OperationLogController {
|
|||||||
|
|
||||||
private final OperationLogService operationLogService;
|
private final OperationLogService operationLogService;
|
||||||
|
|
||||||
@Operation(summary = "操作日志分页检索")
|
@Operation(summary = "操作日志分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('log:list')")
|
@PreAuthorize("@ss.hasPermission('log:list')")
|
||||||
public Result<PageResult<OperationLogRespVO>> page(OperationLogPageReqVO req) {
|
public Result<PageResult<OperationLogRespVO>> page(OperationLogPageReqVO req) {
|
||||||
|
|||||||
+5
-5
@@ -26,7 +26,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** 后台订单:检索、补发、退款回收。 */
|
/** 后台订单:检索、补发、退款回收。 */
|
||||||
@Tag(name = "订单管理")
|
@Tag(name = "后台-订单管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/orders")
|
@RequestMapping("/admin/orders")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -34,7 +34,7 @@ public class OrderAdminController {
|
|||||||
|
|
||||||
private final OrderService orderService;
|
private final OrderService orderService;
|
||||||
|
|
||||||
@Operation(summary = "订单分页检索")
|
@Operation(summary = "订单分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||||
public Result<PageResult<Orders>> page(OrderQueryReqVO req) {
|
public Result<PageResult<Orders>> page(OrderQueryReqVO req) {
|
||||||
@@ -49,7 +49,7 @@ public class OrderAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 订单状态流转流水:支付争议时的证据链。 */
|
/** 订单状态流转流水:支付争议时的证据链。 */
|
||||||
@Operation(summary = "订单状态流转流水(支付争议证据链)")
|
@Operation(summary = "订单状态流转流水")
|
||||||
@GetMapping("/{orderNo}/events")
|
@GetMapping("/{orderNo}/events")
|
||||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||||
public Result<List<OrderEvent>> events(@PathVariable String orderNo) {
|
public Result<List<OrderEvent>> events(@PathVariable String orderNo) {
|
||||||
@@ -57,7 +57,7 @@ public class OrderAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */
|
/** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */
|
||||||
@Operation(summary = "订单补发(已交付时幂等返回)")
|
@Operation(summary = "补发订单(已支付未交付重新交付,幂等)")
|
||||||
@PostMapping("/{orderNo}/compensate")
|
@PostMapping("/{orderNo}/compensate")
|
||||||
@PreAuthorize("@ss.hasPermission('order:update')")
|
@PreAuthorize("@ss.hasPermission('order:update')")
|
||||||
@OperationLog(module = "订单管理", action = "补发订单")
|
@OperationLog(module = "订单管理", action = "补发订单")
|
||||||
@@ -66,7 +66,7 @@ public class OrderAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 退款/撤销:回收权益并扣回金币。 */
|
/** 退款/撤销:回收权益并扣回金币。 */
|
||||||
@Operation(summary = "订单退款/撤销(回收权益并扣回金币)")
|
@Operation(summary = "退款 / 撤销(回收权益并扣回金币)")
|
||||||
@PostMapping("/{orderNo}/refund")
|
@PostMapping("/{orderNo}/refund")
|
||||||
@PreAuthorize("@ss.hasPermission('order:update')")
|
@PreAuthorize("@ss.hasPermission('order:update')")
|
||||||
@OperationLog(module = "订单管理", action = "订单退款")
|
@OperationLog(module = "订单管理", action = "订单退款")
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Tag(name = "权限管理")
|
@Tag(name = "后台-权限点")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/permissions")
|
@RequestMapping("/admin/permissions")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,7 +24,7 @@ public class PermissionController {
|
|||||||
private final PermissionService permissionService;
|
private final PermissionService permissionService;
|
||||||
|
|
||||||
/** 按分组返回权限点,直接喂给前端的角色权限配置页。 */
|
/** 按分组返回权限点,直接喂给前端的角色权限配置页。 */
|
||||||
@Operation(summary = "权限点分组列表(角色权限配置页用)")
|
@Operation(summary = "权限点列表(按分组返回)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("@ss.hasPermission('system:role')")
|
@PreAuthorize("@ss.hasPermission('system:role')")
|
||||||
public Result<List<PermissionGroupVO>> groups() {
|
public Result<List<PermissionGroupVO>> groups() {
|
||||||
|
|||||||
+3
-3
@@ -19,7 +19,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/prices")
|
@RequestMapping("/admin/prices")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -28,14 +28,14 @@ public class PriceAdminController {
|
|||||||
private final PriceService priceService;
|
private final PriceService priceService;
|
||||||
|
|
||||||
/** 当前价格配置:整剧默认 + 分集明细。 */
|
/** 当前价格配置:整剧默认 + 分集明细。 */
|
||||||
@Operation(summary = "当前价格配置:整剧默认 + 分集明细")
|
@Operation(summary = "分集价格配置(整剧默认 + 分集明细)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("@ss.hasPermission('price:update')")
|
@PreAuthorize("@ss.hasPermission('price:update')")
|
||||||
public Result<PriceRespVO> detail(@RequestParam Long dramaId) {
|
public Result<PriceRespVO> detail(@RequestParam Long dramaId) {
|
||||||
return Result.ok(priceService.detail(dramaId));
|
return Result.ok(priceService.detail(dramaId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "保存价格配置(改价立即影响播放与下载判定)")
|
@Operation(summary = "保存分集价格配置")
|
||||||
@PutMapping
|
@PutMapping
|
||||||
@PreAuthorize("@ss.hasPermission('price:update')")
|
@PreAuthorize("@ss.hasPermission('price:update')")
|
||||||
@OperationLog(module = "定价管理", action = "配置分集价格")
|
@OperationLog(module = "定价管理", action = "配置分集价格")
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.commerce.service.ProductService;
|
||||||
|
import com.fruitshorts.commerce.vo.ProductQueryReqVO;
|
||||||
|
import com.fruitshorts.commerce.vo.ProductRespVO;
|
||||||
|
import com.fruitshorts.commerce.vo.ProductSaveReqVO;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
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.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台商品(金币包)配置。
|
||||||
|
*
|
||||||
|
* <p>商品是 C 端 `POST /app/orders/verify` 的发币依据(按 `store_product_id` 对齐商店商品),
|
||||||
|
* 所以 `code` 与"同平台 store_product_id"的唯一性、以及 type 与金额的匹配都在服务端强校验。
|
||||||
|
*/
|
||||||
|
@Tag(name = "后台-商品配置")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/products")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductAdminController {
|
||||||
|
|
||||||
|
private final ProductService productService;
|
||||||
|
|
||||||
|
@Operation(summary = "商品分页查询")
|
||||||
|
@GetMapping("/page")
|
||||||
|
@PreAuthorize("@ss.hasPermission('product:list')")
|
||||||
|
public Result<PageResult<ProductRespVO>> page(@Validated ProductQueryReqVO req) {
|
||||||
|
return Result.ok(productService.page(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "商品详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('product:list')")
|
||||||
|
public Result<ProductRespVO> detail(@PathVariable Long id) {
|
||||||
|
return Result.ok(productService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "新建商品(默认下架,需显式上架;返回新商品 id,按 §0 以字符串下发)")
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("@ss.hasPermission('product:update')")
|
||||||
|
@OperationLog(module = "商品配置", action = "新建商品")
|
||||||
|
public Result<Long> create(@Valid @RequestBody ProductSaveReqVO req) {
|
||||||
|
return Result.ok(productService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "编辑商品")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('product:update')")
|
||||||
|
@OperationLog(module = "商品配置", action = "编辑商品")
|
||||||
|
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ProductSaveReqVO req) {
|
||||||
|
productService.update(id, req);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "上架 / 下架(会员商品在订阅上线前不能启用)")
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@PreAuthorize("@ss.hasPermission('product:update')")
|
||||||
|
@OperationLog(module = "商品配置", action = "商品上下架")
|
||||||
|
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
|
||||||
|
productService.updateStatus(id, status);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除商品")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("@ss.hasPermission('product:update')")
|
||||||
|
@OperationLog(module = "商品配置", action = "删除商品")
|
||||||
|
public Result<Void> delete(@PathVariable Long id) {
|
||||||
|
productService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
/** 后台对账:手动触发与历史查看。 */
|
/** 后台对账:手动触发与历史查看。 */
|
||||||
@Tag(name = "对账管理")
|
@Tag(name = "后台-对账")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/reconcile")
|
@RequestMapping("/admin/reconcile")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -28,7 +28,7 @@ public class ReconcileAdminController {
|
|||||||
|
|
||||||
private final ReconcileService reconcileService;
|
private final ReconcileService reconcileService;
|
||||||
|
|
||||||
@Operation(summary = "对账历史分页")
|
@Operation(summary = "对账任务列表")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||||
public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex,
|
public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex,
|
||||||
@@ -37,7 +37,7 @@ public class ReconcileAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 手动对账:默认前一天,可指定日期与渠道。 */
|
/** 手动对账:默认前一天,可指定日期与渠道。 */
|
||||||
@Operation(summary = "手动对账(默认前一天,可指定日期与渠道)")
|
@Operation(summary = "手动触发对账")
|
||||||
@PostMapping("/run")
|
@PostMapping("/run")
|
||||||
@PreAuthorize("@ss.hasPermission('reconcile:run')")
|
@PreAuthorize("@ss.hasPermission('reconcile:run')")
|
||||||
@OperationLog(module = "订单管理", action = "手动对账")
|
@OperationLog(module = "订单管理", action = "手动对账")
|
||||||
|
|||||||
+8
-6
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
package com.fruitshorts.admin.controller;
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.admin.service.ReportAdminService;
|
||||||
|
import com.fruitshorts.admin.vo.AdminReportRespVO;
|
||||||
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.framework.log.OperationLog;
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import com.fruitshorts.interaction.dal.entity.ContentReport;
|
|
||||||
import com.fruitshorts.interaction.service.ReportService;
|
import com.fruitshorts.interaction.service.ReportService;
|
||||||
import com.fruitshorts.interaction.vo.ReportHandleReqVO;
|
import com.fruitshorts.interaction.vo.ReportHandleReqVO;
|
||||||
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
|
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
|
||||||
@@ -22,23 +23,24 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/** 后台举报工单。 */
|
/** 后台举报工单。 */
|
||||||
@Tag(name = "举报工单")
|
@Tag(name = "后台-举报工单")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/reports")
|
@RequestMapping("/admin/reports")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReportAdminController {
|
public class ReportAdminController {
|
||||||
|
|
||||||
|
private final ReportAdminService reportAdminService;
|
||||||
private final ReportService reportService;
|
private final ReportService reportService;
|
||||||
|
|
||||||
@Operation(summary = "举报工单检索(状态/类型/原因)")
|
@Operation(summary = "举报工单分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('report:list')")
|
@PreAuthorize("@ss.hasPermission('report:list')")
|
||||||
public Result<PageResult<ContentReport>> page(ReportQueryReqVO req) {
|
public Result<PageResult<AdminReportRespVO>> page(ReportQueryReqVO req) {
|
||||||
return Result.ok(reportService.page(req));
|
return Result.ok(reportAdminService.page(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
|
/** 处理工单:结论成立会联动驳回被举报评论,可选择同时封禁被举报用户。 */
|
||||||
@Operation(summary = "处理工单(结论成立联动驳回被举报评论,可选择同时封禁用户)")
|
@Operation(summary = "处理举报工单(可联动驳回评论与封禁用户)")
|
||||||
@PutMapping("/{id}/handle")
|
@PutMapping("/{id}/handle")
|
||||||
@PreAuthorize("@ss.hasPermission('report:handle')")
|
@PreAuthorize("@ss.hasPermission('report:handle')")
|
||||||
@OperationLog(module = "内容治理", action = "处理举报")
|
@OperationLog(module = "内容治理", action = "处理举报")
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Tag(name = "角色分类")
|
@Tag(name = "后台-角色分类")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/role-categories")
|
@RequestMapping("/admin/role-categories")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -38,7 +38,7 @@ public class RoleCategoryController {
|
|||||||
return Result.ok(roleCategoryService.list());
|
return Result.ok(roleCategoryService.list());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新增角色分类")
|
@Operation(summary = "新建角色分类")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('system:role-category')")
|
@PreAuthorize("@ss.hasPermission('system:role-category')")
|
||||||
@OperationLog(module = "后台管理", action = "新增角色分类")
|
@OperationLog(module = "后台管理", action = "新增角色分类")
|
||||||
|
|||||||
+5
-5
@@ -26,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Tag(name = "角色管理")
|
@Tag(name = "后台-角色管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/roles")
|
@RequestMapping("/admin/roles")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -34,7 +34,7 @@ public class RoleController {
|
|||||||
|
|
||||||
private final RoleService roleService;
|
private final RoleService roleService;
|
||||||
|
|
||||||
@Operation(summary = "角色分页检索")
|
@Operation(summary = "角色分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('system:role')")
|
@PreAuthorize("@ss.hasPermission('system:role')")
|
||||||
public Result<PageResult<RoleRespVO>> page(@RequestParam(required = false) String keyword,
|
public Result<PageResult<RoleRespVO>> page(@RequestParam(required = false) String keyword,
|
||||||
@@ -43,7 +43,7 @@ public class RoleController {
|
|||||||
return Result.ok(roleService.page(keyword, pageIndex, pageSize));
|
return Result.ok(roleService.page(keyword, pageIndex, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "角色全量列表")
|
@Operation(summary = "角色下拉列表")
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public Result<List<RoleRespVO>> list() {
|
public Result<List<RoleRespVO>> list() {
|
||||||
return Result.ok(roleService.listAll());
|
return Result.ok(roleService.listAll());
|
||||||
@@ -56,7 +56,7 @@ public class RoleController {
|
|||||||
return Result.ok(roleService.get(id));
|
return Result.ok(roleService.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "新增角色")
|
@Operation(summary = "新建角色")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermission('system:role')")
|
@PreAuthorize("@ss.hasPermission('system:role')")
|
||||||
@OperationLog(module = "后台管理", action = "新增角色")
|
@OperationLog(module = "后台管理", action = "新增角色")
|
||||||
@@ -82,7 +82,7 @@ public class RoleController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "查询角色权限码")
|
@Operation(summary = "角色已授权限")
|
||||||
@GetMapping("/{id}/permissions")
|
@GetMapping("/{id}/permissions")
|
||||||
@PreAuthorize("@ss.hasPermission('system:role')")
|
@PreAuthorize("@ss.hasPermission('system:role')")
|
||||||
public Result<List<String>> permissions(@PathVariable Long id) {
|
public Result<List<String>> permissions(@PathVariable Long id) {
|
||||||
|
|||||||
+3
-3
@@ -24,7 +24,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/sensitive-words")
|
@RequestMapping("/admin/sensitive-words")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -32,7 +32,7 @@ public class SensitiveWordAdminController {
|
|||||||
|
|
||||||
private final SensitiveWordService sensitiveWordService;
|
private final SensitiveWordService sensitiveWordService;
|
||||||
|
|
||||||
@Operation(summary = "敏感词分页检索")
|
@Operation(summary = "敏感词分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('sensitive-word:list')")
|
@PreAuthorize("@ss.hasPermission('sensitive-word:list')")
|
||||||
public Result<PageResult<SensitiveWord>> page(@RequestParam(required = false) String keyword,
|
public Result<PageResult<SensitiveWord>> page(@RequestParam(required = false) String keyword,
|
||||||
@@ -51,7 +51,7 @@ public class SensitiveWordAdminController {
|
|||||||
return Result.ok(sensitiveWordService.create(req));
|
return Result.ok(sensitiveWordService.create(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "修改敏感词")
|
@Operation(summary = "编辑敏感词")
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermission('sensitive-word:update')")
|
@PreAuthorize("@ss.hasPermission('sensitive-word:update')")
|
||||||
@OperationLog(module = "内容治理", action = "修改敏感词")
|
@OperationLog(module = "内容治理", action = "修改敏感词")
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/** 后台内容提交审核:检索、详情、审核(通过 / 驳回 / 撤回)。 */
|
/** 后台内容提交审核:检索、详情、审核(通过 / 驳回 / 撤回)。 */
|
||||||
@Tag(name = "内容提交审核")
|
@Tag(name = "后台-内容提交审核")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/submissions")
|
@RequestMapping("/admin/submissions")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -30,7 +30,7 @@ public class SubmissionAdminController {
|
|||||||
|
|
||||||
private final ContentSubmissionService submissionService;
|
private final ContentSubmissionService submissionService;
|
||||||
|
|
||||||
@Operation(summary = "内容提交分页检索")
|
@Operation(summary = "内容提交分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@PreAuthorize("@ss.hasPermission('submission:list')")
|
@PreAuthorize("@ss.hasPermission('submission:list')")
|
||||||
public Result<PageResult<ContentSubmission>> page(SubmissionQueryReqVO req) {
|
public Result<PageResult<ContentSubmission>> page(SubmissionQueryReqVO req) {
|
||||||
@@ -44,7 +44,7 @@ public class SubmissionAdminController {
|
|||||||
return Result.ok(submissionService.getExisting(id));
|
return Result.ok(submissionService.getExisting(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "审核内容提交(通过后按类型落短剧状态机)")
|
@Operation(summary = "内容提交审核")
|
||||||
@PutMapping("/{id}/audit")
|
@PutMapping("/{id}/audit")
|
||||||
@PreAuthorize("@ss.hasPermission('submission:audit')")
|
@PreAuthorize("@ss.hasPermission('submission:audit')")
|
||||||
@OperationLog(module = "内容审核", action = "提交审核")
|
@OperationLog(module = "内容审核", action = "提交审核")
|
||||||
|
|||||||
+3
-3
@@ -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/wallets")
|
@RequestMapping("/admin/wallets")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -36,7 +36,7 @@ public class WalletAdminController {
|
|||||||
private final WalletService walletService;
|
private final WalletService walletService;
|
||||||
private final AppUserMapper appUserMapper;
|
private final AppUserMapper appUserMapper;
|
||||||
|
|
||||||
@Operation(summary = "金币流水查询")
|
@Operation(summary = "金币流水")
|
||||||
@GetMapping("/{userId}/ledger")
|
@GetMapping("/{userId}/ledger")
|
||||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||||
public Result<List<CoinLedger>> ledger(@PathVariable Long userId,
|
public Result<List<CoinLedger>> ledger(@PathVariable Long userId,
|
||||||
@@ -44,7 +44,7 @@ public class WalletAdminController {
|
|||||||
return Result.ok(walletService.recentLedger(userId, limit));
|
return Result.ok(walletService.recentLedger(userId, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "人工补币/扣回(必须带幂等键,先校验用户存在)")
|
@Operation(summary = "人工补币")
|
||||||
@PostMapping("/coins")
|
@PostMapping("/coins")
|
||||||
@PreAuthorize("@ss.hasPermission('wallet:update')")
|
@PreAuthorize("@ss.hasPermission('wallet:update')")
|
||||||
@OperationLog(module = "订单管理", action = "人工补币")
|
@OperationLog(module = "订单管理", action = "人工补币")
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.admin.service.WatchHistoryAdminService;
|
||||||
|
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
|
||||||
|
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
|
||||||
|
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 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/watch-history")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WatchHistoryAdminController {
|
||||||
|
|
||||||
|
private final WatchHistoryAdminService watchHistoryAdminService;
|
||||||
|
|
||||||
|
@Operation(summary = "观看记录分页查询")
|
||||||
|
@GetMapping("/page")
|
||||||
|
@PreAuthorize("@ss.hasPermission('watch-history:list')")
|
||||||
|
public Result<PageResult<AdminWatchHistoryVO>> page(WatchHistoryQueryReqVO req) {
|
||||||
|
return Result.ok(watchHistoryAdminService.page(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fruitshorts.admin.vo.AdminReportRespVO;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
|
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||||
|
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||||
|
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||||
|
import com.fruitshorts.interaction.dal.entity.ContentReport;
|
||||||
|
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
||||||
|
import com.fruitshorts.interaction.dal.mapper.ContentReportMapper;
|
||||||
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
|
import com.fruitshorts.interaction.service.ReportService;
|
||||||
|
import com.fruitshorts.interaction.vo.ReportQueryReqVO;
|
||||||
|
import com.fruitshorts.user.dal.entity.AppUser;
|
||||||
|
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台举报工单分页与目标展示信息装配。
|
||||||
|
*
|
||||||
|
* <p>举报只保存目标类型和 id;该服务在当前页一次性加载评论、剧、集和用户,
|
||||||
|
* 避免列表逐行查询,同时保持原举报处理流程不变。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReportAdminService {
|
||||||
|
|
||||||
|
private static final int DEFAULT_PAGE_INDEX = 1;
|
||||||
|
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||||
|
private static final int DELETED_FLAG = 1;
|
||||||
|
|
||||||
|
private final ContentReportMapper reportMapper;
|
||||||
|
private final ContentCommentMapper commentMapper;
|
||||||
|
private final DramaMapper dramaMapper;
|
||||||
|
private final DramaEpisodeMapper episodeMapper;
|
||||||
|
private final AppUserMapper appUserMapper;
|
||||||
|
|
||||||
|
/** 按举报工单筛选条件分页查询,并补齐展示字段。 */
|
||||||
|
public PageResult<AdminReportRespVO> page(ReportQueryReqVO req) {
|
||||||
|
Page<ContentReport> page = new Page<>(
|
||||||
|
req.getPageIndex() == null || req.getPageIndex() < 1
|
||||||
|
? DEFAULT_PAGE_INDEX : req.getPageIndex(),
|
||||||
|
req.getPageSize() == null || req.getPageSize() < 1
|
||||||
|
? DEFAULT_PAGE_SIZE : req.getPageSize());
|
||||||
|
IPage<ContentReport> result = reportMapper.selectPage(page,
|
||||||
|
Wrappers.<ContentReport>lambdaQuery()
|
||||||
|
.eq(StringUtils.hasText(req.getStatus()),
|
||||||
|
ContentReport::getStatus, req.getStatus())
|
||||||
|
.eq(StringUtils.hasText(req.getTargetType()),
|
||||||
|
ContentReport::getTargetType, req.getTargetType())
|
||||||
|
.eq(StringUtils.hasText(req.getReasonCode()),
|
||||||
|
ContentReport::getReasonCode, req.getReasonCode())
|
||||||
|
.eq(req.getReporterId() != null,
|
||||||
|
ContentReport::getReporterId, req.getReporterId())
|
||||||
|
.orderByDesc(ContentReport::getId));
|
||||||
|
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AdminReportRespVO> toVoList(List<ContentReport> rows) {
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<Long, ContentComment> comments = loadComments(rows);
|
||||||
|
Map<Long, Drama> dramas = loadDramas(rows, comments);
|
||||||
|
Map<Long, DramaEpisode> episodes = loadEpisodes(comments);
|
||||||
|
Map<Long, AppUser> users = loadUsers(rows);
|
||||||
|
List<AdminReportRespVO> list = new ArrayList<>(rows.size());
|
||||||
|
for (ContentReport row : rows) {
|
||||||
|
list.add(toVo(row, comments, dramas, episodes, users));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, ContentComment> loadComments(List<ContentReport> rows) {
|
||||||
|
List<Long> ids = rows.stream()
|
||||||
|
.filter(row -> ReportService.TYPE_COMMENT.equals(row.getTargetType()))
|
||||||
|
.map(ContentReport::getTargetId)
|
||||||
|
.filter(id -> id != null && id > 0L)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
Map<Long, ContentComment> result = new HashMap<>();
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (ContentComment comment : commentMapper.selectByIdsIncludingDeleted(ids)) {
|
||||||
|
result.put(comment.getId(), comment);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, Drama> loadDramas(List<ContentReport> rows,
|
||||||
|
Map<Long, ContentComment> comments) {
|
||||||
|
Set<Long> ids = new LinkedHashSet<>();
|
||||||
|
for (ContentReport row : rows) {
|
||||||
|
if (ReportService.TYPE_DRAMA.equals(row.getTargetType())) {
|
||||||
|
addPositive(ids, row.getTargetId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ReportService.TYPE_COMMENT.equals(row.getTargetType())) {
|
||||||
|
ContentComment comment = comments.get(row.getTargetId());
|
||||||
|
if (!isDeleted(comment)) {
|
||||||
|
addPositive(ids, comment.getDramaId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<Long, Drama> result = new HashMap<>();
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
|
||||||
|
result.put(drama.getId(), drama);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, DramaEpisode> loadEpisodes(Map<Long, ContentComment> comments) {
|
||||||
|
Set<Long> ids = new LinkedHashSet<>();
|
||||||
|
for (ContentComment comment : comments.values()) {
|
||||||
|
if (!isDeleted(comment)) {
|
||||||
|
addPositive(ids, comment.getEpisodeId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<Long, DramaEpisode> result = new HashMap<>();
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (DramaEpisode episode : episodeMapper.selectBatchIds(ids)) {
|
||||||
|
result.put(episode.getId(), episode);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, AppUser> loadUsers(List<ContentReport> rows) {
|
||||||
|
Set<Long> ids = new LinkedHashSet<>();
|
||||||
|
for (ContentReport row : rows) {
|
||||||
|
addPositive(ids, row.getReporterId());
|
||||||
|
if (ReportService.TYPE_USER.equals(row.getTargetType())) {
|
||||||
|
addPositive(ids, row.getTargetId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<Long, AppUser> result = new HashMap<>();
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
|
||||||
|
result.put(user.getId(), user);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminReportRespVO toVo(ContentReport report,
|
||||||
|
Map<Long, ContentComment> comments,
|
||||||
|
Map<Long, Drama> dramas,
|
||||||
|
Map<Long, DramaEpisode> episodes,
|
||||||
|
Map<Long, AppUser> users) {
|
||||||
|
AdminReportRespVO vo = new AdminReportRespVO();
|
||||||
|
copyReport(report, vo);
|
||||||
|
fillReporter(report, users.get(report.getReporterId()), vo);
|
||||||
|
if (ReportService.TYPE_COMMENT.equals(report.getTargetType())) {
|
||||||
|
fillCommentTarget(report, comments.get(report.getTargetId()), dramas, episodes, vo);
|
||||||
|
} else if (ReportService.TYPE_DRAMA.equals(report.getTargetType())) {
|
||||||
|
fillDramaTarget(report, dramas.get(report.getTargetId()), vo);
|
||||||
|
} else if (ReportService.TYPE_USER.equals(report.getTargetType())) {
|
||||||
|
fillUserTarget(report, users.get(report.getTargetId()), vo);
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyReport(ContentReport report, AdminReportRespVO vo) {
|
||||||
|
vo.setId(report.getId());
|
||||||
|
vo.setReporterId(report.getReporterId());
|
||||||
|
vo.setTargetType(report.getTargetType());
|
||||||
|
vo.setTargetId(report.getTargetId());
|
||||||
|
vo.setReasonCode(report.getReasonCode());
|
||||||
|
vo.setDescription(report.getDescription());
|
||||||
|
vo.setStatus(report.getStatus());
|
||||||
|
vo.setHandleBy(report.getHandleBy());
|
||||||
|
vo.setHandleAt(report.getHandleAt());
|
||||||
|
vo.setHandleNote(report.getHandleNote());
|
||||||
|
vo.setCreatedAt(report.getCreatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillReporter(ContentReport report, AppUser reporter, AdminReportRespVO vo) {
|
||||||
|
if (reporter != null) {
|
||||||
|
vo.setReporterNickname(reporter.getNickname());
|
||||||
|
vo.setReporterUid(reporter.getUid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillCommentTarget(ContentReport report, ContentComment comment,
|
||||||
|
Map<Long, Drama> dramas,
|
||||||
|
Map<Long, DramaEpisode> episodes,
|
||||||
|
AdminReportRespVO vo) {
|
||||||
|
if (isDeleted(comment)) {
|
||||||
|
vo.setTargetSummary("评论 #" + report.getTargetId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Drama drama = dramas.get(comment.getDramaId());
|
||||||
|
if (drama == null) {
|
||||||
|
vo.setTargetSummary("评论 #" + report.getTargetId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DramaEpisode episode = episodes.get(comment.getEpisodeId());
|
||||||
|
vo.setDramaId(comment.getDramaId());
|
||||||
|
vo.setDramaTitle(drama.getTitle());
|
||||||
|
vo.setEpisodeId(comment.getEpisodeId());
|
||||||
|
vo.setCommentContent(comment.getContent());
|
||||||
|
if (episode == null) {
|
||||||
|
vo.setTargetSummary(drama.getTitle());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vo.setEpisodeNo(episode.getEpNo());
|
||||||
|
vo.setEpisodeTitle(episode.getTitle());
|
||||||
|
String summary = drama.getTitle() + " · 第 " + episode.getEpNo() + " 集";
|
||||||
|
if (StringUtils.hasText(episode.getTitle())) {
|
||||||
|
summary += " · " + episode.getTitle();
|
||||||
|
}
|
||||||
|
vo.setTargetSummary(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillDramaTarget(ContentReport report, Drama drama, AdminReportRespVO vo) {
|
||||||
|
vo.setDramaId(report.getTargetId());
|
||||||
|
if (drama == null) {
|
||||||
|
vo.setTargetSummary("短剧 #" + report.getTargetId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vo.setDramaTitle(drama.getTitle());
|
||||||
|
vo.setTargetSummary(drama.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillUserTarget(ContentReport report, AppUser user, AdminReportRespVO vo) {
|
||||||
|
if (user == null) {
|
||||||
|
vo.setTargetSummary("用户 #" + report.getTargetId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vo.setTargetNickname(user.getNickname());
|
||||||
|
vo.setTargetUid(user.getUid());
|
||||||
|
vo.setTargetSummary(formatUser(user, report.getTargetId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatUser(AppUser user, Long fallbackId) {
|
||||||
|
boolean hasNickname = StringUtils.hasText(user.getNickname());
|
||||||
|
boolean hasUid = StringUtils.hasText(user.getUid());
|
||||||
|
if (hasNickname && hasUid) {
|
||||||
|
return user.getNickname() + "(" + user.getUid() + ")";
|
||||||
|
}
|
||||||
|
if (hasNickname) {
|
||||||
|
return user.getNickname();
|
||||||
|
}
|
||||||
|
if (hasUid) {
|
||||||
|
return user.getUid();
|
||||||
|
}
|
||||||
|
return "用户 #" + fallbackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDeleted(ContentComment comment) {
|
||||||
|
return comment == null
|
||||||
|
|| CommentService.STATUS_DELETED.equals(comment.getStatus())
|
||||||
|
|| Integer.valueOf(DELETED_FLAG).equals(comment.getDeleted());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addPositive(Set<Long> ids, Long id) {
|
||||||
|
if (id != null && id > 0L) {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fruitshorts.admin.vo.AdminWatchHistoryVO;
|
||||||
|
import com.fruitshorts.admin.vo.WatchHistoryQueryReqVO;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
|
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||||
|
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||||
|
import com.fruitshorts.content.enums.ContentStatus;
|
||||||
|
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
||||||
|
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
||||||
|
import com.fruitshorts.user.dal.entity.AppUser;
|
||||||
|
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台观看记录分页与跨域装配。
|
||||||
|
*
|
||||||
|
* <p>观看流水是播放域的原始事件,用户、短剧与分集资料分别位于用户和内容域。
|
||||||
|
* 该服务位于 admin-api 聚合层,直接注入只读 Mapper,避免反向扩展业务模块依赖。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WatchHistoryAdminService {
|
||||||
|
|
||||||
|
private static final int DEFAULT_PAGE_INDEX = 1;
|
||||||
|
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||||
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
private static final long FINISHED_TOLERANCE_MS = 1000L;
|
||||||
|
|
||||||
|
private final WatchHistoryMapper watchHistoryMapper;
|
||||||
|
private final AppUserMapper appUserMapper;
|
||||||
|
private final DramaMapper dramaMapper;
|
||||||
|
private final DramaEpisodeMapper dramaEpisodeMapper;
|
||||||
|
|
||||||
|
/** 按后台筛选条件分页查询观看记录。 */
|
||||||
|
public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
|
||||||
|
int pageIndex = req.getPageIndex() == null || req.getPageIndex() < 1
|
||||||
|
? DEFAULT_PAGE_INDEX : req.getPageIndex();
|
||||||
|
int pageSize = req.getPageSize() == null || req.getPageSize() < 1
|
||||||
|
? DEFAULT_PAGE_SIZE : Math.min(req.getPageSize(), MAX_PAGE_SIZE);
|
||||||
|
List<Long> matchedUserIds = StringUtils.hasText(req.getKeyword())
|
||||||
|
? searchUserIds(req.getKeyword()) : null;
|
||||||
|
if (matchedUserIds != null && matchedUserIds.isEmpty()) {
|
||||||
|
return emptyPage();
|
||||||
|
}
|
||||||
|
List<Long> matchedDramaIds = StringUtils.hasText(req.getDramaTitle())
|
||||||
|
? searchDramaIds(req.getDramaTitle()) : null;
|
||||||
|
if (matchedDramaIds != null && matchedDramaIds.isEmpty()) {
|
||||||
|
return emptyPage();
|
||||||
|
}
|
||||||
|
IPage<WatchHistory> result = watchHistoryMapper.selectPage(new Page<>(pageIndex, pageSize),
|
||||||
|
buildQuery(req, matchedUserIds, matchedDramaIds));
|
||||||
|
return new PageResult<>(result.getTotal(), toVoList(result.getRecords()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LambdaQueryWrapper<WatchHistory> buildQuery(WatchHistoryQueryReqVO req,
|
||||||
|
List<Long> matchedUserIds,
|
||||||
|
List<Long> matchedDramaIds) {
|
||||||
|
LambdaQueryWrapper<WatchHistory> wrapper = Wrappers.<WatchHistory>lambdaQuery()
|
||||||
|
.eq(req.getUserId() != null, WatchHistory::getUserId, req.getUserId())
|
||||||
|
.in(matchedUserIds != null, WatchHistory::getUserId, matchedUserIds)
|
||||||
|
.eq(req.getDramaId() != null, WatchHistory::getDramaId, req.getDramaId())
|
||||||
|
.in(matchedDramaIds != null, WatchHistory::getDramaId, matchedDramaIds)
|
||||||
|
.eq(req.getEpisodeId() != null, WatchHistory::getEpisodeId, req.getEpisodeId())
|
||||||
|
.eq(StringUtils.hasText(req.getSource()), WatchHistory::getSource, req.getSource())
|
||||||
|
.ge(req.getStartTime() != null, WatchHistory::getWatchedAt, req.getStartTime())
|
||||||
|
.le(req.getEndTime() != null, WatchHistory::getWatchedAt, req.getEndTime());
|
||||||
|
applyFinished(wrapper, req.getFinished());
|
||||||
|
return wrapper.orderByDesc(WatchHistory::getWatchedAt)
|
||||||
|
.orderByDesc(WatchHistory::getId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyFinished(LambdaQueryWrapper<WatchHistory> wrapper, Boolean finished) {
|
||||||
|
if (Boolean.TRUE.equals(finished)) {
|
||||||
|
wrapper.gt(WatchHistory::getDurationMs, 0L)
|
||||||
|
.apply("position_ms + {0} >= duration_ms", FINISHED_TOLERANCE_MS);
|
||||||
|
} else if (Boolean.FALSE.equals(finished)) {
|
||||||
|
wrapper.and(query -> query.eq(WatchHistory::getDurationMs, 0L)
|
||||||
|
.or()
|
||||||
|
.apply("position_ms + {0} < duration_ms", FINISHED_TOLERANCE_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> searchUserIds(String keyword) {
|
||||||
|
String value = keyword.trim();
|
||||||
|
return appUserMapper.selectList(Wrappers.<AppUser>lambdaQuery()
|
||||||
|
.select(AppUser::getId)
|
||||||
|
.and(query -> query.like(AppUser::getUid, value)
|
||||||
|
.or()
|
||||||
|
.like(AppUser::getNickname, value)))
|
||||||
|
.stream()
|
||||||
|
.map(AppUser::getId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> searchDramaIds(String dramaTitle) {
|
||||||
|
String value = dramaTitle.trim();
|
||||||
|
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
|
||||||
|
.select(Drama::getId)
|
||||||
|
.like(Drama::getTitle, value))
|
||||||
|
.stream()
|
||||||
|
.map(Drama::getId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AdminWatchHistoryVO> toVoList(List<WatchHistory> rows) {
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<Long, AppUser> users = loadUsers(rows);
|
||||||
|
Map<Long, Drama> dramas = loadDramas(rows);
|
||||||
|
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
|
||||||
|
List<AdminWatchHistoryVO> list = new ArrayList<>(rows.size());
|
||||||
|
for (WatchHistory row : rows) {
|
||||||
|
list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()),
|
||||||
|
episodes.get(row.getEpisodeId())));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, AppUser> loadUsers(List<WatchHistory> rows) {
|
||||||
|
Map<Long, AppUser> result = new HashMap<>();
|
||||||
|
List<Long> ids = rows.stream().map(WatchHistory::getUserId).distinct().toList();
|
||||||
|
for (AppUser user : appUserMapper.selectBatchIds(ids)) {
|
||||||
|
result.put(user.getId(), user);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, Drama> loadDramas(List<WatchHistory> rows) {
|
||||||
|
Map<Long, Drama> result = new HashMap<>();
|
||||||
|
List<Long> ids = rows.stream().map(WatchHistory::getDramaId).distinct().toList();
|
||||||
|
for (Drama drama : dramaMapper.selectBatchIds(ids)) {
|
||||||
|
result.put(drama.getId(), drama);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, DramaEpisode> loadEpisodes(List<WatchHistory> rows) {
|
||||||
|
Map<Long, DramaEpisode> result = new HashMap<>();
|
||||||
|
List<Long> ids = rows.stream().map(WatchHistory::getEpisodeId).distinct().toList();
|
||||||
|
for (DramaEpisode episode : dramaEpisodeMapper.selectBatchIds(ids)) {
|
||||||
|
result.put(episode.getId(), episode);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
|
||||||
|
DramaEpisode episode) {
|
||||||
|
AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
|
||||||
|
vo.setId(row.getId());
|
||||||
|
vo.setUserId(row.getUserId());
|
||||||
|
vo.setDramaId(row.getDramaId());
|
||||||
|
vo.setEpisodeId(row.getEpisodeId());
|
||||||
|
vo.setPositionMs(row.getPositionMs());
|
||||||
|
vo.setDurationMs(row.getDurationMs());
|
||||||
|
vo.setSource(row.getSource());
|
||||||
|
vo.setDeviceId(row.getDeviceId());
|
||||||
|
vo.setWatchedAt(row.getWatchedAt());
|
||||||
|
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
|
||||||
|
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
|
||||||
|
vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration);
|
||||||
|
if (user != null) {
|
||||||
|
vo.setUid(user.getUid());
|
||||||
|
vo.setNickname(user.getNickname());
|
||||||
|
}
|
||||||
|
if (drama != null) {
|
||||||
|
vo.setDramaTitle(drama.getTitle());
|
||||||
|
vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus()));
|
||||||
|
} else {
|
||||||
|
vo.setDramaPublished(false);
|
||||||
|
}
|
||||||
|
if (episode != null) {
|
||||||
|
vo.setEpisodeNo(episode.getEpNo());
|
||||||
|
vo.setEpisodeTitle(episode.getTitle());
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PageResult<AdminWatchHistoryVO> emptyPage() {
|
||||||
|
return new PageResult<>(0L, List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 后台举报工单视图,附带举报人与被举报目标的展示信息。 */
|
||||||
|
@Data
|
||||||
|
public class AdminReportRespVO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long reporterId;
|
||||||
|
private String reporterNickname;
|
||||||
|
private String reporterUid;
|
||||||
|
private String targetType;
|
||||||
|
private Long targetId;
|
||||||
|
private String targetSummary;
|
||||||
|
private Long dramaId;
|
||||||
|
private String dramaTitle;
|
||||||
|
private Long episodeId;
|
||||||
|
private Integer episodeNo;
|
||||||
|
private String episodeTitle;
|
||||||
|
private String commentContent;
|
||||||
|
private String targetNickname;
|
||||||
|
private String targetUid;
|
||||||
|
private String reasonCode;
|
||||||
|
private String description;
|
||||||
|
private String status;
|
||||||
|
private Long handleBy;
|
||||||
|
private LocalDateTime handleAt;
|
||||||
|
private String handleNote;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台观看记录行。
|
||||||
|
*
|
||||||
|
* <p>页面需要识别用户、短剧、分集与完播状态,因此由 admin-api 聚合流水与基础资料。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AdminWatchHistoryVO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private String uid;
|
||||||
|
private String nickname;
|
||||||
|
private Long dramaId;
|
||||||
|
private String dramaTitle;
|
||||||
|
private Boolean dramaPublished;
|
||||||
|
private Long episodeId;
|
||||||
|
private Integer episodeNo;
|
||||||
|
private String episodeTitle;
|
||||||
|
private Long positionMs;
|
||||||
|
private Long durationMs;
|
||||||
|
private Boolean finished;
|
||||||
|
private String source;
|
||||||
|
private String deviceId;
|
||||||
|
private LocalDateTime watchedAt;
|
||||||
|
}
|
||||||
-13
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
package com.fruitshorts.admin.vo;
|
package com.fruitshorts.admin.vo;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -16,30 +15,18 @@ import java.time.LocalDateTime;
|
|||||||
@Data
|
@Data
|
||||||
public class AppUserWatchHistoryVO {
|
public class AppUserWatchHistoryVO {
|
||||||
|
|
||||||
@Schema(description = "ID")
|
|
||||||
private Long id;
|
private Long id;
|
||||||
@Schema(description = "短剧 ID")
|
|
||||||
private Long dramaId;
|
private Long dramaId;
|
||||||
@Schema(description = "剧名")
|
|
||||||
private String dramaTitle;
|
private String dramaTitle;
|
||||||
/** 短剧当前是否已上线,用于识别"用户说看了但剧已下线"的情况 */
|
/** 短剧当前是否已上线,用于识别"用户说看了但剧已下线"的情况 */
|
||||||
@Schema(description = "短剧当前是否已上线")
|
|
||||||
private Boolean dramaPublished;
|
private Boolean dramaPublished;
|
||||||
@Schema(description = "剧集 ID")
|
|
||||||
private Long episodeId;
|
private Long episodeId;
|
||||||
@Schema(description = "集号")
|
|
||||||
private Integer episodeNo;
|
private Integer episodeNo;
|
||||||
@Schema(description = "播放进度(毫秒)")
|
|
||||||
private Long positionMs;
|
private Long positionMs;
|
||||||
@Schema(description = "时长(毫秒)")
|
|
||||||
private Long durationMs;
|
private Long durationMs;
|
||||||
/** 是否已看完(进度到达片尾 1 秒内) */
|
/** 是否已看完(进度到达片尾 1 秒内) */
|
||||||
@Schema(description = "是否已看完")
|
|
||||||
private Boolean finished;
|
private Boolean finished;
|
||||||
@Schema(description = "来源")
|
|
||||||
private String source;
|
private String source;
|
||||||
@Schema(description = "设备 ID")
|
|
||||||
private String deviceId;
|
private String deviceId;
|
||||||
@Schema(description = "观看时间")
|
|
||||||
private LocalDateTime watchedAt;
|
private LocalDateTime watchedAt;
|
||||||
}
|
}
|
||||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user