From 34bc980eea3c85fa0764eb3ca1cc6a4d8ff5996b Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Thu, 23 Apr 2026 15:25:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=90=8E=E7=AB=AF=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E9=87=8D=E6=9E=84=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.env | 4 +- app/__pycache__/app_common.cpython-312.pyc | Bin 15390 -> 16450 bytes app/__pycache__/config.cpython-312.pyc | Bin 2602 -> 2878 bytes .../__pycache__/approve.cpython-312.pyc | Bin 55858 -> 56353 bytes app/amazon/__pycache__/main.cpython-312.pyc | Bin 29816 -> 29925 bytes .../__pycache__/price_match.cpython-312.pyc | Bin 13517 -> 52331 bytes app/amazon/__pycache__/tool.cpython-312.pyc | Bin 4576 -> 6536 bytes app/app_common.py | 31 +- .../__pycache__/main.cpython-312.pyc | Bin 8810 -> 12948 bytes app/blueprints/main.py | 108 +++- app/config.py | 20 +- app/main.py | 2 +- .../config/TaskPressureProperties.java | 4 + .../BrandTaskProgressCacheService.java | 311 +--------- .../brand/service/BrandTaskService.java | 95 ++- .../service/BrandTaskStorageService.java | 475 +++++++++++++++ .../convert/service/ConvertRunService.java | 10 +- .../dedupe/service/DedupeRunService.java | 4 +- .../service/DedupeTotalDataService.java | 8 +- .../controller/DeleteBrandRunController.java | 23 +- .../service/DeleteBrandRunService.java | 118 ++-- .../service/DeleteBrandTaskCacheService.java | 187 +----- .../DeleteBrandTaskStorageService.java | 404 +++++++++++++ .../file/service/oss/OssStorageService.java | 66 +++ .../PatrolDeleteExcelAssemblyService.java | 38 +- .../service/PatrolDeleteTaskCacheService.java | 66 +-- .../dto/PriceTrackSubmitResultRequest.java | 9 + .../model/vo/PriceTrackCreateTaskVo.java | 3 + .../model/vo/PriceTrackMatchShopsVo.java | 3 + .../PriceTrackExcelAssemblyService.java | 48 +- .../pricetrack/service/PriceTrackService.java | 4 + .../service/PriceTrackTaskCacheService.java | 60 +- .../service/PriceTrackTaskService.java | 45 +- .../ProductRiskExcelAssemblyService.java | 32 +- .../service/ProductRiskResolveService.java | 8 +- .../service/ProductRiskTaskCacheService.java | 66 +-- .../service/ProductRiskTaskService.java | 26 +- .../controller/SkipPriceAsinController.java | 1 + .../SkipPriceAsinCountryUpdateRequest.java | 5 + .../model/dto/SkipPriceAsinCreateRequest.java | 5 + .../model/entity/SkipPriceAsinEntity.java | 16 + .../shopkey/model/vo/SkipPriceAsinItemVo.java | 6 + .../shopkey/service/SkipPriceAsinService.java | 152 ++++- .../ShopMatchExcelAssemblyService.java | 22 +- .../service/ShopMatchTaskCacheService.java | 122 +--- .../split/service/SplitRunService.java | 4 +- .../modules/task/mapper/TaskChunkMapper.java | 9 + .../task/mapper/TaskScopeStateMapper.java | 9 + .../task/model/entity/TaskChunkEntity.java | 26 + .../model/entity/TaskScopeStateEntity.java | 29 + ...kScopePayloadBufferMaintenanceService.java | 172 ++++++ .../TaskScopePayloadStorageService.java | 542 ++++++++++++++++++ .../service/ZiniaoShopSwitchService.java | 10 +- .../src/main/resources/application.yml | 4 + .../db/V29__skip_price_asin_minimum_price.sql | 76 +++ .../resources/db/V30__task_chunk_storage.sql | 36 ++ backend/__pycache__/config.cpython-312.pyc | Bin 1513 -> 1510 bytes .../__pycache__/admin_api.cpython-312.pyc | Bin 86277 -> 93391 bytes .../__pycache__/auth.cpython-312.pyc | Bin 4782 -> 4376 bytes .../__pycache__/main.cpython-312.pyc | Bin 2543 -> 2434 bytes backend/blueprints/admin_api.py | 202 +++++-- backend/blueprints/auth.py | 19 +- backend/blueprints/main.py | 5 +- backend/config.py | 4 +- backend/run.sh | 98 ++++ .../utils/__pycache__/auth.cpython-312.pyc | Bin 4546 -> 5017 bytes backend/utils/auth.py | 19 +- backend/web_source/admin.html | 308 +++++++--- .../brand/components/BrandPatrolDeleteTab.vue | 8 + .../brand/components/BrandPriceTrackTab.vue | 70 ++- .../brand/components/BrandProductRiskTab.vue | 48 +- .../brand/components/BrandShopMatchTab.vue | 13 +- frontend-vue/src/shared/api/java-modules.ts | 4 + new_web_source/patrol-delete.html | 17 + new_web_source/price-track.html | 7 +- new_web_source/shop-match.html | 7 +- 76 files changed, 3242 insertions(+), 1111 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskChunkMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskScopeStateMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskChunkEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadBufferMaintenanceService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java create mode 100644 backend-java/src/main/resources/db/V29__skip_price_asin_minimum_price.sql create mode 100644 backend-java/src/main/resources/db/V30__task_chunk_storage.sql create mode 100644 backend/run.sh create mode 100644 new_web_source/patrol-delete.html diff --git a/app/.env b/app/.env index 8a4270d..a8104b3 100644 --- a/app/.env +++ b/app/.env @@ -13,7 +13,7 @@ client_name=ShuFuAI # java_api_base=http://47.111.163.154:18080 -# java_api_base=http://127.0.0.1:18080 -java_api_base=http://8.136.19.173:18080 +java_api_base=http://127.0.0.1:18080 +# java_api_base=http://8.136.19.173:18080 diff --git a/app/__pycache__/app_common.cpython-312.pyc b/app/__pycache__/app_common.cpython-312.pyc index eac4be7d29aabe5fd7d14f346be32d034f2750db..5b2db77d3382ad07497a5b760daeb9c289c37b0a 100644 GIT binary patch delta 2895 zcmb_eU2GKB6~1?7cJ{~XwVefn!Nztl*lS}a4nGECOufb+uyF`>Wb8tQ_0AYCUGJK+ z^PQi2_wA|sGu-#5!a@(j-%azA$S<3&`qoO4^qaaKF4}lwq5HN( zYm5z57!@dKXsFOVIoXIbv_UVJWkZ#^*Qgqi^uk$XsCsekn^nXw__|`lyoJxXFE&DNm)Ma7H1sKl{s`Z>KIc~4$dT&bflHriw=p8)~^!vMII%k zntt&&M{`MW&RPwFQqIx}sZ{Dt-E}7)*_1lSuI!j(Q}Q62wxoE%QpQ8( zsHH{{Bax^vCs|U{^Oidtis=!ZqE8X5s)CI;Snjq{M$DKRhxW6T8W=I%0BzJ9i#D}I z*LOYkZu(4S9zU*p zxj=+|+|bBZu%wjaS-!>dJh2Y)JTFVRoGh~3oMQ92HrV-_@Ms?tpeajd9Yr-AK-{9%0fkL;=J&E^?&PkJpjI^{_ph(c2_{LC0&)`V;$Zor@l%l)l07pK0Yisr6rK^Ut*TdOuee)sEW=l~ z$W;UQ22p}Bg#8esX`BEE65`m}ZCn>WUE7+r9iw&ylYstJ0G%V?BKTT3mLfqxu;hdZ z0Vhn5=BorNV`6c`=3(b6dn_Sg(j#JLt3&!&MC>oC;HivWmf@SHP`Z*XX)L4rIw~Z^ zp7r(k=OX1P%bZ)kN?lWm!hiy6aDcT0Wd!RpcdGy3OP`}T9bK@2ppd{vP(-klXS(z4 z#AcICL9x_ud~7UY+L^aj<5_LseXB)c5i=5sYPM?u2iRX&$s5k&_5|IGWy{WDC+*bq zVYq|~Z)Kd#e^&X7c-(S`kBZ8j*ALI4IBV^{2A!@+%W1@3jR4*bcIGb7F1sSt<*o>2 z*Yibos4tQNK~bi2mxp@;mQ&O8cv#b*K@7HT;T7Uy>mj~HJZ!B@TdrV7Uzdhg7VMyJ zdHVW0`VV%J!18qT^>y|4*>m4OS5WJF@#UkPS>=875!>w~&FG%7I}oV9fSSQBRiFgg z{B4h*nqKXyx2xObZySs26H%ib-o_P z_f&c2iK1RQaPGjRo^w5KZg^0=^^vk|-XX7(9FQ delta 1882 zcmdUv%}*Ow5Wx5CuGim&1cHgMu^}YIjh%#w04l@*6Of=ZRb$jPQ5(m2*JY{gb!L|W z5d;oVRHTR~yhC${s;KG(kydGa?Ip>vy|t%R+Nx6j02B^ARqD)|lyGS8ebWAR-pss@ zc{A@lU1MJ?hJFYJeFC=c+H5_Vx)*8{H`uq`Q*2!I-V&}vRiD;g6jlE%`ATe04xk)Y zgJ|tgLG0eMVqM0Tl%kjK@-$P-IqqN#RDOtIRMbd9+>ag}ENt31HsJnZUL9xy@p zT;T6`-e|QH+5$C|EeqRHU0xB<7~}6AZpti++vrWr3|RiUBnZnwk?F!R`-*L_tY8KI z(Tl3ME!KNyg%xR8nrq-sT&LS))m8G8y(=yzh{CdKRem|^+z~z!Z{oik_R1@Yb(Eh} zdU#?m;BH~Us$4g=E2{gE=jF63%4OkB--56xT^1JDmUo^%RLoV|x1i1BOk*Ajnk~Z2 zMW9_InC(`z0xVUn8?Je)m>RI%pjAuxf`%qV12Dkc3fiJ!+Hy5-&D!#up;v5AW+Xe7 zyD)LdR&=wdmo%WrZP}=56jx83ns-+th2%vt; z4admrE*KR{TeKiWJyx_O$S*+mzNuoFZtTVD4LZmY$bnW-HOSF&Fo>!ce!E{I)`UO& z!MpwI{db4fBSQ~750j6BKl#&Z7oP8_)9dN`T|fI{eAst8-Hs!kL=R=+(v$vZCMi6P zyD|y)(`01SFFo@yWMMDQ=qLzO78LXD|EyL9{-ajlI0ghK34Mrzht1D~S{lxy`V`rR z2}ahLiixa~Z#1UZS^j-v7wh1^H%44j=#j38hyD`T$YygoLw5{D2^R=n!Wdzk zkRhB!BqW$1d6MuJ!lWyQ8HWzO)*N+Zv3IHVWph7H%k7q&;*6Z)=fgdrfqnNibiWzy zVH$rNF7?OJ05W1r1ag8r!lVmu4d=z59V!_g>lLh}>+?EjDilzu@@(ttEMB|Wx+%p+ zPyz1|-bakX2P9r5d`S3+pNaIci(HGmx$5kQ>I1G4=nVs^TksG%3H#@;e*_f0EtwW1 z+>j%uYlMS`VKdDM;FZ*1q~u2t-RQ`xWu0$^qpVsRiq_bP8Ppz(lRRFDa~2&8ek4Y5 z4#!eTxEU1*8IIr&S_omn(b}!!&q8CLklaRy5a?Bd5TSvvA5^iqZ?w;u>0oS7MmN)iaPD_KFK)e}TRC+GuA~05+Hbu-Dr}BF8k}U8_@9IK->#q< zoXP(6T$AWxyS2)vI^cstdjW&sRGU&CT;B84 z1itm-S{XAHuLWN{72nm`s#Yii|4|BKaDmR>Q0(^Mt1^lcF@nf2Zj1dU& z0upBhso@3@83%x~xoHY}tO5f!@RE-Q8^}GERkGLHj{iYW!cxPArS}x_4Cxv`Ug#rs z-)$t#?a+@H)>M z-FzqCEp!TlJEQz*epEOuoZX)W?oOTePFUj&`!XR)ZyViKr!@$k(Ni(?UCf<}xv98+ a;`$li@5w!3fX)TwlRUBC*aXZWN&gFKou?-N delta 535 zcmZ{hJ5K^Z6ouz55Lj3cWtaC;c0p9sAAsVkkk-!IY3>vlDU6AUG{g>Vl#GorB-Rou zdjEj2Fm@((+FM(>gFtMUVsg*@&OI}ec?v)1foH$p%j|XA{;Yqioj?>Sonl?AL(J#x zfh3HfZ=ii+t=ZZFzwC+@Xb_OqRB+6v6vl%C`U1MBLR<@t80k&159yde;hr*#50R51 zM|#6R1DG6hMJd)(#5?3taaj$05j)o4kx>*zq}QLITdk<@_vNAk#={JSK?u!`@WzJJ;TCUpB{oeHc`Uy{RGii z0qwo&i@ixm3Mg4Gl<`UeDqU)*>y<)#mjVf3p(jhmeu1JT0uc=1Lk(K!Qn3&)uyCR} zey42gSr$(BT9rzl=p*79xh+M+YrFXuid1~XtA^!XllZTu?YEk}$=mEU^r1>X_~PEo z^=|TYJ_f7wP}ZkMS~%91j0_+Ps^EN|29GUrHF_(Gjsr$5 zN_Y;5!P=jXY;deUO_brIFfkL(^k;~&d@_u)p{GBM&Uu2LuQO;=_~c&4aT~q@nNR4W zG&t?e#oZ)Jop0~VspFF#bMp9(h4>DLh8%b&SQ;}u6;#JsDTLMFizo<(2U4J7Sc?>3 z8gQV`acrfMS|ri&bJ3d;2cq+NnRdb-;&@(Uim9A88Tvl z22cJU^zCa{9^8l0VwB>c6ioczZzKWyLs{6`PeX@L&10+iF){Dp*Qf*64hIGQxnM~P;XQRF{5@+mrKz=K^qd;Z~#ODpHjWANbe*ALFY%HVq|!H!i3 z+U@qYw3xDT#=qL})xYZ{#>Pju?1Dc>i~Pl7dq|{)ljAvNBmNy4b8w~6&M#U%+{>e< z_(jPt&Hj<`1Vl=NFnz04yr{H(a3DgaK+QyU`4Y=rab7I`S;2Kwv{!`mWnsmh2uUkz z&=O}@wVT@B6&( zd7tNd-Y+L-e3pJUO--u?gAVcM&(t5MKYNO%Ow-e3Ob7Ytd*t;ze08G>Qs2AGrU|SL zA%u~L6#S+Uru0@=6s>R}UPY}4K{!?elM8wce_0DvGwtw6m05$)xhR-(4y_`5j$|7o zXFP0bq&)7Io&Ts_SjP~&NL!4WQL1bx9*&eH(K8*m+&B41-n^^@&(~?gAIan!LrBG zb5hxP8+7!4xX zGOADM$HQXzP%AOmx%4cyVb{661&2rrRHnuq~mrH>%k>j8z;Jc#Ewu|x3 zwwI}$Mylcn#ouA!K($z(UZb!@Z+xoe*?%}U~T{a diff --git a/app/amazon/__pycache__/main.cpython-312.pyc b/app/amazon/__pycache__/main.cpython-312.pyc index 1a9e82f09dfe58bef88dad887205c06eae2739ae..cb7f75a026fdb4604887ea6858afb5ec2eaf299e 100644 GIT binary patch delta 3410 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 delta 3321 zcmZuzeQZqYy~hD+H|P zbm$`p8}b6Sy~2j6a(=+RWu_odvrdqwp3{s15qd}uky0U>e3?ko9T7=Z;wOf8NZ}0T zKxZKoGe}xu!LY2x{NbpoXJa>eXc0tAE>Z{srW&`zlpTb@lgGpak^LUN#{6Fwpx@$z z|Cu^<$8``i*BDFz^PmOZGkY>pCYIW#@6`uv@VPmU*x|qCH{d_!1@L=IyEUy>e@qCZ zzbOVXpem&wS~{&@vgE*LDRV^ApcAZCkA2d)$FFB8Ip8^K8OemObuP(*H?1yb_7ggY zuwDo_`b@h_2TuhY@QJnP+oCz1ind+o7S{{giR6NvwmoDTJhBz*Bpp=RXCfl? znsgchCQSvbVXuKW|Pic!(P5hGEz(-sS5^@ zwkTCPWL5RW*g9z=T*}VN-t2< z*yI#-F%w+PaX?efRAPqC91mm`Wx=tWG|1kR{n%QRW@B;qQz+0yea7*b8BQmBxF`=w zwwht3bI`7%^O+m1M0g&olgfw(Y9?)1s^wsznP|vD;00NOk_TZeLL)*eLK_3gL?^OZ z2X`hFIeBhaK$aUm6w5~IVXDjP-pWMv0)v-?F`LcOP5xF?dfOwhdbv^un#atbxK2$f zT~Z-jt*|Vu60a^UTRLC-^?U=9J9JP|ot;=Txzk`<#3HPsZiaLY4CQybmM~G$C3W7C zq+ab(qAKoLzO$CNtH^QabKfPo;Pd1YFZ6o~NDxkW3Ue@F8exz$sWBxQl>-u?o8Y#m zD|-`a4Kk$)mC`7Nr)99NAkR1##S++AFnca%q6xGmPhs_W>34QGxlT5zQn!hcT^YTS|UkXN1ReQ67ylE zjDV(j+Z?&*Mj8Cmz`%WP@T5dt@P~QLmIZ9wAnE8rm|l9?g9lL@8|B{dckjRT;|GV| z7(IWUaw2fKbPD{v^milz!(~CDLP2?3y_P%Lx)Jawq8bS(Ss5e^)F0`TwH`@Fg++Bre5`ZmH35egE; z)t{QI-$VHz!bULFV7l15eb(~d6M16k_|eG^nq$y zeIb>G;K;hw8rK(i!(thZC9Lc74P;}Y_{FVaBhL$)jGjZ_ouxyG6A?y<_bAdse~EAz z;X{Ng2-grU!C(C=`fHfDihTe$3%vO_Jt^pM13~LFCP{W}+m16#dD8vp#It-Rb50Il z(JgdoVW8R>JU%kDKa+NH4#E5F5-Cc2-u?<9w-S-SU6Jj$mM)K#CxT}p+ibmEg--1x z)YHREl{9t9{(wwXb}oLzI=MrC{bMtR>M&5LN zP!i78dZQbbg{P5&5DAwO2~LLh<+w2vFXMD3sRZM(Tj`?66mk^SNAj6lPo%n^U3vw| zKX}}CH4gC-OYxgSYZ2-Z@SczqgOM2R#UXwY=uw0V2u6e<1l}ZkH{D^wvjWA-ua$`78frSb>!E<-vB{Z+ZZZ@buDy~q-1M_u zV*-wEwmkn($V?0OuuFZtd6=4cE{$<`^G4c1>YE#r;s^c35fsrZnl-I bCS$pR-Zrq~mgxGk-u!aIm?-Ec^LYOU2bwww diff --git a/app/amazon/__pycache__/price_match.cpython-312.pyc b/app/amazon/__pycache__/price_match.cpython-312.pyc index 56c0306d6667564b558e46f6d864e9a06c99fbac..8fbcf0599ffde4f42d81fbe1d7dd54df1d0abf12 100644 GIT binary patch literal 52331 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 IleH-Rzk9(dngG>G<*d(#nd^^5qU{@k+`8Ygj5S zt@O0WWu588o5V z!k8x8G&@k6Hi+Lt+dR;CB1kmK0RJ09Q+gZ0=c}ehg|n)LLdq(r2@*qcM_{y(L6Wn~ z0M-SGQ;5zxt$mR|P}^wAdC#1Ai>d|5Mw_~RA=Px$II>jmG3xRXh%ZuIgVRtzCw<@# z@G~fM8a^a-wM#ui$@qn2{PN_jFQxc?Nk{;xra6}CJS0U9r6N5!pVT+kfJi_ml|oW9 z3|f^E4`S9)Gf#{vgJW}6QK^B1l!yY=sCmDKuPj|!@n$7ePQlVu;d0HRX5s;Zf!3Mc zTgj1&l%w210ea@-G1!i|h}p5#1Q`OD)b(iQz#y2U==G@*Mn+Nxqq!2`b^ys!krPtK zk#s^>BK35GP&1yqdrUfYW;$bv5BPn|mU>@{hku^0LISWe=k7>%qRDtS z^d&U&@=1s9q`QYzvoLx1mI@;!!csi`0_J0nFTkv2*%r2nf=;B)d@-S*76k(tMyK6Yb9qv0%a$gP&etG3eRWvi;TR)BP_3KT#bfDHj|a-^Gb z1Y}M+pjB6MY2tmWl!;89MpJyBVto-1KI*#$M-kqY(ZWZcuV%>9Pl0B!<&T)(o&mLQY;S7YgYV`(2ZP(5Z`B&?mtGenjjXzlqR z9GWnfjM_JjnQKS&wV|vI6_*LM<0gx+?um7Fgm`QzineyNb_OHFgtf3Y|7d<+#o&T5 z>yilhz%py1aBknHgO!7eMqSmTudEp>tQj@djO)!&Q-|po)3^5QhW-twHjWj)_KUte zYeKf-=T>C2^%Qr$J4%$~okwE#aX_hqH@L^qNJ|dnf4>G->eI9 zQH-#ayF8V}8WjS=Ro3HV+YV{?6_pkiO_IgmKZhxiGw5&aCJkPUdEq Ts9uDO_ZH< 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 diff --git a/app/blueprints/main.py b/app/blueprints/main.py index b9313fd..8b3d19e 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -2,7 +2,7 @@ 主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo """ import os -from flask import Blueprint, send_file +from flask import Blueprint, send_file, render_template_string from app_common import ( get_db, @@ -20,7 +20,39 @@ import requests from config import base_url,version,JAVA_API_BASE -main_bp = Blueprint('main', __name__) +main_bp = Blueprint('main', __name__) + + +def _resolve_asset_filename(filename): + """Resolve hashed Vite assets without maintaining hardcoded alias tables.""" + safe_name = os.path.basename(filename) + exact_path = os.path.join(ASSETS_DIR, safe_name) + if os.path.isfile(exact_path): + return safe_name + + base_name, ext = os.path.splitext(safe_name) + if not base_name or not ext: + return safe_name + + parts = base_name.split('-') + if len(parts) < 2: + return safe_name + + try: + asset_names = os.listdir(ASSETS_DIR) + except OSError: + return safe_name + + for prefix_length in range(len(parts) - 1, 0, -1): + prefix = '-'.join(parts[:prefix_length]) + matches = [ + asset_name for asset_name in asset_names + if asset_name.endswith(ext) and asset_name.startswith(prefix) + ] + if len(matches) == 1: + return matches[0] + + return safe_name @main_bp.route('/') @@ -50,10 +82,42 @@ def wb(): return _render_html('index.html') -@main_bp.route('/brand') -@login_required -def brand_page(): - return _render_html('brand.html', user_id=session.get('user_id')) +@main_bp.route('/brand') +@login_required +def brand_page(): + repo_brand_path = os.path.abspath(os.path.join(BASE_DIR, '..', 'web_source', 'brand.html')) + if os.path.isfile(repo_brand_path): + try: + with open(repo_brand_path, 'rb') as f: + raw = f.read() + try: + from html_crypto import decrypt + content = decrypt(raw).decode('utf-8') + except Exception: + if raw[:7] == b'gAAAAAB': + raise + content = raw.decode('utf-8', errors='replace') + return render_template_string(content, user_id=session.get('user_id')) + except Exception: + pass + return _render_html('brand.html', user_id=session.get('user_id')) + + +@main_bp.route('/brand/legacy') +@login_required +def brand_page_legacy(): + legacy_path = os.path.join(BASE_DIR, 'web_source', 'templates_backup', 'brand.html') + if not os.path.isfile(legacy_path): + return '', 404 + with open(legacy_path, 'rb') as f: + content = f.read().decode('utf-8', errors='replace') + content = content.replace( + '
', + '
', + 1, + ) + content = content.replace('height: calc(100vh - 56px);', 'height: 100vh;', 1) + return render_template_string(content, user_id=session.get('user_id')) @@ -69,10 +133,11 @@ def serve_static(filename): return send_file(file_abs, as_attachment=False) -@main_bp.route('/assets/') -def serve_assets(filename): - """提供 static 目录及子目录下的静态文件访问。""" - filepath = os.path.normpath(os.path.join(ASSETS_DIR, filename)) +@main_bp.route('/assets/') +def serve_assets(filename): + """提供 static 目录及子目录下的静态文件访问。""" + filename = _resolve_asset_filename(filename) + filepath = os.path.normpath(os.path.join(ASSETS_DIR, filename)) static_abs = os.path.abspath(ASSETS_DIR) file_abs = os.path.abspath(filepath) if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): @@ -82,14 +147,19 @@ def serve_assets(filename): @main_bp.route('/new_web_source/') def serve_new_web_source(filename): - """提供 static 目录及子目录下的静态文件访问。""" - filepath = os.path.normpath(os.path.join("new_web_source", filename)) - static_abs = os.path.abspath("new_web_source") - file_abs = os.path.abspath(filepath) - if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): - return '', 404 - return send_file(file_abs, as_attachment=False) - + """提供 new_web_source 目录下的静态页面文件访问。""" + candidate_dirs = [ + os.path.abspath(os.path.join(BASE_DIR, 'new_web_source')), + os.path.abspath(os.path.join(BASE_DIR, '..', 'new_web_source')), + ] + for static_abs in candidate_dirs: + filepath = os.path.normpath(os.path.join(static_abs, filename)) + file_abs = os.path.abspath(filepath) + if not file_abs.startswith(static_abs): + continue + if os.path.isfile(file_abs): + return send_file(file_abs, as_attachment=False) + return '', 404 @main_bp.route('/logo.jpg', methods=['GET']) @@ -161,4 +231,4 @@ def proxy(path): return Response("无法连接到后端服务", status=502) except Exception as e: print(f"代理请求失败: {e}") - return Response(f"代理错误: {str(e)}", status=500) + return Response(f"代理错误: {str(e)}", status=500) diff --git a/app/config.py b/app/config.py index 137cad8..db6b0e8 100644 --- a/app/config.py +++ b/app/config.py @@ -3,11 +3,12 @@ coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgq workflow_id = "7608812635877900322" STITCH_WORKFLOW_ID = "7608813873483300907" -import os -from dotenv import load_dotenv -from queue import Queue -load_dotenv() -_base_url = os.getenv("base_url","http://159.75.121.33:15124") +import os +from dotenv import load_dotenv +from queue import Queue +load_dotenv() +_base_url = os.getenv("base_url","http://159.75.121.33:15124") +base_dir = os.path.dirname(os.path.abspath(__file__)) # MySQL 配置 mysql_host = os.getenv("mysql_host") @@ -15,10 +16,11 @@ mysql_user = os.getenv("mysql_user") mysql_password = os.getenv("mysql_password","WTFrb5y6hNLz6hNy") mysql_database = os.getenv("mysql_database","aiimage") proxy_url = os.getenv("proxy_url") -proxy_mode = int(os.getenv("proxy_mode",1)) - -client_name=os.getenv("client_name") + ".exe" -JAVA_API_BASE = os.getenv("java_api_base", "http://127.0.0.1:18080") +proxy_mode = int(os.getenv("proxy_mode",1)) + +_client_name = os.getenv("client_name", "").strip() +client_name = f"{_client_name}.exe" if _client_name else "client.exe" +JAVA_API_BASE = os.getenv("java_api_base", "http://127.0.0.1:18080") # 紫鸟浏览器配置 from urllib.parse import unquote diff --git a/app/main.py b/app/main.py index be4fba7..f8bdcdd 100644 --- a/app/main.py +++ b/app/main.py @@ -433,7 +433,7 @@ def main(): webview.start( debug=True, storage_path=cache_path, - private_mode=False + private_mode=True ) diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java index 5c7fdf8..3a157f0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java @@ -8,4 +8,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; public class TaskPressureProperties { private long localTaskEntityCacheMillis = 3000; private int dbSelectBatchSize = 200; + private long scopePayloadFlushIntervalMillis = 15000; + private long scopePayloadBufferRetentionHours = 24; + private int scopePayloadRecoveryMaxFiles = 200; + private String scopePayloadCleanupCron = "15 */30 * * * *"; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java index b7d0612..be8759c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java @@ -1,25 +1,13 @@ package com.nanri.aiimage.modules.brand.service; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.BrandProgressProperties; -import com.nanri.aiimage.modules.brand.model.dto.BrandCrawlResultFileDto; -import com.nanri.aiimage.modules.brand.model.dto.BrandFileAggregateCacheDto; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.security.MessageDigest; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; @Service @@ -33,6 +21,7 @@ public class BrandTaskProgressCacheService { private final StringRedisTemplate stringRedisTemplate; private final BrandProgressProperties brandProgressProperties; + @SuppressWarnings("unused") private final ObjectMapper objectMapper; public BrandTaskProgressCacheService(StringRedisTemplate stringRedisTemplate, @@ -96,121 +85,9 @@ public class BrandTaskProgressCacheService { return stringRedisTemplate.opsForHash().entries(buildKey(taskId)); } - public void saveParsedPayload(Long taskId, Object payload) { - try { - Files.createDirectories(buildTaskDir(taskId)); - Files.writeString( - buildPayloadFile(taskId), - objectMapper.writeValueAsString(payload), - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE - ); - } catch (Exception ex) { - throw new BusinessException("鏆傚瓨鍝佺墝鍘熷鏁版嵁澶辫触"); - } - } - - public T getParsedPayload(Long taskId, TypeReference typeReference) { - Path payloadFile = buildPayloadFile(taskId); - if (!Files.isRegularFile(payloadFile)) { - return null; - } - try { - return objectMapper.readValue(Files.readString(payloadFile), typeReference); - } catch (Exception ex) { - throw new BusinessException("璇诲彇鍝佺墝鍘熷鏁版嵁澶辫触"); - } - } - - public ChunkStoreResult storeChunk(Long taskId, BrandCrawlResultFileDto file) { - validateChunk(file); - String fileUrl = normalizeFileUrl(file.getFileUrl()); - String chunkDigest = digestChunk(file); - String digestField = String.valueOf(file.getChunkIndex()); - String digestKey = buildChunkDigestKey(taskId, fileUrl); - Object existingDigest = stringRedisTemplate.opsForHash().get(digestKey, digestField); - if (existingDigest instanceof String existing && !existing.isBlank()) { - refreshFileKeys(taskId, fileUrl); - if (existing.equals(chunkDigest)) { - BrandFileAggregateCacheDto aggregate = getFileAggregate(taskId, fileUrl); - int finishedFiles = countCompletedFiles(taskId); - return new ChunkStoreResult(false, false, finishedFiles, aggregate); - } - throw new BusinessException("鍚屼竴鍒嗙墖閲嶅鎻愪氦浣嗗唴瀹逛笉涓€鑷? " + fileUrl + "#" + file.getChunkIndex()); - } - - BrandFileAggregateCacheDto aggregate = getFileAggregate(taskId, fileUrl); - if (aggregate == null) { - aggregate = createAggregate(file); - } else { - ensureChunkConsistency(aggregate, file); - } - mergeAggregate(aggregate, file); - - stringRedisTemplate.opsForSet().add(buildChunkReceiptKey(taskId, fileUrl), digestField); - Long receivedSize = stringRedisTemplate.opsForSet().size(buildChunkReceiptKey(taskId, fileUrl)); - int receivedCount = receivedSize == null ? 0 : receivedSize.intValue(); - if (receivedCount <= 0) { - receivedCount = aggregate.getReceivedChunkCount() == null ? 0 : aggregate.getReceivedChunkCount(); - } - aggregate.setReceivedChunkCount(Math.max(receivedCount, aggregate.getReceivedChunkCount() == null ? 0 : aggregate.getReceivedChunkCount())); - boolean fileCompleted = aggregate.getChunkTotal() != null - && aggregate.getChunkTotal() > 0 - && aggregate.getReceivedChunkCount() >= aggregate.getChunkTotal(); - aggregate.setCompleted(fileCompleted); - saveFileAggregate(taskId, aggregate); - stringRedisTemplate.opsForHash().put(digestKey, digestField, chunkDigest); - refreshFileKeys(taskId, fileUrl); - - boolean newlyCompleted = false; - int finishedFiles = countCompletedFiles(taskId); - if (fileCompleted) { - Long added = stringRedisTemplate.opsForSet().add(buildCompletedFilesKey(taskId), fileUrl); - refreshTaskKey(buildCompletedFilesKey(taskId)); - newlyCompleted = added != null && added > 0; - if (newlyCompleted) { - finishedFiles = countCompletedFiles(taskId); - } - } - return new ChunkStoreResult(true, newlyCompleted, finishedFiles, aggregate); - } - - public BrandFileAggregateCacheDto getFileAggregate(Long taskId, String fileUrl) { - String normalizedFileUrl = normalizeFileUrl(fileUrl); - Object raw = stringRedisTemplate.opsForHash().get(buildFileAggregateKey(taskId), normalizedFileUrl); - if (!(raw instanceof String json) || json.isBlank()) { - return null; - } - try { - return objectMapper.readValue(json, BrandFileAggregateCacheDto.class); - } catch (Exception ex) { - throw new BusinessException("璇诲彇鍝佺墝鏂囦欢鑱氬悎缂撳瓨澶辫触"); - } - } - - public Map getAllFileAggregates(Long taskId) { - Map stored = stringRedisTemplate.opsForHash().entries(buildFileAggregateKey(taskId)); - Map result = new LinkedHashMap<>(); - for (Map.Entry entry : stored.entrySet()) { - if (!(entry.getKey() instanceof String fileUrl) || !(entry.getValue() instanceof String json) || json.isBlank()) { - continue; - } - try { - result.put(fileUrl, objectMapper.readValue(json, BrandFileAggregateCacheDto.class)); - } catch (Exception ignored) { - } - } - return result; - } - - public int countCompletedFiles(Long taskId) { - Long count = stringRedisTemplate.opsForSet().size(buildCompletedFilesKey(taskId)); - return count == null ? 0 : count.intValue(); - } - public boolean acquireFinalizeLock(Long taskId) { - Boolean ok = stringRedisTemplate.opsForValue().setIfAbsent(buildFinalizeLockKey(taskId), String.valueOf(Instant.now().toEpochMilli()), FINALIZE_LOCK_TTL); + Boolean ok = stringRedisTemplate.opsForValue() + .setIfAbsent(buildFinalizeLockKey(taskId), String.valueOf(Instant.now().toEpochMilli()), FINALIZE_LOCK_TTL); return Boolean.TRUE.equals(ok); } @@ -220,18 +97,7 @@ public class BrandTaskProgressCacheService { public void delete(Long taskId) { stringRedisTemplate.delete(buildKey(taskId)); - stringRedisTemplate.delete(buildFileAggregateKey(taskId)); - stringRedisTemplate.delete(buildCompletedFilesKey(taskId)); stringRedisTemplate.delete(buildFinalizeLockKey(taskId)); - stringRedisTemplate.delete(buildLegacyResultKey(taskId)); - deleteTaskDirQuietly(taskId); - } - - public void deleteFileState(Long taskId, String fileUrl) { - String normalizedFileUrl = normalizeFileUrl(fileUrl); - stringRedisTemplate.opsForHash().delete(buildFileAggregateKey(taskId), normalizedFileUrl); - stringRedisTemplate.delete(buildChunkReceiptKey(taskId, normalizedFileUrl)); - stringRedisTemplate.delete(buildChunkDigestKey(taskId, normalizedFileUrl)); } public String buildKey(Long taskId) { @@ -242,182 +108,14 @@ public class BrandTaskProgressCacheService { return brandProgressProperties.getHeartbeatTimeoutMinutes(); } - private void saveFileAggregate(Long taskId, BrandFileAggregateCacheDto aggregate) { - try { - stringRedisTemplate.opsForHash().put(buildFileAggregateKey(taskId), normalizeFileUrl(aggregate.getFileUrl()), objectMapper.writeValueAsString(aggregate)); - refreshTaskKey(buildFileAggregateKey(taskId)); - } catch (Exception ex) { - throw new BusinessException("鏆傚瓨鍝佺墝鏂囦欢鑱氬悎缁撴灉澶辫触"); - } - } - - private void validateChunk(BrandCrawlResultFileDto file) { - if (file == null) { - throw new BusinessException("鍒嗙墖涓嶈兘涓虹┖"); - } - if (file.getChunkIndex() == null || file.getChunkTotal() == null || file.getChunkIndex() <= 0 || file.getChunkTotal() <= 0 || file.getChunkIndex() > file.getChunkTotal()) { - throw new BusinessException("鍒嗙墖鍙傛暟涓嶅悎娉?"); - } - if (file.getFileUrl() == null || file.getFileUrl().isBlank()) { - throw new BusinessException("fileUrl 涓嶈兘涓虹┖"); - } - } - - private BrandFileAggregateCacheDto createAggregate(BrandCrawlResultFileDto file) { - BrandFileAggregateCacheDto aggregate = new BrandFileAggregateCacheDto(); - aggregate.setFileUrl(normalizeFileUrl(file.getFileUrl())); - aggregate.setOriginalFilename(file.getOriginalFilename()); - aggregate.setRelativePath(file.getRelativePath()); - aggregate.setMainSheetName(file.getMainSheetName()); - aggregate.setChunkTotal(file.getChunkTotal()); - aggregate.setTotalLines(safePositive(file.getTotalLines())); - aggregate.setReceivedChunkCount(0); - aggregate.setProcessedLineCount(0); - aggregate.setCompleted(false); - aggregate.setInvalidBrands(new ArrayList<>()); - aggregate.setQueryFailedBrands(new ArrayList<>()); - return aggregate; - } - - private void ensureChunkConsistency(BrandFileAggregateCacheDto aggregate, BrandCrawlResultFileDto file) { - if (aggregate.getChunkTotal() != null && file.getChunkTotal() != null && !aggregate.getChunkTotal().equals(file.getChunkTotal())) { - throw new BusinessException("鍚屼竴鏂囦欢鐨?chunkTotal 涓嶄竴鑷? " + aggregate.getFileUrl()); - } - if (aggregate.getChunkTotal() == null) { - aggregate.setChunkTotal(file.getChunkTotal()); - } - int incomingTotalLines = safePositive(file.getTotalLines()); - if (incomingTotalLines > 0) { - aggregate.setTotalLines(Math.max(safePositive(aggregate.getTotalLines()), incomingTotalLines)); - } - if ((aggregate.getOriginalFilename() == null || aggregate.getOriginalFilename().isBlank()) && file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank()) { - aggregate.setOriginalFilename(file.getOriginalFilename()); - } - if ((aggregate.getRelativePath() == null || aggregate.getRelativePath().isBlank()) && file.getRelativePath() != null && !file.getRelativePath().isBlank()) { - aggregate.setRelativePath(file.getRelativePath()); - } - if ((aggregate.getMainSheetName() == null || aggregate.getMainSheetName().isBlank()) && file.getMainSheetName() != null && !file.getMainSheetName().isBlank()) { - aggregate.setMainSheetName(file.getMainSheetName()); - } - } - - private void mergeAggregate(BrandFileAggregateCacheDto aggregate, BrandCrawlResultFileDto file) { - int processed = safePositive(aggregate.getProcessedLineCount()) - + sizeOf(file.getKeptRows()) - + sizeOf(file.getInvalidBrands()) - + sizeOf(file.getQueryFailedBrands()); - aggregate.setProcessedLineCount(processed); - if (file.getInvalidBrands() != null && !file.getInvalidBrands().isEmpty()) { - aggregate.getInvalidBrands().addAll(file.getInvalidBrands()); - } - if (file.getQueryFailedBrands() != null && !file.getQueryFailedBrands().isEmpty()) { - aggregate.getQueryFailedBrands().addAll(file.getQueryFailedBrands()); - } - } - - private int sizeOf(List list) { - return list == null ? 0 : list.size(); - } - - private int safePositive(Integer value) { - return value == null || value <= 0 ? 0 : value; - } - - private String digestChunk(BrandCrawlResultFileDto file) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] bytes = digest.digest(objectMapper.writeValueAsString(file).getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } catch (Exception ex) { - throw new BusinessException("璁$畻鍝佺墝缁撴灉鍒嗙墖鎽樿澶辫触"); - } - } - - private void refreshFileKeys(Long taskId, String fileUrl) { - refreshTaskKey(buildChunkReceiptKey(taskId, fileUrl)); - refreshTaskKey(buildChunkDigestKey(taskId, fileUrl)); - refreshTaskKey(buildFileAggregateKey(taskId)); - } - - private void refreshTaskKey(String key) { - stringRedisTemplate.expire(key, ttl()); - } - private Duration ttl() { return Duration.ofHours(brandProgressProperties.getTtlHours()); } - private Path buildTaskDir(Long taskId) { - return Path.of(System.getProperty("java.io.tmpdir"), "brand-task-cache", String.valueOf(taskId)); - } - - private Path buildPayloadFile(Long taskId) { - return buildTaskDir(taskId).resolve("parsed-payload.json"); - } - - private void deleteTaskDirQuietly(Long taskId) { - Path taskDir = buildTaskDir(taskId); - if (!Files.exists(taskDir)) { - return; - } - try (var walk = Files.walk(taskDir)) { - walk.sorted(java.util.Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException ignored) { - } - }); - } catch (IOException ignored) { - } - } - - private String buildFileAggregateKey(Long taskId) { - return "brand:task:file-aggregate:" + taskId; - } - - private String buildChunkReceiptKey(Long taskId, String fileUrl) { - return "brand:task:file-chunks:" + taskId + ":" + hashFileUrl(fileUrl); - } - - private String buildChunkDigestKey(Long taskId, String fileUrl) { - return "brand:task:file-chunk-digests:" + taskId + ":" + hashFileUrl(fileUrl); - } - - private String buildCompletedFilesKey(Long taskId) { - return "brand:task:completed-files:" + taskId; - } - private String buildFinalizeLockKey(Long taskId) { return "brand:task:finalizing:" + taskId; } - private String buildLegacyResultKey(Long taskId) { - return "brand:task:result-chunks:" + taskId; - } - - private String normalizeFileUrl(String fileUrl) { - return fileUrl == null ? "" : fileUrl.trim(); - } - - private String hashFileUrl(String fileUrl) { - String normalized = normalizeFileUrl(fileUrl); - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] bytes = digest.digest(normalized.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } catch (Exception ex) { - throw new BusinessException("鐢熸垚鏂囦欢缂撳瓨閿け璐?"); - } - } - private String normalizePhase(String phase) { if (phase == null || phase.isBlank()) { return PHASE_CRAWLING; @@ -432,7 +130,4 @@ public class BrandTaskProgressCacheService { private String blankToEmpty(String value) { return value == null ? "" : value.trim(); } - - public record ChunkStoreResult(boolean stored, boolean newlyCompletedFile, int finishedFiles, BrandFileAggregateCacheDto aggregate) { - } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java index f9e7067..7d3b590 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java @@ -39,7 +39,7 @@ import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.WorkbookFactory; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -83,6 +83,7 @@ public class BrandTaskService { private final StorageProperties storageProperties; private final BrandProgressProperties brandProgressProperties; private final BrandTaskProgressCacheService brandTaskProgressCacheService; + private final BrandTaskStorageService brandTaskStorageService; private final DistributedJobLockService distributedJobLockService; private final ObjectMapper objectMapper; @@ -193,7 +194,7 @@ public class BrandTaskService { cachedFiles.add(cacheDto); } - brandTaskProgressCacheService.saveParsedPayload(taskId, cachedFiles); + brandTaskStorageService.saveParsedPayload(taskId, cachedFiles); brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper() .eq(BrandCrawlTaskEntity::getId, taskId) @@ -226,9 +227,7 @@ public class BrandTaskService { sourceByUrl.put(file.getFileUrl(), file); sourceIndexByUrl.put(file.getFileUrl(), i + 1); } - List cachedFiles = brandTaskProgressCacheService.getParsedPayload(taskId, - new TypeReference>() { - }); + List cachedFiles = brandTaskStorageService.getParsedPayload(taskId); if (cachedFiles == null || cachedFiles.isEmpty()) { throw new BusinessException("任务原始数据已过期,请重新创建任务"); } @@ -252,7 +251,7 @@ public class BrandTaskService { totalCount, Thread.currentThread().getName()); - int finishedCount = brandTaskProgressCacheService.countCompletedFiles(taskId); + int finishedCount = brandTaskStorageService.countCompletedFiles(taskId); for (BrandCrawlResultFileDto resultFile : resultFiles) { String fileUrl = blankToNull(resultFile.getFileUrl()); if (fileUrl == null) { @@ -272,7 +271,7 @@ public class BrandTaskService { } else if (totalLines > 0 && resultFile.getTotalLines() > totalLines) { throw new BusinessException("totalLines 超过原始文件行数: " + fileUrl); } - BrandTaskProgressCacheService.ChunkStoreResult storeResult = brandTaskProgressCacheService.storeChunk(taskId, resultFile); + BrandTaskStorageService.ChunkStoreResult storeResult = brandTaskStorageService.storeChunk(taskId, resultFile); finishedCount = Math.max(finishedCount, storeResult.finishedFiles()); log.info("[brand-submit] taskId={} fileUrl={} chunk={}/{} stored={} newlyCompletedFile={} finishedFiles={}", taskId, @@ -316,6 +315,7 @@ public class BrandTaskService { if (updated == 0) { throw new BusinessException("任务不存在或无法取消"); } + brandTaskStorageService.deleteTaskData(taskId); brandTaskProgressCacheService.delete(taskId); } @@ -327,6 +327,7 @@ public class BrandTaskService { if (deleted == 0) { throw new BusinessException("任务不存在或正在执行中无法删除"); } + brandTaskStorageService.deleteTaskData(taskId); brandTaskProgressCacheService.delete(taskId); } @@ -555,7 +556,7 @@ public class BrandTaskService { if (STATUS_FAILED.equalsIgnoreCase(freshStatus)) { throw new BusinessException("任务已结束,不能继续组装结果"); } - Map aggregates = brandTaskProgressCacheService.getAllFileAggregates(taskId); + Map aggregates = brandTaskStorageService.getAllFileAggregates(taskId); log.info("[brand-finalize] taskId={} aggregateFiles={} expectedFiles={} thread={}", taskId, aggregates.size(), @@ -600,9 +601,7 @@ public class BrandTaskService { if (updated == 0) { throw new BusinessException("任务已取消"); } - for (BrandSourceFileDto sourceFile : sourceFiles) { - brandTaskProgressCacheService.deleteFileState(taskId, sourceFile.getFileUrl()); - } + brandTaskStorageService.deleteTaskData(taskId); brandTaskProgressCacheService.delete(taskId); log.info("[brand-finalize] taskId={} finalized success files={} elapsedMs={}", taskId, @@ -614,7 +613,7 @@ public class BrandTaskService { .eq(BrandCrawlTaskEntity::getId, taskId) .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED) .set(BrandCrawlTaskEntity::getStatus, STATUS_FAILED) - .set(BrandCrawlTaskEntity::getProgressCurrent, brandTaskProgressCacheService.countCompletedFiles(taskId)) + .set(BrandCrawlTaskEntity::getProgressCurrent, brandTaskStorageService.countCompletedFiles(taskId)) .set(BrandCrawlTaskEntity::getProgressTotal, totalCount) .set(BrandCrawlTaskEntity::getErrorMessage, ex.getMessage())); brandTaskProgressCacheService.markFailed(taskId, ex.getMessage()); @@ -622,6 +621,8 @@ public class BrandTaskService { throw businessException; } throw new BusinessException(ex.getMessage()); + } finally { + cleanupBrandResultTempFiles(outputEntries, outputDir); } } @@ -746,7 +747,7 @@ public class BrandTaskService { String value = index == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(index))); rowData.put(column, value); } - String brand = normalizeCellText(Objects.toString(rowData.getOrDefault("\u54c1\u724c", ""), "")); + String brand = normalizeCellText(Objects.toString(rowData.getOrDefault("品牌", ""), "")); if (brand.isBlank()) { rowData.put("__rowIndex", rowNum + 1); rows.add(rowData); @@ -827,7 +828,9 @@ public class BrandTaskService { BrandParsedFileCacheDto cachedFile, BrandFileAggregateCacheDto resultFile) throws IOException { String actualStrategy = normalizeStrategy(strategy); - try (XSSFWorkbook workbook = new XSSFWorkbook()) { + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try { String mainSheetName = blankToDefault(cachedFile.getSheetName(), "Sheet1"); var mainSheet = workbook.createSheet(mainSheetName); var headerRow = mainSheet.createRow(0); @@ -878,8 +881,35 @@ public class BrandTaskService { } try (FileOutputStream outputStream = new FileOutputStream(outputFile)) { + applyBrandSheetWidths(mainSheet, columns.size()); + applyFixedColumnWidths(invalidSheet, new int[]{20, 12, 18}); + applyFixedColumnWidths(queryFailedSheet, new int[]{20, 18}); workbook.write(outputStream); } + } finally { + try { + workbook.close(); + } catch (Exception ignored) { + } + workbook.dispose(); + } + } + + private void applyBrandSheetWidths(Sheet sheet, int columnCount) { + if (sheet == null || columnCount <= 0) { + return; + } + for (int i = 0; i < columnCount; i++) { + sheet.setColumnWidth(i, 20 * 256); + } + } + + private void applyFixedColumnWidths(Sheet sheet, int[] widths) { + if (sheet == null || widths == null) { + return; + } + for (int i = 0; i < widths.length; i++) { + sheet.setColumnWidth(i, widths[i] * 256); } } @@ -946,6 +976,43 @@ public class BrandTaskService { return zipFile; } + private void cleanupBrandResultTempFiles(List entries, File outputDir) { + if (entries != null) { + for (OutputEntry entry : entries) { + if (entry == null) { + continue; + } + deleteQuietly(entry.resultFile()); + if (isManagedBrandSourceTempFile(entry.sourceFile())) { + deleteQuietly(entry.sourceFile()); + } + } + } + deleteQuietly(outputDir); + } + + private boolean isManagedBrandSourceTempFile(File file) { + if (file == null) { + return false; + } + File managedDir = FileUtil.file(storageProperties.getLocalTempDir(), "brand-source-download"); + try { + return file.getCanonicalPath().startsWith(managedDir.getCanonicalPath()); + } catch (IOException ex) { + return false; + } + } + + private void deleteQuietly(File file) { + if (file == null || !file.exists()) { + return; + } + try { + FileUtil.del(file); + } catch (Exception ignored) { + } + } + private File buildNamedOutputFile(File outputDir, String filename) { File candidate = FileUtil.file(outputDir, filename); if (!candidate.exists()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java new file mode 100644 index 0000000..9deb686 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java @@ -0,0 +1,475 @@ +package com.nanri.aiimage.modules.brand.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.brand.model.dto.BrandCrawlResultFileDto; +import com.nanri.aiimage.modules.brand.model.dto.BrandFileAggregateCacheDto; +import com.nanri.aiimage.modules.brand.model.dto.BrandParsedFileCacheDto; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class BrandTaskStorageService { + + private static final String MODULE_TYPE = "BRAND"; + private static final String OSS_POINTER_PREFIX = "oss:"; + + private final TaskChunkMapper taskChunkMapper; + private final TaskScopeStateMapper taskScopeStateMapper; + private final ObjectMapper objectMapper; + private final OssStorageService ossStorageService; + + @Transactional + public void saveParsedPayload(Long taskId, List payload) { + if (taskId == null || taskId <= 0 || payload == null || payload.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(); + for (BrandParsedFileCacheDto item : payload) { + if (item == null || isBlank(item.getFileUrl())) { + continue; + } + String scopeKey = normalize(item.getFileUrl()); + String scopeHash = hash(scopeKey); + String parsedJson = storeParsedPayload(taskId, scopeHash, writeJson(item, "Save brand task parsed payload failed")); + TaskScopeStateEntity state = getScopeState(taskId, scopeHash); + if (state == null) { + TaskScopeStateEntity entity = new TaskScopeStateEntity(); + entity.setTaskId(taskId); + entity.setModuleType(MODULE_TYPE); + entity.setScopeKey(scopeKey); + entity.setScopeHash(scopeHash); + entity.setParsedPayloadJson(parsedJson); + entity.setChunkTotal(0); + entity.setReceivedChunkCount(0); + entity.setCompleted(0); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskScopeStateMapper.insert(entity); + continue; + } catch (DuplicateKeyException ignored) { + state = getScopeState(taskId, scopeHash); + } + } + if (state == null) { + throw new BusinessException("Save brand task parsed payload failed"); + } + deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), parsedJson); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getScopeKey, scopeKey) + .set(TaskScopeStateEntity::getParsedPayloadJson, parsedJson) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + } + } + + public List getParsedPayload(Long taskId) { + if (taskId == null || taskId <= 0) { + return List.of(); + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getParsedPayloadJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .isNotNull(TaskScopeStateEntity::getParsedPayloadJson)); + if (states == null || states.isEmpty()) { + return List.of(); + } + List result = new ArrayList<>(); + for (TaskScopeStateEntity state : states) { + String payloadJson = resolveParsedPayload(state.getParsedPayloadJson()); + if (isBlank(payloadJson)) { + continue; + } + try { + result.add(objectMapper.readValue(payloadJson, BrandParsedFileCacheDto.class)); + } catch (Exception ex) { + throw new BusinessException("Read brand task parsed payload failed"); + } + } + return result; + } + + @Transactional + public ChunkStoreResult storeChunk(Long taskId, BrandCrawlResultFileDto file) { + validateChunk(file); + String scopeKey = normalize(file.getFileUrl()); + String scopeHash = hash(scopeKey); + String payloadJson = writeJson(file, "Save brand task chunk failed"); + String payloadHash = hash(payloadJson); + LocalDateTime now = LocalDateTime.now(); + + TaskChunkEntity existingChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .eq(TaskChunkEntity::getChunkIndex, file.getChunkIndex()) + .last("limit 1")); + if (existingChunk != null) { + if (payloadHash.equals(existingChunk.getPayloadHash())) { + BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); + return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate); + } + throw new BusinessException("Brand task chunk conflict: " + scopeKey + "#" + file.getChunkIndex()); + } + + TaskChunkEntity entity = new TaskChunkEntity(); + entity.setTaskId(taskId); + entity.setModuleType(MODULE_TYPE); + entity.setScopeKey(scopeKey); + entity.setScopeHash(scopeHash); + entity.setChunkIndex(file.getChunkIndex()); + entity.setChunkTotal(file.getChunkTotal()); + entity.setPayloadJson(payloadJson); + entity.setPayloadHash(payloadHash); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskChunkMapper.insert(entity); + } catch (DuplicateKeyException ex) { + TaskChunkEntity concurrentChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .eq(TaskChunkEntity::getChunkIndex, file.getChunkIndex()) + .last("limit 1")); + if (concurrentChunk != null && payloadHash.equals(concurrentChunk.getPayloadHash())) { + BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); + return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate); + } + throw new BusinessException("Brand task chunk conflict: " + scopeKey + "#" + file.getChunkIndex()); + } + + BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); + boolean completed = Boolean.TRUE.equals(aggregate.getCompleted()); + saveAggregate(taskId, scopeKey, scopeHash, aggregate, now); + return new ChunkStoreResult(true, completed, countCompletedFiles(taskId), aggregate); + } + + public BrandFileAggregateCacheDto getFileAggregate(Long taskId, String fileUrl) { + TaskScopeStateEntity state = getScopeState(taskId, hash(normalize(fileUrl))); + if (state == null || isBlank(state.getStateJson())) { + return null; + } + try { + return objectMapper.readValue(state.getStateJson(), BrandFileAggregateCacheDto.class); + } catch (Exception ex) { + throw new BusinessException("Read brand task aggregate failed"); + } + } + + public Map getAllFileAggregates(Long taskId) { + if (taskId == null || taskId <= 0) { + return Map.of(); + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getScopeKey, TaskScopeStateEntity::getStateJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .isNotNull(TaskScopeStateEntity::getStateJson)); + if (states == null || states.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + for (TaskScopeStateEntity state : states) { + if (isBlank(state.getStateJson()) || isBlank(state.getScopeKey())) { + continue; + } + try { + result.put(state.getScopeKey(), objectMapper.readValue(state.getStateJson(), BrandFileAggregateCacheDto.class)); + } catch (Exception ex) { + throw new BusinessException("Read brand task aggregate failed"); + } + } + return result; + } + + public int countCompletedFiles(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getCompleted, 1)); + return count == null ? 0 : count.intValue(); + } + + @Transactional + public void deleteTaskData(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getParsedPayloadJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + if (states != null) { + for (TaskScopeStateEntity state : states) { + deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), null); + } + } + taskChunkMapper.delete(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + taskScopeStateMapper.delete(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + } + + private void saveAggregate(Long taskId, + String scopeKey, + String scopeHash, + BrandFileAggregateCacheDto aggregate, + LocalDateTime now) { + String aggregateJson = writeJson(aggregate, "Save brand task aggregate failed"); + TaskScopeStateEntity state = getScopeState(taskId, scopeHash); + if (state == null) { + throw new BusinessException("Brand task aggregate state missing: " + scopeKey); + } + int chunkTotal = aggregate.getChunkTotal() == null ? 0 : aggregate.getChunkTotal(); + int receivedCount = aggregate.getReceivedChunkCount() == null ? 0 : aggregate.getReceivedChunkCount(); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getScopeKey, scopeKey) + .set(TaskScopeStateEntity::getStateJson, aggregateJson) + .set(TaskScopeStateEntity::getChunkTotal, chunkTotal) + .set(TaskScopeStateEntity::getReceivedChunkCount, receivedCount) + .set(TaskScopeStateEntity::getCompleted, Boolean.TRUE.equals(aggregate.getCompleted()) ? 1 : 0) + .set(TaskScopeStateEntity::getLastChunkAt, now) + .set(TaskScopeStateEntity::getLastError, null) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + } + + private TaskScopeStateEntity getScopeState(Long taskId, String scopeHash) { + return taskScopeStateMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getScopeHash, scopeHash) + .last("limit 1")); + } + + private BrandFileAggregateCacheDto createAggregate(BrandCrawlResultFileDto file) { + BrandFileAggregateCacheDto aggregate = new BrandFileAggregateCacheDto(); + aggregate.setFileUrl(normalize(file.getFileUrl())); + aggregate.setOriginalFilename(file.getOriginalFilename()); + aggregate.setRelativePath(file.getRelativePath()); + aggregate.setMainSheetName(file.getMainSheetName()); + aggregate.setChunkTotal(file.getChunkTotal()); + aggregate.setTotalLines(safePositive(file.getTotalLines())); + aggregate.setReceivedChunkCount(0); + aggregate.setProcessedLineCount(0); + aggregate.setCompleted(false); + aggregate.setInvalidBrands(new ArrayList<>()); + aggregate.setQueryFailedBrands(new ArrayList<>()); + return aggregate; + } + + private void ensureChunkConsistency(BrandFileAggregateCacheDto aggregate, BrandCrawlResultFileDto file) { + if (aggregate.getChunkTotal() != null && file.getChunkTotal() != null && !aggregate.getChunkTotal().equals(file.getChunkTotal())) { + throw new BusinessException("Brand task chunkTotal mismatch: " + aggregate.getFileUrl()); + } + if (aggregate.getChunkTotal() == null) { + aggregate.setChunkTotal(file.getChunkTotal()); + } + int incomingTotalLines = safePositive(file.getTotalLines()); + if (incomingTotalLines > 0) { + aggregate.setTotalLines(Math.max(safePositive(aggregate.getTotalLines()), incomingTotalLines)); + } + if (isBlank(aggregate.getOriginalFilename()) && !isBlank(file.getOriginalFilename())) { + aggregate.setOriginalFilename(file.getOriginalFilename()); + } + if (isBlank(aggregate.getRelativePath()) && !isBlank(file.getRelativePath())) { + aggregate.setRelativePath(file.getRelativePath()); + } + if (isBlank(aggregate.getMainSheetName()) && !isBlank(file.getMainSheetName())) { + aggregate.setMainSheetName(file.getMainSheetName()); + } + } + + private void mergeAggregate(BrandFileAggregateCacheDto aggregate, BrandCrawlResultFileDto file) { + int processed = safePositive(aggregate.getProcessedLineCount()) + + sizeOf(file.getKeptRows()) + + sizeOf(file.getInvalidBrands()) + + sizeOf(file.getQueryFailedBrands()); + aggregate.setProcessedLineCount(processed); + if (file.getInvalidBrands() != null && !file.getInvalidBrands().isEmpty()) { + aggregate.getInvalidBrands().addAll(file.getInvalidBrands()); + } + if (file.getQueryFailedBrands() != null && !file.getQueryFailedBrands().isEmpty()) { + aggregate.getQueryFailedBrands().addAll(file.getQueryFailedBrands()); + } + } + + private BrandFileAggregateCacheDto rebuildAggregate(Long taskId, String scopeKey, String scopeHash) { + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .orderByAsc(TaskChunkEntity::getChunkIndex, TaskChunkEntity::getId)); + if (chunks == null || chunks.isEmpty()) { + BrandFileAggregateCacheDto aggregate = getFileAggregate(taskId, scopeKey); + if (aggregate != null) { + aggregate.setFileUrl(scopeKey); + aggregate.setReceivedChunkCount(0); + aggregate.setProcessedLineCount(0); + aggregate.setCompleted(false); + aggregate.setInvalidBrands(new ArrayList<>()); + aggregate.setQueryFailedBrands(new ArrayList<>()); + } + return aggregate; + } + + BrandFileAggregateCacheDto aggregate = null; + for (TaskChunkEntity chunk : chunks) { + BrandCrawlResultFileDto chunkPayload = readChunkPayload(chunk.getPayloadJson()); + if (aggregate == null) { + aggregate = createAggregate(chunkPayload); + } else { + ensureChunkConsistency(aggregate, chunkPayload); + } + mergeAggregate(aggregate, chunkPayload); + } + if (aggregate == null) { + throw new BusinessException("Read brand task aggregate data failed"); + } + aggregate.setFileUrl(scopeKey); + aggregate.setReceivedChunkCount(chunks.size()); + aggregate.setCompleted(aggregate.getChunkTotal() != null + && aggregate.getChunkTotal() > 0 + && chunks.size() >= aggregate.getChunkTotal()); + return aggregate; + } + + private BrandCrawlResultFileDto readChunkPayload(String payloadJson) { + try { + return objectMapper.readValue(payloadJson, BrandCrawlResultFileDto.class); + } catch (Exception ex) { + throw new BusinessException("Read brand task chunk failed"); + } + } + + private void validateChunk(BrandCrawlResultFileDto file) { + if (file == null) { + throw new BusinessException("Brand task chunk must not be null"); + } + if (file.getChunkIndex() == null || file.getChunkTotal() == null || file.getChunkIndex() <= 0 + || file.getChunkTotal() <= 0 || file.getChunkIndex() > file.getChunkTotal()) { + throw new BusinessException("Brand task chunk parameters are invalid"); + } + if (isBlank(file.getFileUrl())) { + throw new BusinessException("fileUrl must not be blank"); + } + } + + private String storeParsedPayload(Long taskId, String scopeHash, String payloadJson) { + try { + String pointer = OSS_POINTER_PREFIX + ossStorageService.uploadTaskParsedPayload(MODULE_TYPE, taskId, scopeHash, payloadJson); + return objectMapper.writeValueAsString(pointer); + } catch (Exception ex) { + throw new BusinessException("Save brand task parsed payload failed"); + } + } + + private String resolveParsedPayload(String value) { + if (isBlank(value)) { + return value; + } + String ossPointer = extractOssPointer(value); + if (ossPointer == null) { + return value; + } + try { + return ossStorageService.readObjectAsString(ossPointer.substring(OSS_POINTER_PREFIX.length())); + } catch (Exception ex) { + throw new BusinessException("Read brand task parsed payload failed"); + } + } + + private void deleteParsedPayloadIfNeeded(String oldValue, String newValue) { + String oldPointer = extractOssPointer(oldValue); + String newPointer = extractOssPointer(newValue); + if (oldPointer == null || oldPointer.equals(newPointer)) { + return; + } + try { + ossStorageService.deleteObject(oldPointer.substring(OSS_POINTER_PREFIX.length())); + } catch (Exception ignored) { + } + } + + private String extractOssPointer(String value) { + if (isBlank(value)) { + return null; + } + if (value.startsWith(OSS_POINTER_PREFIX)) { + return value; + } + try { + String decoded = objectMapper.readValue(value, String.class); + return decoded != null && decoded.startsWith(OSS_POINTER_PREFIX) ? decoded : null; + } catch (Exception ignored) { + return null; + } + } + + private String writeJson(Object value, String message) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(message); + } + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private int sizeOf(List list) { + return list == null ? 0 : list.size(); + } + + private int safePositive(Integer value) { + return value == null || value <= 0 ? 0 : value; + } + + private String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(normalize(value).getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("failed to hash brand scope", ex); + } + } + + public record ChunkStoreResult(boolean stored, boolean newlyCompletedFile, int finishedFiles, BrandFileAggregateCacheDto aggregate) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java index 65a01e6..b8d3fd2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java @@ -44,11 +44,11 @@ public class ConvertRunService { private static final String MODULE_TYPE = "CONVERT"; private static final String TEMPLATE_CODE_FIVE_COUNTRIES = "uk_offer"; private static final List FIVE_COUNTRY_OUTPUT_FILES = List.of( - "\u82f1\u56fd.txt", - "\u6cd5\u56fd.txt", - "\u5fb7\u56fd.txt", - "\u897f\u73ed\u7259.txt", - "\u610f\u5927\u5229.txt" + "英国.txt", + "法国.txt", + "德国.txt", + "西班牙.txt", + "意大利.txt" ); private final FileTaskMapper fileTaskMapper; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java index 11a676a..b5f5bb0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java @@ -461,8 +461,8 @@ public class DedupeRunService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace("", "") + .replace(" ", " ") .replace("\r\n", " ") .replace("\r", " ") .replace("\n", " ") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java index 763b53e..0516837 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java @@ -229,7 +229,7 @@ public class DedupeTotalDataService { progress.setStatus("success"); } catch (Exception e) { progress.setStatus("failed"); - progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "鍒犻櫎 Excel 鍖归厤鏁版嵁澶辫触"); + progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "删除 Excel 匹配数据失败"); } finally { deleteQuietly(tempFile); } @@ -251,7 +251,7 @@ public class DedupeTotalDataService { progress.setStatus("success"); } catch (Exception e) { progress.setStatus("failed"); - progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "瀵煎叆 Excel 澶辫触"); + progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "导入 Excel 失败"); } finally { deleteQuietly(tempFile); } @@ -590,8 +590,8 @@ public class DedupeTotalDataService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace("", "") + .replace(" ", " ") .replace("\r\n", " ") .replace("\r", " ") .replace("\n", " ") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index 94a7ade..b259a9b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -135,9 +135,10 @@ public class DeleteBrandRunController { try { String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + String asciiFilename = buildAsciiDownloadFilename(filename, taskId); response.setContentType("application/octet-stream"); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename); + "attachment; filename=\"" + asciiFilename + "\"; filename*=UTF-8''" + encodedFilename); try (InputStream in = URI.create(url).toURL().openStream()) { byte[] buffer = new byte[65536]; int read; @@ -150,4 +151,24 @@ public class DeleteBrandRunController { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); } } + + private String buildAsciiDownloadFilename(String filename, Long taskId) { + String sanitized = filename == null ? "" : filename.replaceAll("[^A-Za-z0-9._-]", "_"); + sanitized = sanitized.replaceAll("_+", "_"); + sanitized = sanitized.replaceAll("^[_\\.]+|[_\\.]+$", ""); + if (!sanitized.isBlank() && sanitized.contains(".")) { + return sanitized; + } + String ext = "xlsx"; + if (filename != null) { + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex >= 0 && dotIndex < filename.length() - 1) { + String rawExt = filename.substring(dotIndex + 1).replaceAll("[^A-Za-z0-9]", ""); + if (!rawExt.isBlank()) { + ext = rawExt; + } + } + } + return "delete-brand_" + taskId + "." + ext; + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java index 4a0805c..f7d5a63 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java @@ -44,7 +44,7 @@ import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -74,6 +74,7 @@ public class DeleteBrandRunService { private final FileResultMapper fileResultMapper; private final LocalFileStorageService localFileStorageService; private final DeleteBrandTaskCacheService deleteBrandTaskCacheService; + private final DeleteBrandTaskStorageService deleteBrandTaskStorageService; private final ZiniaoShopSwitchService ziniaoShopSwitchService; private final OssStorageService ossStorageService; private final ObjectMapper objectMapper; @@ -221,9 +222,7 @@ public class DeleteBrandRunService { deleteBrandTaskCacheService.saveTaskCache(task); } - if (!parsedPayloadByFileIdentity.isEmpty()) { - deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadByFileIdentity); - } + deleteBrandTaskStorageService.saveParsedPayload(task.getId(), parsedPayloadByFileIdentity); DeleteBrandRunVo vo = new DeleteBrandRunVo(); vo.setTotal(request.getFiles().size()); @@ -277,7 +276,7 @@ public class DeleteBrandRunService { item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); item.setError(entity.getErrorMessage()); - // 以 task.resultJson 中的项为准补全 matched/shopId/platform/openStoreUrl/错误信息 + // 以 task.resultJson 中的项为准补充 matched/shopId/platform/openStoreUrl/错误信息 // 真实补充 if (entity.getTaskId() != null) { item.setTaskStatus(statusByTaskId.get(entity.getTaskId())); @@ -291,7 +290,7 @@ public class DeleteBrandRunService { item.setMatched(candidate.isMatched()); item.setMatchStatus(candidate.getMatchStatus()); item.setMatchMessage(candidate.getMatchMessage()); - // 如果物理结果已经是成功了,说明已经处理过了,强制置为匹配成功 + // 如果物理结果已经成功,说明已经处理过,强制标记为匹配成功 if (item.isSuccess() && candidate.getShopId() != null && !candidate.getShopId().isBlank()) { item.setMatched(true); item.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_MATCHED); @@ -303,8 +302,8 @@ public class DeleteBrandRunService { item.setPlatform(candidate.getPlatform()); item.setOpenStoreUrl(candidate.getOpenStoreUrl()); - // 历史表可能只有通用 errorMessage;优先返回任务当时的真实 error - // 但是,如果物理结果是成功的,就不应该显示所谓的“未匹配”报错 + // 历史表里可能只有通用 errorMessage,优先返回任务当时的真实 error + // 但如果物理结果已成功,就不应该再显示所谓“未匹配”的报错 if (item.isSuccess()) { item.setError(null); } else if ((item.getError() == null || item.getError().isBlank()) && candidate.getError() != null && !candidate.getError().isBlank()) { @@ -344,7 +343,16 @@ public class DeleteBrandRunService { if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { throw new BusinessException("记录不存在"); } + Long taskId = entity.getTaskId(); fileResultMapper.deleteById(resultId); + if (taskId != null && taskId > 0) { + Long remaining = fileResultMapper.selectCount(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + if (remaining == null || remaining <= 0) { + deleteBrandTaskStorageService.deleteTaskData(taskId); + } + } } private ParsedDeleteBrandFile parseDeleteBrandFile(File inputFile) { @@ -468,8 +476,8 @@ public class DeleteBrandRunService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace("", "") + .replace(" ", " ") .replace("\r\n", " ") .replace("\r", " ") .replace("\n", " ") @@ -640,7 +648,7 @@ public class DeleteBrandRunService { taskVo.setCreatedAt(task.getCreatedAt()); taskVo.setUpdatedAt(task.getUpdatedAt()); taskVo.setFinishedAt(task.getFinishedAt()); - // 轮询期避免每次都查 biz_file_result(会放大 DB 压力);仅任务成功后再提供下载信息 + // 轮询期间避免每次都查 biz_file_result,放大 DB 压力;仅任务成功后再提供下载信息 if ("SUCCESS".equals(task.getStatus())) { taskVo.setDownloadUrl(resolveTaskDownloadUrl(task.getId())); taskVo.setDownloadFilename(resolveTaskDownloadFilename(task.getId())); @@ -996,9 +1004,7 @@ public class DeleteBrandRunService { throw new BusinessException("任务已结束,拒绝继续提交结果"); } - Map parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId, - new TypeReference>() { - }); + Map parsedPayload = deleteBrandTaskStorageService.loadParsedPayload(taskId); if (parsedPayload == null || parsedPayload.isEmpty()) { throw new BusinessException("任务原始数据已过期,请重新执行解析"); } @@ -1029,10 +1035,10 @@ public class DeleteBrandRunService { throw new BusinessException("文件标识不匹配: " + fileIdentity); } - // 更早的 duplicate fast-path:尽量在较重的校验/进度写入之前直接短路 + // 更早执行 duplicate fast-path,尽量在较重校验和进度写入前直接短路 validateChunk(fileDto, parsedFile); validateResultFileAgainstParsed(fileDto, parsedFile); - boolean stored = deleteBrandTaskCacheService.storeResultChunkIfChanged(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto); + boolean stored = deleteBrandTaskStorageService.storeResultChunkIfChanged(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto); if (!stored) { log.info("[DeleteBrand] Duplicate chunk ignored for taskId: {}, file: {}, chunkIndex: {}", taskId, fileIdentity, fileDto.getChunkIndex()); continue; @@ -1051,12 +1057,12 @@ public class DeleteBrandRunService { progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis())); deleteBrandTaskCacheService.saveProgress(taskId, progress, true); - // 如果该文件已完成,立即更新成品计数的 Redis 缓存「提示」,让后续 tryFinalizeTask 能更精确 + // 如果该文件已完成,立即更新成品计数的 Redis 提示,让后续 tryFinalizeTask 更准确 if (isFileCompleted(fileDto)) { log.info("[DeleteBrand] File chunk transmission fully reached via chunkTotal for taskId: {}, file: {}", taskId, fileDto.getSourceFilename()); shouldTryFinalize = true; - // 这里我们暂不直接 increment,而是标记需要重算或在 tryFinalizeTask 中重新计算 - // 为了保险,我们直接在 tryFinalizeTask 里重新遍历分片状态 + // 这里暂不直接 increment,而是标记需要在 tryFinalizeTask 中重新计算 + // 为了保险,直接在 tryFinalizeTask 里重新遍历分片状态 } } @@ -1079,26 +1085,24 @@ public class DeleteBrandRunService { return; } - Map parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId, - new TypeReference>() { - }); + Map parsedPayload = deleteBrandTaskStorageService.loadParsedPayload(taskId); if (parsedPayload == null || parsedPayload.isEmpty()) { return; } int expectedFiles = parsedPayload.size(); - // 移除 finishedFilesHint 强行返回逻辑,因为 Redis 里的 finished_files 可能是旧的, - // 既然进入了 tryFinalizeTask,就说明有新片到达,应该以 loadMergedChunks 为准 + // 移除 finishedFilesHint 的强依赖,因为 Redis 里的 finished_files 可能是旧值 + // 既然进入 tryFinalizeTask,就说明有新分片到达,应该以 loadMergedChunks 为准 - Map> mergedByFile = loadMergedChunks(taskId); + Map> mergedByFile = deleteBrandTaskStorageService.loadMergedChunks(taskId); int finishedFiles = countCompletedFiles(mergedByFile); if (finishedFiles < expectedFiles) { Map progress = new LinkedHashMap<>(); progress.put("finished_files", String.valueOf(finishedFiles)); - // 定时补偿只负责“再试一次 finalize”,不能把缺片任务重新续命, - // 否则 stale-check 永远看不到超时任务。 + // 定时补偿只负责“再试一次 finalize”,不能把缺片任务重新续命 + // 否则 stale-check 会一直看不到超时任务 if (!fromCompensation) { progress.put("updated_at", String.valueOf(System.currentTimeMillis())); } @@ -1133,24 +1137,6 @@ public class DeleteBrandRunService { finalizeTask(task, parsedPayload, mergedByFile); } - private Map> loadMergedChunks(Long taskId) { - Map> groupedJson = deleteBrandTaskCacheService.groupResultChunkJsonByFile(taskId); - Map> merged = new LinkedHashMap<>(); - for (Map.Entry> entry : groupedJson.entrySet()) { - List chunks = new ArrayList<>(); - for (String raw : entry.getValue()) { - try { - chunks.add(objectMapper.readValue(raw, DeleteBrandResultFileDto.class)); - } catch (Exception ex) { - throw new BusinessException("读取删除品牌结果分片失败"); - } - } - chunks.sort(Comparator.comparing(DeleteBrandResultFileDto::getChunkIndex, Comparator.nullsLast(Integer::compareTo))); - merged.put(entry.getKey(), chunks); - } - return merged; - } - private int countCompletedFiles(Map> mergedByFile) { int finishedFiles = 0; for (List chunks : mergedByFile.values()) { @@ -1187,6 +1173,7 @@ public class DeleteBrandRunService { private void finalizeTask(FileTaskEntity task, Map parsedPayload, Map> mergedByFile) { + File outputDir = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "delete-brand-result", String.valueOf(task.getId()))); try { deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( "phase", DeleteBrandTaskCacheService.PHASE_ASSEMBLING, @@ -1259,6 +1246,8 @@ public class DeleteBrandRunService { item.setPlatform(parsedFile.getPlatform()); item.setOpenStoreUrl(parsedFile.getOpenStoreUrl()); item.setMatched(parsedFile.getShopId() != null && !parsedFile.getShopId().isBlank()); + item.setSuccess(true); + item.setError(null); item.setMatchStatus(item.isMatched() ? ZiniaoShopIndexService.MATCH_STATUS_MATCHED : ZiniaoShopIndexService.MATCH_STATUS_PENDING); item.setMatchMessage(item.isMatched() ? null : "店铺索引信息缺失,请重新匹配索引"); finalItems.add(item); @@ -1267,12 +1256,14 @@ public class DeleteBrandRunService { task.setStatus("SUCCESS"); task.setSuccessFileCount(successCount); + task.setFailedFileCount(Math.max(0, parsedPayload.size() - successCount)); task.setErrorMessage(null); task.setResultJson(toCompactTaskResultJsonForDb(finalItems)); task.setUpdatedAt(LocalDateTime.now()); task.setFinishedAt(LocalDateTime.now()); fileTaskMapper.updateById(task); deleteBrandTaskCacheService.saveTaskCache(task); + deleteBrandTaskStorageService.deleteTaskData(task.getId()); deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( "phase", "success", @@ -1297,6 +1288,8 @@ public class DeleteBrandRunService { throw businessException; } throw new BusinessException("删除品牌结果组装失败"); + } finally { + deleteQuietly(outputDir); } } @@ -1355,7 +1348,9 @@ public class DeleteBrandRunService { File outputDir = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "delete-brand-result", String.valueOf(taskId))); File outputFile = buildNamedOutputFile(outputDir, blankToDefault(parsedFile.getSourceFilename(), "delete-brand-result.xlsx")); - try (Workbook workbook = new XSSFWorkbook(); FileOutputStream outputStream = new FileOutputStream(outputFile)) { + try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); + FileOutputStream outputStream = new FileOutputStream(outputFile)) { + workbook.setCompressTempFiles(true); Sheet sheet = workbook.createSheet("删除品牌结果"); Row headerRow = sheet.createRow(0); headerRow.createCell(0).setCellValue("国家"); @@ -1371,9 +1366,7 @@ public class DeleteBrandRunService { createTextCell(row, 2, item.getStatus()); } } - for (int i = 0; i < 3; i++) { - sheet.autoSizeColumn(i); - } + applyDeleteBrandColumnWidths(sheet, 3); workbook.write(outputStream); return outputFile; } catch (Exception ex) { @@ -1387,7 +1380,9 @@ public class DeleteBrandRunService { File outputDir = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "delete-brand-result", String.valueOf(taskId))); File outputFile = buildNamedOutputFile(outputDir, blankToDefault(parsedFile.getSourceFilename(), "delete-brand-result.xlsx")); - try (Workbook workbook = new XSSFWorkbook(); FileOutputStream outputStream = new FileOutputStream(outputFile)) { + try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); + FileOutputStream outputStream = new FileOutputStream(outputFile)) { + workbook.setCompressTempFiles(true); Sheet sheet = workbook.createSheet("删除品牌结果"); Row countryRow = sheet.createRow(0); Row headerRow = sheet.createRow(1); @@ -1417,9 +1412,7 @@ public class DeleteBrandRunService { } } - for (int i = 0; i < mergedFile.countries().size() * 2; i++) { - sheet.autoSizeColumn(i); - } + applyDeleteBrandColumnWidths(sheet, mergedFile.countries().size() * 2); workbook.write(outputStream); return outputFile; } catch (Exception ex) { @@ -1432,6 +1425,22 @@ public class DeleteBrandRunService { cell.setCellValue(value == null ? "" : value); } + private void applyDeleteBrandColumnWidths(Sheet sheet, int columnCount) { + for (int i = 0; i < columnCount; i++) { + sheet.setColumnWidth(i, 18 * 256); + } + } + + private void deleteQuietly(File file) { + if (file == null || !file.exists()) { + return; + } + try { + FileUtil.del(file); + } catch (Exception ignored) { + } + } + private File buildNamedOutputFile(File outputDir, String filename) { File candidate = FileUtil.file(outputDir, filename); if (!candidate.exists()) { @@ -1478,10 +1487,9 @@ public class DeleteBrandRunService { String normalized = blankToEmpty(value); return normalized.isBlank() ? defaultValue : normalized; } - /** * 落库用 result_json:去掉国家分组、预览行等大字段,保留匹配摘要与计数。 - * 索引常命中后单次任务 JSON 体积会陡增,易触发 max_allowed_packet / 更新失败,进而前端 unwrap 报「请求失败」。 + * 索引命中后单次任务 JSON 体积会明显膨胀,容易触发 max_allowed_packet 或更新失败。 */ private String toCompactTaskResultJsonForDb(List items) { if (items == null || items.isEmpty()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java index e13a50d..2552055 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java @@ -1,16 +1,11 @@ package com.nanri.aiimage.modules.deletebrand.service; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.TaskPressureProperties; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.time.Duration; import java.util.concurrent.ConcurrentHashMap; @@ -24,63 +19,16 @@ public class DeleteBrandTaskCacheService { public static final String PHASE_FAILED = "failed"; private static final long PAYLOAD_TTL_HOURS = 24; - private static final long LOCAL_PARSED_PAYLOAD_CACHE_MILLIS = 3000; private static final long LOCAL_PROGRESS_CACHE_MILLIS = 1500; private static final long MIN_PROGRESS_REDIS_FLUSH_MILLIS = 1200; private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; - private final ConcurrentHashMap parsedPayloadLocalCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap progressLocalCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap progressRedisFlushAt = new ConcurrentHashMap<>(); private final ConcurrentHashMap taskEntityLocalCache = new ConcurrentHashMap<>(); - public void saveParsedPayload(Long taskId, Object payload) { - try { - Files.createDirectories(buildTaskDir(taskId)); - Files.writeString( - buildPayloadFile(taskId), - objectMapper.writeValueAsString(payload), - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE - ); - parsedPayloadLocalCache.put(taskId, new LocalParsedPayloadCacheEntry(System.currentTimeMillis(), payload)); - } catch (Exception ex) { - throw new BusinessException("鏆傚瓨鍒犻櫎鍝佺墝鍘熷鏁版嵁澶辫触"); - } - } - - public T getParsedPayload(Long taskId, TypeReference typeReference) { - if (taskId == null || taskId <= 0) { - return null; - } - - long now = System.currentTimeMillis(); - LocalParsedPayloadCacheEntry cached = parsedPayloadLocalCache.get(taskId); - if (cached != null && cached.isFresh(now) && cached.value() != null) { - try { - return objectMapper.convertValue(cached.value(), typeReference); - } catch (Exception ignored) { - // fall through to file - } - } - - Path payloadFile = buildPayloadFile(taskId); - if (!Files.isRegularFile(payloadFile)) { - parsedPayloadLocalCache.remove(taskId); - return null; - } - try { - T parsed = objectMapper.readValue(Files.readString(payloadFile), typeReference); - parsedPayloadLocalCache.put(taskId, new LocalParsedPayloadCacheEntry(now, parsed)); - return parsed; - } catch (Exception ex) { - throw new BusinessException("璇诲彇鍒犻櫎鍝佺墝鍘熷鏁版嵁澶辫触"); - } - } - public void saveProgress(Long taskId, java.util.Map values) { saveProgress(taskId, values, false); } @@ -150,13 +98,15 @@ public class DeleteBrandTaskCacheService { return result; } - java.util.List pipelineResults = stringRedisTemplate.executePipelined((org.springframework.data.redis.core.RedisCallback) connection -> { - org.springframework.data.redis.serializer.RedisSerializer serializer = stringRedisTemplate.getStringSerializer(); - for (Long taskId : missingTaskIds) { - connection.hGetAll(serializer.serialize(buildProgressKey(taskId))); - } - return null; - }); + java.util.List pipelineResults = stringRedisTemplate.executePipelined( + (org.springframework.data.redis.core.RedisCallback) connection -> { + org.springframework.data.redis.serializer.RedisSerializer serializer = + stringRedisTemplate.getStringSerializer(); + for (Long taskId : missingTaskIds) { + connection.hGetAll(serializer.serialize(buildProgressKey(taskId))); + } + return null; + }); for (int i = 0; i < missingTaskIds.size(); i++) { Long taskId = missingTaskIds.get(i); @@ -175,83 +125,12 @@ public class DeleteBrandTaskCacheService { return result; } - public boolean hasSameResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) { - if (chunkIndex == null || chunkIndex <= 0) { - throw new BusinessException("chunkIndex 涓嶅悎娉?"); - } - if (fileIdentity == null || fileIdentity.isBlank()) { - throw new BusinessException("fileIdentity 涓嶈兘涓虹┖"); - } - String field = fileIdentity.trim() + "#" + chunkIndex; - String chunkJson; - try { - chunkJson = objectMapper.writeValueAsString(chunk); - } catch (Exception ex) { - throw new BusinessException("璇诲彇鍒犻櫎鍝佺墝缁撴灉鍒嗙墖澶辫触"); - } - Object existing = stringRedisTemplate.opsForHash().get(buildResultChunksKey(taskId), field); - if (chunkJson.equals(existing)) { - stringRedisTemplate.expire(buildResultChunksKey(taskId), Duration.ofHours(PAYLOAD_TTL_HOURS)); - return true; - } - return false; - } - - public boolean mergeResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) { - String resultKey = buildResultChunksKey(taskId); - if (chunkIndex == null || chunkIndex <= 0) { - throw new BusinessException("chunkIndex 涓嶅悎娉?"); - } - if (fileIdentity == null || fileIdentity.isBlank()) { - throw new BusinessException("fileIdentity 涓嶈兘涓虹┖"); - } - String field = fileIdentity.trim() + "#" + chunkIndex; - String chunkJson; - try { - chunkJson = objectMapper.writeValueAsString(chunk); - } catch (Exception ex) { - throw new BusinessException("鏆傚瓨鍒犻櫎鍝佺墝缁撴灉鍒嗙墖澶辫触"); - } - Object existing = stringRedisTemplate.opsForHash().get(resultKey, field); - if (chunkJson.equals(existing)) { - stringRedisTemplate.expire(resultKey, Duration.ofHours(PAYLOAD_TTL_HOURS)); - return false; - } - stringRedisTemplate.opsForHash().put(resultKey, field, chunkJson); - stringRedisTemplate.expire(resultKey, Duration.ofHours(PAYLOAD_TTL_HOURS)); - return true; - } - - public boolean storeResultChunkIfChanged(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) { - return mergeResultChunk(taskId, fileIdentity, chunkIndex, chunk); - } - - public java.util.Map> groupResultChunkJsonByFile(Long taskId) { - java.util.Map stored = stringRedisTemplate.opsForHash().entries(buildResultChunksKey(taskId)); - java.util.Map> grouped = new java.util.LinkedHashMap<>(); - for (java.util.Map.Entry entry : stored.entrySet()) { - if (!(entry.getKey() instanceof String field) || !(entry.getValue() instanceof String raw) || raw.isBlank()) { - continue; - } - int sep = field.lastIndexOf('#'); - if (sep <= 0) { - continue; - } - String fileIdentity = field.substring(0, sep); - grouped.computeIfAbsent(fileIdentity, ignored -> new java.util.ArrayList<>()).add(raw); - } - return grouped; - } - public void delete(Long taskId) { - parsedPayloadLocalCache.remove(taskId); progressLocalCache.remove(taskId); progressRedisFlushAt.remove(taskId); taskEntityLocalCache.remove(taskId); stringRedisTemplate.delete(buildProgressKey(taskId)); - stringRedisTemplate.delete(buildResultChunksKey(taskId)); stringRedisTemplate.delete(buildTaskEntityKey(taskId)); - deleteTaskDirQuietly(taskId); } public void saveTaskCache(com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task) { @@ -264,13 +143,18 @@ public class DeleteBrandTaskCacheService { objectMapper.convertValue(task, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class) )); try { - stringRedisTemplate.opsForValue().set(buildTaskEntityKey(task.getId()), objectMapper.writeValueAsString(task), Duration.ofHours(PAYLOAD_TTL_HOURS)); + stringRedisTemplate.opsForValue().set( + buildTaskEntityKey(task.getId()), + objectMapper.writeValueAsString(task), + Duration.ofHours(PAYLOAD_TTL_HOURS)); } catch (Exception ignored) { } } - public java.util.Map getTaskCacheBatch(java.util.List taskIds) { - java.util.Map result = new java.util.LinkedHashMap<>(); + public java.util.Map getTaskCacheBatch( + java.util.List taskIds) { + java.util.Map result = + new java.util.LinkedHashMap<>(); if (taskIds == null || taskIds.isEmpty()) { return result; } @@ -285,7 +169,8 @@ public class DeleteBrandTaskCacheService { for (Long taskId : normalized) { LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId); if (isLocalTaskEntityCacheFresh(cached, now)) { - result.put(taskId, objectMapper.convertValue(cached.task(), com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class)); + result.put(taskId, objectMapper.convertValue( + cached.task(), com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class)); } else { missingIds.add(taskId); } @@ -313,40 +198,10 @@ public class DeleteBrandTaskCacheService { return result; } - private Path buildTaskDir(Long taskId) { - return Path.of(System.getProperty("java.io.tmpdir"), "delete-brand-task-cache", String.valueOf(taskId)); - } - - private Path buildPayloadFile(Long taskId) { - return buildTaskDir(taskId).resolve("parsed-payload.json"); - } - - private void deleteTaskDirQuietly(Long taskId) { - Path taskDir = buildTaskDir(taskId); - if (!Files.exists(taskDir)) { - return; - } - try (var walk = Files.walk(taskDir)) { - walk.sorted(java.util.Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (Exception ignored) { - } - }); - } catch (Exception ignored) { - } - } - private String buildTaskEntityKey(Long taskId) { return "delete-brand:task:entity:" + taskId; } - private record LocalParsedPayloadCacheEntry(long cachedAtMillis, Object value) { - private boolean isFresh(long now) { - return now - cachedAtMillis <= LOCAL_PARSED_PAYLOAD_CACHE_MILLIS; - } - } - private record LocalProgressCacheEntry(long cachedAtMillis, java.util.Map values) { private boolean isFresh(long now) { return now - cachedAtMillis <= LOCAL_PROGRESS_CACHE_MILLIS; @@ -377,8 +232,4 @@ public class DeleteBrandTaskCacheService { private String buildProgressKey(Long taskId) { return "delete-brand:task:progress:" + taskId; } - - private String buildResultChunksKey(Long taskId) { - return "delete-brand:task:result-chunks:" + taskId; - } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java new file mode 100644 index 0000000..3cc4857 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java @@ -0,0 +1,404 @@ +package com.nanri.aiimage.modules.deletebrand.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.deletebrand.model.cache.DeleteBrandParsedFileCacheDto; +import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandResultFileDto; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DeleteBrandTaskStorageService { + + private static final String MODULE_TYPE = "DELETE_BRAND"; + private static final String OSS_POINTER_PREFIX = "oss:"; + + private final TaskChunkMapper taskChunkMapper; + private final TaskScopeStateMapper taskScopeStateMapper; + private final ObjectMapper objectMapper; + private final OssStorageService ossStorageService; + + @Transactional + public void saveParsedPayload(Long taskId, Map parsedPayloadByScope) { + if (taskId == null || taskId <= 0 || parsedPayloadByScope == null || parsedPayloadByScope.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(); + Map existingByHash = loadScopeStateByHash(taskId); + for (Map.Entry entry : parsedPayloadByScope.entrySet()) { + String scopeKey = normalizeScopeKey(entry.getKey()); + if (scopeKey.isBlank() || entry.getValue() == null) { + continue; + } + String scopeHash = hash(scopeKey); + TaskScopeStateEntity existing = existingByHash.get(scopeHash); + String payloadJson = writeJson(entry.getValue(), "failed to save delete-brand parsed payload"); + String parsedPayloadPointer = storeParsedPayload(taskId, scopeHash, payloadJson); + if (existing == null) { + TaskScopeStateEntity entity = new TaskScopeStateEntity(); + entity.setTaskId(taskId); + entity.setModuleType(MODULE_TYPE); + entity.setScopeKey(scopeKey); + entity.setScopeHash(scopeHash); + entity.setParsedPayloadJson(parsedPayloadPointer); + entity.setChunkTotal(0); + entity.setReceivedChunkCount(0); + entity.setCompleted(0); + entity.setLastChunkAt(null); + entity.setLastError(null); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskScopeStateMapper.insert(entity); + existingByHash.put(scopeHash, entity); + continue; + } catch (DuplicateKeyException ex) { + log.info("[delete-brand-storage] parsed payload scope inserted concurrently taskId={} scopeKey={}", taskId, scopeKey); + existing = getScopeState(taskId, scopeHash); + } + } + if (existing == null) { + throw new BusinessException("failed to save delete-brand parsed payload"); + } + deleteParsedPayloadIfNeeded(existing.getParsedPayloadJson(), parsedPayloadPointer); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, existing.getId()) + .set(TaskScopeStateEntity::getScopeKey, scopeKey) + .set(TaskScopeStateEntity::getParsedPayloadJson, parsedPayloadPointer) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + } + } + + public Map loadParsedPayload(Long taskId) { + if (taskId == null || taskId <= 0) { + return Map.of(); + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getScopeKey, TaskScopeStateEntity::getParsedPayloadJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .isNotNull(TaskScopeStateEntity::getParsedPayloadJson)); + if (states == null || states.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + for (TaskScopeStateEntity state : states) { + if (isBlank(state.getScopeKey())) { + continue; + } + String payloadJson = resolveParsedPayload(state.getParsedPayloadJson()); + if (isBlank(payloadJson)) { + continue; + } + try { + result.put(state.getScopeKey(), objectMapper.readValue(payloadJson, DeleteBrandParsedFileCacheDto.class)); + } catch (Exception ex) { + throw new BusinessException("failed to load delete-brand parsed payload"); + } + } + return result; + } + + @Transactional + public boolean storeResultChunkIfChanged(Long taskId, String scopeKey, Integer chunkIndex, DeleteBrandResultFileDto chunk) { + if (taskId == null || taskId <= 0) { + throw new BusinessException("taskId is invalid"); + } + if (chunkIndex == null || chunkIndex <= 0) { + throw new BusinessException("chunkIndex is invalid"); + } + String normalizedScopeKey = normalizeScopeKey(scopeKey); + if (normalizedScopeKey.isBlank()) { + throw new BusinessException("scopeKey must not be blank"); + } + String scopeHash = hash(normalizedScopeKey); + String payloadJson = writeJson(chunk, "failed to save delete-brand result chunk"); + String payloadHash = hash(payloadJson); + LocalDateTime now = LocalDateTime.now(); + + TaskChunkEntity existingChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .eq(TaskChunkEntity::getChunkIndex, chunkIndex) + .last("limit 1")); + boolean changed = true; + if (existingChunk == null) { + TaskChunkEntity entity = new TaskChunkEntity(); + entity.setTaskId(taskId); + entity.setModuleType(MODULE_TYPE); + entity.setScopeKey(normalizedScopeKey); + entity.setScopeHash(scopeHash); + entity.setChunkIndex(chunkIndex); + entity.setChunkTotal(chunk.getChunkTotal() == null ? 0 : chunk.getChunkTotal()); + entity.setPayloadJson(payloadJson); + entity.setPayloadHash(payloadHash); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskChunkMapper.insert(entity); + } catch (DuplicateKeyException ex) { + TaskChunkEntity concurrentChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .eq(TaskChunkEntity::getChunkIndex, chunkIndex) + .last("limit 1")); + if (concurrentChunk != null) { + changed = !payloadHash.equals(concurrentChunk.getPayloadHash()); + concurrentChunk.setScopeKey(normalizedScopeKey); + concurrentChunk.setChunkTotal(chunk.getChunkTotal() == null ? 0 : chunk.getChunkTotal()); + concurrentChunk.setPayloadJson(payloadJson); + concurrentChunk.setPayloadHash(payloadHash); + concurrentChunk.setUpdatedAt(now); + taskChunkMapper.updateById(concurrentChunk); + } else { + throw ex; + } + } + } else { + changed = !payloadHash.equals(existingChunk.getPayloadHash()); + existingChunk.setScopeKey(normalizedScopeKey); + existingChunk.setChunkTotal(chunk.getChunkTotal() == null ? 0 : chunk.getChunkTotal()); + existingChunk.setPayloadJson(payloadJson); + existingChunk.setPayloadHash(payloadHash); + existingChunk.setUpdatedAt(now); + taskChunkMapper.updateById(existingChunk); + } + + refreshScopeState(taskId, normalizedScopeKey, scopeHash, chunk.getChunkTotal(), now); + return changed; + } + + public Map> loadMergedChunks(Long taskId) { + if (taskId == null || taskId <= 0) { + return Map.of(); + } + List entities = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskChunkEntity::getScopeHash) + .orderByAsc(TaskChunkEntity::getChunkIndex) + .orderByAsc(TaskChunkEntity::getId)); + if (entities == null || entities.isEmpty()) { + return Map.of(); + } + Map> grouped = new LinkedHashMap<>(); + for (TaskChunkEntity entity : entities) { + if (entity.getPayloadJson() == null || entity.getPayloadJson().isBlank()) { + continue; + } + try { + DeleteBrandResultFileDto dto = objectMapper.readValue(entity.getPayloadJson(), DeleteBrandResultFileDto.class); + grouped.computeIfAbsent(entity.getScopeKey(), ignored -> new ArrayList<>()).add(dto); + } catch (Exception ex) { + throw new BusinessException("failed to load delete-brand result chunks"); + } + } + for (List chunks : grouped.values()) { + chunks.sort(Comparator.comparing(DeleteBrandResultFileDto::getChunkIndex, Comparator.nullsLast(Integer::compareTo))); + } + return grouped; + } + + @Transactional + public void deleteTaskData(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getParsedPayloadJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + if (states != null) { + for (TaskScopeStateEntity state : states) { + deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), null); + } + } + taskChunkMapper.delete(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + taskScopeStateMapper.delete(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + } + + private void refreshScopeState(Long taskId, String scopeKey, String scopeHash, Integer chunkTotal, LocalDateTime now) { + Long receivedCountRaw = taskChunkMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash)); + int receivedCount = receivedCountRaw == null ? 0 : receivedCountRaw.intValue(); + int normalizedChunkTotal = chunkTotal == null || chunkTotal < 0 ? 0 : chunkTotal; + + TaskScopeStateEntity state = getScopeState(taskId, scopeHash); + if (state == null) { + state = new TaskScopeStateEntity(); + state.setTaskId(taskId); + state.setModuleType(MODULE_TYPE); + state.setScopeKey(scopeKey); + state.setScopeHash(scopeHash); + state.setParsedPayloadJson(null); + state.setChunkTotal(normalizedChunkTotal); + state.setReceivedChunkCount(receivedCount); + state.setCompleted(normalizedChunkTotal > 0 && receivedCount >= normalizedChunkTotal ? 1 : 0); + state.setLastChunkAt(now); + state.setLastError(null); + state.setCreatedAt(now); + state.setUpdatedAt(now); + try { + taskScopeStateMapper.insert(state); + } catch (DuplicateKeyException ex) { + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getScopeHash, scopeHash) + .set(TaskScopeStateEntity::getScopeKey, scopeKey) + .set(TaskScopeStateEntity::getChunkTotal, normalizedChunkTotal) + .set(TaskScopeStateEntity::getReceivedChunkCount, receivedCount) + .set(TaskScopeStateEntity::getCompleted, normalizedChunkTotal > 0 && receivedCount >= normalizedChunkTotal ? 1 : 0) + .set(TaskScopeStateEntity::getLastChunkAt, now) + .set(TaskScopeStateEntity::getLastError, null) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + } + return; + } + + int mergedChunkTotal = Math.max(state.getChunkTotal() == null ? 0 : state.getChunkTotal(), normalizedChunkTotal); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getScopeKey, scopeKey) + .set(TaskScopeStateEntity::getChunkTotal, mergedChunkTotal) + .set(TaskScopeStateEntity::getReceivedChunkCount, receivedCount) + .set(TaskScopeStateEntity::getCompleted, mergedChunkTotal > 0 && receivedCount >= mergedChunkTotal ? 1 : 0) + .set(TaskScopeStateEntity::getLastChunkAt, now) + .set(TaskScopeStateEntity::getLastError, null) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + } + + private TaskScopeStateEntity getScopeState(Long taskId, String scopeHash) { + return taskScopeStateMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getScopeHash, scopeHash) + .last("limit 1")); + } + + private Map loadScopeStateByHash(Long taskId) { + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + Map result = new LinkedHashMap<>(); + if (states == null) { + return result; + } + for (TaskScopeStateEntity state : states) { + if (!isBlank(state.getScopeHash())) { + result.put(state.getScopeHash(), state); + } + } + return result; + } + + private String storeParsedPayload(Long taskId, String scopeHash, String payloadJson) { + try { + String pointer = OSS_POINTER_PREFIX + ossStorageService.uploadTaskParsedPayload(MODULE_TYPE, taskId, scopeHash, payloadJson); + return objectMapper.writeValueAsString(pointer); + } catch (Exception ex) { + throw new BusinessException("failed to save delete-brand parsed payload"); + } + } + + private String resolveParsedPayload(String value) { + if (isBlank(value)) { + return value; + } + String ossPointer = extractOssPointer(value); + if (ossPointer == null) { + return value; + } + try { + return ossStorageService.readObjectAsString(ossPointer.substring(OSS_POINTER_PREFIX.length())); + } catch (Exception ex) { + throw new BusinessException("failed to load delete-brand parsed payload"); + } + } + + private void deleteParsedPayloadIfNeeded(String oldValue, String newValue) { + String oldPointer = extractOssPointer(oldValue); + String newPointer = extractOssPointer(newValue); + if (oldPointer == null || oldPointer.equals(newPointer)) { + return; + } + try { + ossStorageService.deleteObject(oldPointer.substring(OSS_POINTER_PREFIX.length())); + } catch (Exception ignored) { + } + } + + private String extractOssPointer(String value) { + if (isBlank(value)) { + return null; + } + if (value.startsWith(OSS_POINTER_PREFIX)) { + return value; + } + try { + String decoded = objectMapper.readValue(value, String.class); + return decoded != null && decoded.startsWith(OSS_POINTER_PREFIX) ? decoded : null; + } catch (Exception ignored) { + return null; + } + } + + private String writeJson(Object value, String message) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(message); + } + } + + private String normalizeScopeKey(String scopeKey) { + return scopeKey == null ? "" : scopeKey.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(normalizeScopeKey(value).getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("failed to hash delete-brand scope", ex); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java index 3b767bf..5fc49f3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java @@ -2,14 +2,18 @@ package com.nanri.aiimage.modules.file.service.oss; import com.aliyun.oss.OSS; import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.OSSObject; import com.nanri.aiimage.config.OssProperties; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.io.ByteArrayInputStream; import java.io.File; import java.net.URI; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.Date; +import java.util.Objects; import java.util.UUID; @Service @@ -33,6 +37,68 @@ public class OssStorageService { } } + public String uploadText(String objectKey, String content) { + if (objectKey == null || objectKey.isBlank()) { + throw new IllegalArgumentException("objectKey must not be blank"); + } + OSS ossClient = buildClient(); + try (ByteArrayInputStream stream = new ByteArrayInputStream( + Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8))) { + ossClient.putObject(ossProperties.getBucket(), objectKey, stream); + return objectKey; + } catch (Exception ex) { + throw new IllegalStateException("failed to upload text to oss", ex); + } finally { + ossClient.shutdown(); + } + } + + public String uploadTaskScopePayload(String moduleType, Long taskId, String scopeHash, String content) { + String normalizedModuleType = moduleType == null || moduleType.isBlank() + ? "unknown" + : moduleType.trim().toLowerCase(); + String normalizedScopeHash = scopeHash == null || scopeHash.isBlank() ? UUID.randomUUID().toString() : scopeHash; + String objectKey = String.format("task-scope/%s/%s/%s.json", normalizedModuleType, taskId, normalizedScopeHash); + return uploadText(objectKey, content); + } + + public String uploadTaskParsedPayload(String moduleType, Long taskId, String scopeHash, String content) { + String normalizedModuleType = moduleType == null || moduleType.isBlank() + ? "unknown" + : moduleType.trim().toLowerCase(); + String normalizedScopeHash = scopeHash == null || scopeHash.isBlank() ? UUID.randomUUID().toString() : scopeHash; + String objectKey = String.format("task-parsed/%s/%s/%s.json", normalizedModuleType, taskId, normalizedScopeHash); + return uploadText(objectKey, content); + } + + public String readObjectAsString(String value) { + String objectKey = resolveObjectKey(value); + if (objectKey == null || objectKey.isBlank()) { + return null; + } + OSS ossClient = buildClient(); + try (OSSObject ossObject = ossClient.getObject(ossProperties.getBucket(), objectKey)) { + return new String(ossObject.getObjectContent().readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException("failed to read object from oss", ex); + } finally { + ossClient.shutdown(); + } + } + + public void deleteObject(String value) { + String objectKey = resolveObjectKey(value); + if (objectKey == null || objectKey.isBlank()) { + return; + } + OSS ossClient = buildClient(); + try { + ossClient.deleteObject(ossProperties.getBucket(), objectKey); + } finally { + ossClient.shutdown(); + } + } + /** * 根据 objectKey 生成预签名下载 URL(1小时有效)。 */ diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteExcelAssemblyService.java index 90313e6..6a399a4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteExcelAssemblyService.java @@ -8,7 +8,7 @@ import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteResultItemVo; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import java.io.File; @@ -40,8 +40,9 @@ public class PatrolDeleteExcelAssemblyService { }; public void writeWorkbook(File outputXlsx, List items) { - try (XSSFWorkbook workbook = new XSSFWorkbook(); - FileOutputStream outputStream = new FileOutputStream(outputXlsx)) { + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) { Sheet sheet = workbook.createSheet("巡店删除结果"); Row headerRow = sheet.createRow(0); for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) { @@ -73,13 +74,17 @@ public class PatrolDeleteExcelAssemblyService { rowIndex++; } - for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) { - sheet.autoSizeColumn(columnIndex); - } + applyDefaultColumnWidths(sheet); workbook.write(outputStream); } catch (Exception ex) { log.warn("[patrol-delete] write workbook failed: {}", ex.getMessage()); - throw new BusinessException("生成巡店删除结果 Excel 失败: " + ex.getMessage()); + throw new BusinessException("generate patrol-delete excel failed: " + ex.getMessage()); + } finally { + try { + workbook.close(); + } catch (Exception ignored) { + } + workbook.dispose(); } } @@ -141,4 +146,23 @@ public class PatrolDeleteExcelAssemblyService { private String safe(String value) { return value == null ? "" : value; } + + private void applyDefaultColumnWidths(Sheet sheet) { + int[] widths = { + 20, + 14, 10, 12, 16, + 14, 10, 12, 16, + 14, 10, 12, 16, + 14, 10, 12, 16, + 14, 10, 12, 16, + 10, 16, + 10, 16, + 10, 16, + 10, 16, + 10, 16 + }; + for (int i = 0; i < widths.length; i++) { + sheet.setColumnWidth(i, widths[i] * 256); + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java index c48d0ee..19212f1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java @@ -1,10 +1,10 @@ package com.nanri.aiimage.modules.patroldelete.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteShopPayloadDto; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -20,80 +20,40 @@ import java.util.concurrent.ConcurrentHashMap; @RequiredArgsConstructor public class PatrolDeleteTaskCacheService { + private static final String MODULE_TYPE = "PATROL_DELETE"; private static final long PAYLOAD_TTL_HOURS = 24; private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; + private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final ConcurrentHashMap taskEntityLocalCache = new ConcurrentHashMap<>(); public PatrolDeleteShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return null; - } - Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey); - if (!(raw instanceof String json) || json.isBlank()) { - return null; - } - try { - return objectMapper.readValue(json, PatrolDeleteShopPayloadDto.class); - } catch (Exception ex) { - throw new BusinessException("读取巡店删除店铺缓存失败"); - } + return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, PatrolDeleteShopPayloadDto.class); } public void saveShopMergedPayload(Long taskId, String shopKey, PatrolDeleteShopPayloadDto payload) { if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) { return; } - try { - stringRedisTemplate.opsForHash().put(buildShopPayloadKey(taskId), shopKey, objectMapper.writeValueAsString(payload)); - stringRedisTemplate.expire(buildShopPayloadKey(taskId), Duration.ofHours(PAYLOAD_TTL_HOURS)); - touchTaskHeartbeat(taskId); - } catch (Exception ex) { - throw new BusinessException("暂存巡店删除店铺缓存失败"); - } + taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload); + touchTaskHeartbeat(taskId); } public void removeShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return; - } - stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey); + taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey); } public Map getAllShopMergedPayload(Long taskId) { - try { - Map raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId)); - if (raw == null || raw.isEmpty()) { - return Map.of(); - } - Map out = new LinkedHashMap<>(); - for (Map.Entry entry : raw.entrySet()) { - if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) { - continue; - } - out.put(key, objectMapper.readValue(val, PatrolDeleteShopPayloadDto.class)); - } - return out; - } catch (Exception ex) { - throw new BusinessException("读取巡店删除缓存失败"); - } + return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, PatrolDeleteShopPayloadDto.class); } public boolean hasAnyShopMergedPayload(Long taskId) { - if (taskId == null || taskId <= 0) { - return false; - } - Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId)); - return size != null && size > 0; + return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE); } public long countShopMergedPayload(Long taskId) { - if (taskId == null || taskId <= 0) { - return 0L; - } - Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId)); - return size == null ? 0L : size; + return taskScopePayloadStorageService.countScopePayload(taskId, MODULE_TYPE); } public long getTaskHeartbeatMillis(Long taskId) { @@ -123,9 +83,9 @@ public class PatrolDeleteTaskCacheService { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildShopPayloadKey(taskId)); stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); } public void saveTaskCache(FileTaskEntity task) { @@ -190,10 +150,6 @@ public class PatrolDeleteTaskCacheService { return result; } - private String buildShopPayloadKey(Long taskId) { - return "patrol-delete:task:shop-payload:" + taskId; - } - private String buildTaskHeartbeatKey(Long taskId) { return "patrol-delete:task:heartbeat:" + taskId; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java index d850686..c9f8762 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java @@ -93,5 +93,14 @@ public class PriceTrackSubmitResultRequest { @Schema(description = "状态", example = "SUCCESS") private String status; + + @Schema(description = "Python 确认是否需要从 skip ASIN 表删除当前 ASIN", example = "true") + private Boolean deleteSkipAsin; + + @Schema(description = "需要删除的 skip ASIN;不传时默认使用 asin 字段", example = "B0ABCDE123") + private String removeAsin; + + @Schema(description = "删除确认原因,便于排查日志", example = "CURRENT_PRICE_BELOW_MINIMUM") + private String deleteReason; } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java index d9887ae..50f998c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java @@ -18,6 +18,9 @@ public class PriceTrackCreateTaskVo { @Schema(description = "all skip asins grouped by country") private Map> skipAsinsByCountry; + @Schema(description = "all skip asin details grouped by country code, each item includes asin and minimumPrice") + private Map>> skipAsinDetailsByCountry; + @Schema(description = "parsed asin rows by country") private Map>> asinRowsByCountry; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java index 426e06d..8475179 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java @@ -17,6 +17,9 @@ public class PriceTrackMatchShopsVo { @Schema(description = "All skip ASINs grouped by country code") private Map> skipAsinsByCountry; + @Schema(description = "All skip ASIN details grouped by country code, each item includes asin and minimumPrice") + private Map>> skipAsinDetailsByCountry; + @Schema(description = "Parsed asin rows grouped by country code") private Map>> asinRowsByCountry; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java index f1adb09..2741df9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java @@ -6,7 +6,7 @@ import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import java.io.File; @@ -20,25 +20,27 @@ import java.util.Map; public class PriceTrackExcelAssemblyService { private static final String[] RESULT_HEADER = { - "\u5e97\u94fa\u5546\u57ce\u540d\u79f0", + "店铺商城名称", "ASIN", - "\u4ef7\u683c", - "\u63a8\u8350\u4ef7", - "\u6700\u4f4e\u4ef7", - "\u7b2c\u4e00\u540d", - "\u7b2c\u4e8c\u540d", - "\u8d2d\u7269\u8f66\u5e97\u94fa\u540d", - "\u6539\u4ef7\u60c5\u51b5", - "\u4fee\u6539\u6b21\u6570", - "\u72b6\u6001" + "价格", + "推荐价", + "最低价", + "第一名", + "第二名", + "购物车店铺名", + "改价情况", + "修改次数", + "状态" }; public void writeWorkbook(File outputXlsx, Map> countries) { Map> safe = countries == null ? Map.of() : countries; - try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputXlsx)) { + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try (FileOutputStream fos = new FileOutputStream(outputXlsx)) { for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) { String sheetName = code.name(); - Sheet sheet = wb.createSheet(sheetName); + Sheet sheet = workbook.createSheet(sheetName); Row headerRow = sheet.createRow(0); for (int i = 0; i < RESULT_HEADER.length; i++) { headerRow.createCell(i).setCellValue(RESULT_HEADER[i]); @@ -62,14 +64,18 @@ public class PriceTrackExcelAssemblyService { row.createCell(9).setCellValue(valueOf(item.getModifyCount())); row.createCell(10).setCellValue(valueOf(item.getStatus())); } - for (int i = 0; i < RESULT_HEADER.length; i++) { - sheet.autoSizeColumn(i); - } + applyDefaultColumnWidths(sheet); } - wb.write(fos); + workbook.write(fos); } catch (Exception ex) { log.warn("[price-track] write workbook failed: {}", ex.getMessage()); - throw new BusinessException("鐢熸垚 Excel 澶辫触: " + ex.getMessage()); + throw new BusinessException("generate price-track excel failed: " + ex.getMessage()); + } finally { + try { + workbook.close(); + } catch (Exception ignored) { + } + workbook.dispose(); } } @@ -105,4 +111,10 @@ public class PriceTrackExcelAssemblyService { return value == null ? "" : value; } + private void applyDefaultColumnWidths(Sheet sheet) { + int[] widths = {22, 18, 12, 12, 12, 20, 20, 22, 18, 12, 12}; + for (int i = 0; i < widths.length; i++) { + sheet.setColumnWidth(i, widths[i] * 256); + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackService.java index 7caade6..d9210b6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackService.java @@ -148,6 +148,9 @@ public class PriceTrackService { Map> skipAsinsByCountry = asinMode ? new LinkedHashMap<>() : skipPriceAsinService.listAllSkipAsinsByCountry(); + Map>> skipAsinDetailsByCountry = asinMode + ? new LinkedHashMap<>() + : skipPriceAsinService.listAllSkipAsinDetailsByCountry(); Map>> asinRowsByCountry = !asinMode ? new LinkedHashMap<>() @@ -157,6 +160,7 @@ public class PriceTrackService { PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo(); vo.setSkipAsinsByCountry(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountry(skipAsinDetailsByCountry); vo.setAsinRowsByCountry(asinRowsByCountry); vo.setMinimumPriceByCountryAndAsin(priceTrackTaskService.buildMinimumPriceLookupForMatch(asinRowsByCountry)); for (String shopName : ordered) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java index 432c18e..6570567 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java @@ -1,10 +1,10 @@ package com.nanri.aiimage.modules.pricetrack.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -20,10 +20,12 @@ import java.util.concurrent.ConcurrentHashMap; @RequiredArgsConstructor public class PriceTrackTaskCacheService { + private static final String MODULE_TYPE = "PRICE_TRACK"; private static final long HEARTBEAT_TTL_HOURS = 24; private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; + private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final ConcurrentHashMap taskEntityLocalCache = new ConcurrentHashMap<>(); public void touchTaskHeartbeat(Long taskId) { @@ -52,65 +54,27 @@ public class PriceTrackTaskCacheService { } public PriceTrackSubmitResultRequest.ShopResult getShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return null; - } - Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey); - if (!(raw instanceof String json) || json.isBlank()) { - return null; - } - try { - return objectMapper.readValue(json, PriceTrackSubmitResultRequest.ShopResult.class); - } catch (Exception ex) { - throw new BusinessException("read price-track cache failed"); - } + return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, PriceTrackSubmitResultRequest.ShopResult.class); } public void saveShopMergedPayload(Long taskId, String shopKey, PriceTrackSubmitResultRequest.ShopResult payload) { if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) { return; } - try { - stringRedisTemplate.opsForHash().put(buildShopPayloadKey(taskId), shopKey, objectMapper.writeValueAsString(payload)); - stringRedisTemplate.expire(buildShopPayloadKey(taskId), Duration.ofHours(HEARTBEAT_TTL_HOURS)); - touchTaskHeartbeat(taskId); - } catch (Exception ex) { - throw new BusinessException("save price-track cache failed"); - } + taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload); + touchTaskHeartbeat(taskId); } public void removeShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return; - } - stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey); + taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey); } public Map getAllShopMergedPayload(Long taskId) { - try { - Map raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId)); - if (raw == null || raw.isEmpty()) { - return Map.of(); - } - LinkedHashMap out = new LinkedHashMap<>(); - for (Map.Entry entry : raw.entrySet()) { - if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) { - continue; - } - out.put(key, objectMapper.readValue(val, PriceTrackSubmitResultRequest.ShopResult.class)); - } - return out; - } catch (Exception ex) { - throw new BusinessException("read price-track cache failed"); - } + return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, PriceTrackSubmitResultRequest.ShopResult.class); } public boolean hasAnyShopMergedPayload(Long taskId) { - if (taskId == null || taskId <= 0) { - return false; - } - Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId)); - return size != null && size > 0; + return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE); } public void deleteTaskCache(Long taskId) { @@ -118,9 +82,9 @@ public class PriceTrackTaskCacheService { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildShopPayloadKey(taskId)); stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); } public void saveTaskCache(FileTaskEntity task) { @@ -185,10 +149,6 @@ public class PriceTrackTaskCacheService { return result; } - private String buildShopPayloadKey(Long taskId) { - return "price-track:task:shop-payload:" + taskId; - } - private String buildTaskHeartbeatKey(Long taskId) { return "price-track:task:heartbeat:" + taskId; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index 75c07d0..43fc570 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -206,7 +206,7 @@ public class PriceTrackTaskService { @Transactional public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) { if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); - if (shopName == null || shopName.isBlank()) throw new BusinessException("shop_name 涓嶈兘涓虹┖"); + if (shopName == null || shopName.isBlank()) throw new BusinessException("shop_name 不能为空"); String norm = ziniaoShopSwitchService.normalizeShopName(shopName); if (norm.isBlank()) throw new BusinessException("店铺名称无效"); @@ -293,6 +293,9 @@ public class PriceTrackTaskService { Map> skipAsinsByCountry = request.isAsinMode() ? new LinkedHashMap<>() : skipPriceAsinService.listAllSkipAsinsByCountry(); + Map>> skipAsinDetailsByCountry = request.isAsinMode() + ? new LinkedHashMap<>() + : skipPriceAsinService.listAllSkipAsinDetailsByCountry(); Map>> asinRowsByCountry = request.isAsinMode() ? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes()) : new LinkedHashMap<>(); @@ -348,6 +351,7 @@ public class PriceTrackTaskService { ctx.put("asinMode", request.isAsinMode()); ctx.put("countryCodes", request.getCountryCodes()); ctx.put("skipAsinsByCountry", skipAsinsByCountry); + ctx.put("skipAsinDetailsByCountry", skipAsinDetailsByCountry); ctx.put("asinRowsByCountry", asinRowsByCountry); ctx.put("minimumPriceByCountryAndAsin", minimumPriceByCountryAndAsin); ctx.put("items", uniqueItems); @@ -368,6 +372,7 @@ public class PriceTrackTaskService { vo.setTaskId(task.getId()); vo.setItems(snapshot); vo.setSkipAsinsByCountry(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountry(skipAsinDetailsByCountry); vo.setAsinRowsByCountry(asinRowsByCountry); vo.setMinimumPriceByCountryAndAsin(minimumPriceByCountryAndAsin); return vo; @@ -419,6 +424,7 @@ public class PriceTrackTaskService { priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey); continue; } + handleSkipAsinDeletionSignals(shopKey, payload); PriceTrackSubmitResultRequest.ShopResult merged = mergeShopPayload(taskId, shopKey, payload); if (!Boolean.TRUE.equals(merged.getSuccess())) { continue; @@ -833,8 +839,8 @@ public class PriceTrackTaskService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace("", "") + .replace(" ", " ") .replace("\r", " ") .replace("\n", " ") .trim() @@ -1075,6 +1081,11 @@ public class PriceTrackTaskService { merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus())); merged.setModifyCount(firstNonBlank(incoming.getModifyCount(), merged.getModifyCount())); merged.setStatus(firstNonBlank(incoming.getStatus(), merged.getStatus())); + if (incoming.getDeleteSkipAsin() != null) { + merged.setDeleteSkipAsin(incoming.getDeleteSkipAsin()); + } + merged.setRemoveAsin(firstNonBlank(incoming.getRemoveAsin(), merged.getRemoveAsin())); + merged.setDeleteReason(firstNonBlank(incoming.getDeleteReason(), merged.getDeleteReason())); return merged; } private PriceTrackSubmitResultRequest.AsinResult cloneRow(PriceTrackSubmitResultRequest.AsinResult row) { @@ -1093,8 +1104,36 @@ public class PriceTrackTaskService { out.setPriceChangeStatus(row.getPriceChangeStatus()); out.setModifyCount(row.getModifyCount()); out.setStatus(row.getStatus()); + out.setDeleteSkipAsin(row.getDeleteSkipAsin()); + out.setRemoveAsin(row.getRemoveAsin()); + out.setDeleteReason(row.getDeleteReason()); return out; } + private void handleSkipAsinDeletionSignals(String shopKey, + PriceTrackSubmitResultRequest.ShopResult payload) { + if (payload == null || payload.getCountries() == null || payload.getCountries().isEmpty()) { + return; + } + for (Map.Entry> entry : payload.getCountries().entrySet()) { + String countryCode = entry.getKey(); + List rows = entry.getValue(); + if (countryCode == null || countryCode.isBlank() || rows == null || rows.isEmpty()) { + continue; + } + for (PriceTrackSubmitResultRequest.AsinResult row : rows) { + if (row == null || !Boolean.TRUE.equals(row.getDeleteSkipAsin())) { + continue; + } + String targetAsin = firstNonBlank(row.getRemoveAsin(), row.getAsin()); + if (targetAsin == null || targetAsin.isBlank()) { + continue; + } + boolean removed = skipPriceAsinService.removeByShopCountryAndAsin(shopKey, countryCode, targetAsin); + log.info("[price-track] skip asin delete signal taskShop={} country={} asin={} removed={} reason={}", + shopKey, countryCode, targetAsin, removed, row.getDeleteReason()); + } + } + } private int countPayloadRows(PriceTrackSubmitResultRequest.ShopResult payload) { if (payload == null) { return 0; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java index 301ee7c..f678dc5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java @@ -7,7 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import java.io.File; @@ -35,9 +35,11 @@ public class ProductRiskExcelAssemblyService { public void writeWorkbook(File outputXlsx, String shopDisplayName, Map> countries) { Map> safe = countries == null ? Map.of() : countries; - try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputXlsx)) { + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try (FileOutputStream fos = new FileOutputStream(outputXlsx)) { for (String sheetName : SHEET_ORDER) { - Sheet sheet = wb.createSheet(sheetName); + Sheet sheet = workbook.createSheet(sheetName); Row headerRow = sheet.createRow(0); for (int c = 0; c < HEADER.length; c++) { headerRow.createCell(c).setCellValue(HEADER[c]); @@ -53,16 +55,20 @@ public class ProductRiskExcelAssemblyService { createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin()); createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus()); } - for (int i = 0; i < HEADER.length; i++) { - sheet.autoSizeColumn(i); - } + applyDefaultColumnWidths(sheet); } - wb.write(fos); + workbook.write(fos); } catch (BusinessException ex) { throw ex; } catch (Exception ex) { log.warn("[product-risk] write workbook failed: {}", ex.getMessage()); - throw new BusinessException("生成 Excel 失败: " + ex.getMessage()); + throw new BusinessException("generate product-risk excel failed: " + ex.getMessage()); + } finally { + try { + workbook.close(); + } catch (Exception ignored) { + } + workbook.dispose(); } } @@ -79,9 +85,6 @@ public class ProductRiskExcelAssemblyService { return n; } - /** - * 将接口中的 {@link ProductRiskCountryCode} 键转为 Excel 中文工作表名 → 行列表。 - */ public Map> normalizeCountriesMap(Map> raw) { if (raw == null || raw.isEmpty()) { return new LinkedHashMap<>(); @@ -107,4 +110,11 @@ public class ProductRiskExcelAssemblyService { } return fallback == null ? "" : fallback; } + + private void applyDefaultColumnWidths(Sheet sheet) { + int[] widths = {20, 22, 18, 10, 18, 18}; + for (int i = 0; i < widths.length; i++) { + sheet.setColumnWidth(i, widths[i] * 256); + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskResolveService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskResolveService.java index 002ac48..76d5492 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskResolveService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskResolveService.java @@ -34,7 +34,7 @@ import java.util.Objects; public class ProductRiskResolveService { /** - * 默认处理顺序:德国 → 英国 → 法国 → 意大利 → 西班牙(与前端一致)。 + * 默认国家偏好顺序:德国 -> 英国 -> 法国 -> 意大利 -> 西班牙,与前端保持一致。 */ public static final List DEFAULT_COUNTRY_PREFERENCE_ORDER = List.of("DE", "UK", "FR", "IT", "ES"); @@ -78,7 +78,7 @@ public class ProductRiskResolveService { if (indexHit == null || !indexHit.isMatched()) { String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank() ? indexHit.getMatchMessage() - : "店铺索引未命中,无法加入备选"; + : "店铺索引未命中,无法加入待处理列表"; throw new BusinessException(hint); } if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) { @@ -135,7 +135,7 @@ public class ProductRiskResolveService { } } if (ordered.isEmpty()) { - throw new BusinessException("shop_names 无有效店铺名"); + throw new BusinessException("shop_names 没有有效店铺名"); } ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo(); for (String shopName : ordered) { @@ -227,7 +227,7 @@ public class ProductRiskResolveService { } /** - * 仅保留合法枚举名,去重并保持顺序;非法项静默丢弃(用于读取库内数据)。 + * 仅保留合法国家代码,去重并保持顺序;非法值静默忽略,用于读取库存量数据。 */ private static List parseValidCountryCodes(List raw) { if (raw == null || raw.isEmpty()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java index aac342b..db0e5c9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java @@ -1,10 +1,10 @@ package com.nanri.aiimage.modules.productrisk.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskShopPayloadDto; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -20,80 +20,40 @@ import java.util.concurrent.ConcurrentHashMap; @RequiredArgsConstructor public class ProductRiskTaskCacheService { + private static final String MODULE_TYPE = "PRODUCT_RISK_RESOLVE"; private static final long PAYLOAD_TTL_HOURS = 24; private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; + private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final ConcurrentHashMap taskEntityLocalCache = new ConcurrentHashMap<>(); public ProductRiskShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return null; - } - Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey); - if (!(raw instanceof String json) || json.isBlank()) { - return null; - } - try { - return objectMapper.readValue(json, ProductRiskShopPayloadDto.class); - } catch (Exception ex) { - throw new BusinessException("读取商品风险店铺缓存失败"); - } + return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ProductRiskShopPayloadDto.class); } public void saveShopMergedPayload(Long taskId, String shopKey, ProductRiskShopPayloadDto payload) { if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) { return; } - try { - stringRedisTemplate.opsForHash().put(buildShopPayloadKey(taskId), shopKey, objectMapper.writeValueAsString(payload)); - stringRedisTemplate.expire(buildShopPayloadKey(taskId), Duration.ofHours(PAYLOAD_TTL_HOURS)); - touchTaskHeartbeat(taskId); - } catch (Exception ex) { - throw new BusinessException("暂存商品风险店铺缓存失败"); - } + taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload); + touchTaskHeartbeat(taskId); } public void removeShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return; - } - stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey); + taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey); } public Map getAllShopMergedPayload(Long taskId) { - try { - Map raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId)); - if (raw == null || raw.isEmpty()) { - return Map.of(); - } - java.util.LinkedHashMap out = new java.util.LinkedHashMap<>(); - for (Map.Entry entry : raw.entrySet()) { - if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) { - continue; - } - out.put(key, objectMapper.readValue(val, ProductRiskShopPayloadDto.class)); - } - return out; - } catch (Exception ex) { - throw new BusinessException("读取商品风险缓存失败"); - } + return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, ProductRiskShopPayloadDto.class); } public boolean hasAnyShopMergedPayload(Long taskId) { - if (taskId == null || taskId <= 0) { - return false; - } - Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId)); - return size != null && size > 0; + return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE); } public long countShopMergedPayload(Long taskId) { - if (taskId == null || taskId <= 0) { - return 0L; - } - Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId)); - return size == null ? 0L : size; + return taskScopePayloadStorageService.countScopePayload(taskId, MODULE_TYPE); } public long getTaskHeartbeatMillis(Long taskId) { @@ -116,9 +76,9 @@ public class ProductRiskTaskCacheService { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildShopPayloadKey(taskId)); stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); } public void saveTaskCache(FileTaskEntity task) { @@ -183,10 +143,6 @@ public class ProductRiskTaskCacheService { return result; } - private String buildShopPayloadKey(Long taskId) { - return "product-risk:task:shop-payload:" + taskId; - } - public void touchTaskHeartbeat(Long taskId) { stringRedisTemplate.opsForValue().set( buildTaskHeartbeatKey(taskId), diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java index a7cf6d5..bb00826 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java @@ -189,7 +189,7 @@ public class ProductRiskTaskService { } /** - * 删除整条商品风险任务及其下所有店铺结果(运行中或已结束均可,需归属当前用户)。 + * 删除整条商品风险任务及其下所有店铺结果,运行中和已结束任务都允许删除,但必须归属当前用户。 */ @Transactional public void deleteTask(Long taskId, Long userId) { @@ -208,8 +208,8 @@ public class ProductRiskTaskService { } /** - * 从用户当前「运行中」的商品风险任务里,按规范化店铺名删除一条结果(用于前端移除匹配列表后同步后端)。 - * 若无对应记录则 removed=false。 + * 从用户当前“运行中”的商品风险任务里,按规范化店铺名删除一条结果,用于前端移除匹配列表后同步后端。 + * 如果没有对应记录,则返回 removed=false。 */ @Transactional public ProductRiskPendingDeleteVo deletePendingShopResult(Long userId, String shopName) { @@ -252,7 +252,7 @@ public class ProductRiskTaskService { } /** - * 删除一条 file_result 后重算父任务状态;若无剩余结果则删除任务。 + * 删除一条 file_result 后重算父任务状态;如果没有剩余结果则删除任务。 */ private void reconcileTaskAfterResultRemoval(Long taskId) { FileTaskEntity task = loadTaskForExecution(taskId); @@ -303,11 +303,11 @@ public class ProductRiskTaskService { task.setFinishedAt(LocalDateTime.now()); } else if (ok > 0) { task.setStatus("SUCCESS"); - task.setErrorMessage(String.join(";", allErrors)); + task.setErrorMessage(String.join("; ", allErrors)); task.setFinishedAt(LocalDateTime.now()); } else { task.setStatus("FAILED"); - task.setErrorMessage(allErrors.isEmpty() ? "全部店铺处理失败" : String.join(";", allErrors)); + task.setErrorMessage(allErrors.isEmpty() ? "全部店铺处理失败" : String.join("; ", allErrors)); task.setFinishedAt(LocalDateTime.now()); } try { @@ -507,7 +507,7 @@ public class ProductRiskTaskService { String shopKey = fr.getSourceFilename(); ProductRiskShopPayloadDto payload = shopKey == null ? null : payloadByShop.get(shopKey); - // 兼容单店任务:若规范化店名偶发不一致(空格/符号差异)导致未命中,且任务与回传都只有 1 个店时按唯一店回填。 + // 兼容单店任务:如果规范化店名偶发不一致导致未命中,且任务与回传都只有 1 个店时按唯一店铺回填。 if (payload == null && resultRows.size() == 1 && payloadByShop.size() == 1) { payload = payloadByShop.values().iterator().next(); fallbackMatchedCount++; @@ -517,7 +517,7 @@ public class ProductRiskTaskService { if (payload == null) { skippedUnmatchedCount++; - // 与删除品牌一致:支持队列逐条处理、分次回传,未在本次 payload 中的店铺保持待处理 + // 与删除品牌一致:支持队列逐条处理、分次回传,本次 payload 未包含的店铺保持待处理。 continue; } matchedShopCount++; @@ -602,11 +602,11 @@ public class ProductRiskTaskService { task.setFinishedAt(LocalDateTime.now()); } else if (ok > 0) { task.setStatus("SUCCESS"); - task.setErrorMessage(String.join(";", allErrors.isEmpty() ? batchErrors : allErrors)); + task.setErrorMessage(String.join("; ", allErrors.isEmpty() ? batchErrors : allErrors)); task.setFinishedAt(LocalDateTime.now()); } else { task.setStatus("FAILED"); - task.setErrorMessage(allErrors.isEmpty() ? "全部店铺处理失败" : String.join(";", allErrors)); + task.setErrorMessage(allErrors.isEmpty() ? "全部店铺处理失败" : String.join("; ", allErrors)); task.setFinishedAt(LocalDateTime.now()); } try { @@ -1034,7 +1034,7 @@ public class ProductRiskTaskService { continue; } - // 兼容 Python 末次“空行 done=true”心跳:视为当前国家已有行全部完成。 + // 兼容 Python 末次“空行 + done=true”心跳:视为当前国家已有行全部完成。 if (isDoneValue(row.getDone()) && isEmptyBusinessRow(row) && !byKey.isEmpty()) { for (Map.Entry entry : byKey.entrySet()) { ProductRiskRowDto existed = entry.getValue(); @@ -1103,8 +1103,8 @@ public class ProductRiskTaskService { if (row == null) { return true; } - // 注意:shopName 只是上下文信息,不作为业务行是否为空的判定条件。 - // Python 末次心跳行通常只带 shopName + done=true,其他业务字段为空。 + // 注意,shopName 只是上下文信息,不作为业务行是否为空的判定条件。 + // Python 末次心跳行通常只带 shopName + done=true,其它业务字段为空。 return isBlank(row.getProductAsinSku()) && isBlank(row.getRemoveAsin()) && isBlank(row.getStatus()) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java index 74c951b..8b9e088 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java @@ -71,6 +71,7 @@ public class SkipPriceAsinController { id, country, request.getAsin(), + request.getMinimumPrice(), operatorId, Boolean.TRUE.equals(superAdmin))); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java index be37835..049cd53 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java @@ -4,10 +4,15 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Data; +import java.math.BigDecimal; + @Data public class SkipPriceAsinCountryUpdateRequest { @Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "ASIN 不能为空") private String asin; + + @Schema(description = "最低价", example = "18.50") + private BigDecimal minimumPrice; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCreateRequest.java index 22d666d..c9e72b6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCreateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCreateRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; +import java.math.BigDecimal; import java.util.List; import java.util.Map; @@ -29,4 +30,8 @@ public class SkipPriceAsinCreateRequest { * 新请求: 每个国家对应独立 ASIN,例如 {"DE":"B0...", "UK":"B0..."}。 */ private Map asinMappings; + + private BigDecimal minimumPrice; + + private Map minimumPriceMappings; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java index 63cc68d..874cb71 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDateTime; @Data @@ -24,18 +25,33 @@ public class SkipPriceAsinEntity { @TableField("asin_de") private String asinDe; + @TableField("minimum_price_de") + private BigDecimal minimumPriceDe; + @TableField("asin_uk") private String asinUk; + @TableField("minimum_price_uk") + private BigDecimal minimumPriceUk; + @TableField("asin_fr") private String asinFr; + @TableField("minimum_price_fr") + private BigDecimal minimumPriceFr; + @TableField("asin_it") private String asinIt; + @TableField("minimum_price_it") + private BigDecimal minimumPriceIt; + @TableField("asin_es") private String asinEs; + @TableField("minimum_price_es") + private BigDecimal minimumPriceEs; + @TableField("created_at") private LocalDateTime createdAt; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/SkipPriceAsinItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/SkipPriceAsinItemVo.java index 963db9a..bc07309 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/SkipPriceAsinItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/SkipPriceAsinItemVo.java @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.model.vo; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDateTime; @Data @@ -12,10 +13,15 @@ public class SkipPriceAsinItemVo { private String groupName; private String shopName; private String asinDe; + private BigDecimal minimumPriceDe; private String asinUk; + private BigDecimal minimumPriceUk; private String asinFr; + private BigDecimal minimumPriceFr; private String asinIt; + private BigDecimal minimumPriceIt; private String asinEs; + private BigDecimal minimumPriceEs; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index 0407766..db38556 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -12,6 +12,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -79,6 +82,7 @@ public class SkipPriceAsinService { String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空"); Set countries = normalizeCountries(request.getCountries()); Map countryAsinMap = normalizeCountryAsinMap(countries, request); + Map countryMinimumPriceMap = normalizeCountryMinimumPriceMap(countries, request); SkipPriceAsinEntity entity = skipPriceAsinMapper.selectOne(new LambdaQueryWrapper() .eq(SkipPriceAsinEntity::getGroupId, group.getId()) @@ -91,7 +95,8 @@ public class SkipPriceAsinService { } for (Map.Entry entry : countryAsinMap.entrySet()) { - setCountryAsin(entity, entry.getKey(), entry.getValue()); + String country = entry.getKey(); + setCountryData(entity, country, entry.getValue(), countryMinimumPriceMap.get(country)); } if (entity.getId() == null) { @@ -108,7 +113,7 @@ public class SkipPriceAsinService { SkipPriceAsinEntity entity = getById(id); validateGroupAccess(entity, operatorId, superAdmin); String normalizedCountry = normalizeCountry(country); - setCountryAsin(entity, normalizedCountry, ""); + setCountryData(entity, normalizedCountry, "", null); if (isEmptyRow(entity)) { skipPriceAsinMapper.deleteById(entity.getId()); return; @@ -117,12 +122,13 @@ public class SkipPriceAsinService { } @Transactional - public SkipPriceAsinItemVo updateCountry(Long id, String country, String asin, Long operatorId, boolean superAdmin) { + public SkipPriceAsinItemVo updateCountry(Long id, String country, String asin, BigDecimal minimumPrice, + Long operatorId, boolean superAdmin) { SkipPriceAsinEntity entity = getById(id); validateGroupAccess(entity, operatorId, superAdmin); String normalizedCountry = normalizeCountry(country); String normalizedAsin = normalizeAsin(asin); - setCountryAsin(entity, normalizedCountry, normalizedAsin); + setCountryData(entity, normalizedCountry, normalizedAsin, normalizeMinimumPrice(minimumPrice)); skipPriceAsinMapper.updateById(entity); SkipPriceAsinEntity saved = getById(entity.getId()); String groupName = ""; @@ -154,10 +160,15 @@ public class SkipPriceAsinService { vo.setGroupName(groupName == null ? "" : groupName); vo.setShopName(entity.getShopName()); vo.setAsinDe(blankToEmpty(entity.getAsinDe())); + vo.setMinimumPriceDe(entity.getMinimumPriceDe()); vo.setAsinUk(blankToEmpty(entity.getAsinUk())); + vo.setMinimumPriceUk(entity.getMinimumPriceUk()); vo.setAsinFr(blankToEmpty(entity.getAsinFr())); + vo.setMinimumPriceFr(entity.getMinimumPriceFr()); vo.setAsinIt(blankToEmpty(entity.getAsinIt())); + vo.setMinimumPriceIt(entity.getMinimumPriceIt()); vo.setAsinEs(blankToEmpty(entity.getAsinEs())); + vo.setMinimumPriceEs(entity.getMinimumPriceEs()); vo.setCreatedAt(entity.getCreatedAt()); vo.setUpdatedAt(entity.getUpdatedAt()); return vo; @@ -213,6 +224,27 @@ public class SkipPriceAsinService { return normalized; } + private Map normalizeCountryMinimumPriceMap(Set countries, SkipPriceAsinCreateRequest request) { + LinkedHashMap normalized = new LinkedHashMap<>(); + Map requestMappings = request.getMinimumPriceMappings(); + if (requestMappings != null && !requestMappings.isEmpty()) { + for (String country : countries) { + BigDecimal minimumPrice = requestMappings.get(country); + if (minimumPrice == null) { + minimumPrice = requestMappings.get(country.toLowerCase(Locale.ROOT)); + } + normalized.put(country, normalizeMinimumPrice(minimumPrice)); + } + return normalized; + } + + BigDecimal minimumPrice = normalizeMinimumPrice(request.getMinimumPrice()); + for (String country : countries) { + normalized.put(country, minimumPrice); + } + return normalized; + } + private String normalizeCountry(String country) { String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT); if (!SUPPORTED_COUNTRIES.contains(normalized)) { @@ -229,6 +261,16 @@ public class SkipPriceAsinService { return normalized; } + private BigDecimal normalizeMinimumPrice(BigDecimal minimumPrice) { + if (minimumPrice == null) { + return null; + } + if (minimumPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new BusinessException("最低价不能小于 0"); + } + return minimumPrice.setScale(2, RoundingMode.HALF_UP); + } + private String normalizeRequired(String value, String message) { String normalized = normalizeBlank(value); if (normalized.isEmpty()) { @@ -245,14 +287,29 @@ public class SkipPriceAsinService { return value == null ? "" : value; } - private void setCountryAsin(SkipPriceAsinEntity entity, String country, String asin) { + private void setCountryData(SkipPriceAsinEntity entity, String country, String asin, BigDecimal minimumPrice) { String value = normalizeBlank(asin); switch (country) { - case "DE" -> entity.setAsinDe(value); - case "UK" -> entity.setAsinUk(value); - case "FR" -> entity.setAsinFr(value); - case "IT" -> entity.setAsinIt(value); - case "ES" -> entity.setAsinEs(value); + case "DE" -> { + entity.setAsinDe(value); + entity.setMinimumPriceDe(minimumPrice); + } + case "UK" -> { + entity.setAsinUk(value); + entity.setMinimumPriceUk(minimumPrice); + } + case "FR" -> { + entity.setAsinFr(value); + entity.setMinimumPriceFr(minimumPrice); + } + case "IT" -> { + entity.setAsinIt(value); + entity.setMinimumPriceIt(minimumPrice); + } + case "ES" -> { + entity.setAsinEs(value); + entity.setMinimumPriceEs(minimumPrice); + } default -> throw new BusinessException("国家参数不支持: " + country); } } @@ -262,7 +319,12 @@ public class SkipPriceAsinService { && normalizeBlank(entity.getAsinUk()).isEmpty() && normalizeBlank(entity.getAsinFr()).isEmpty() && normalizeBlank(entity.getAsinIt()).isEmpty() - && normalizeBlank(entity.getAsinEs()).isEmpty(); + && normalizeBlank(entity.getAsinEs()).isEmpty() + && entity.getMinimumPriceDe() == null + && entity.getMinimumPriceUk() == null + && entity.getMinimumPriceFr() == null + && entity.getMinimumPriceIt() == null + && entity.getMinimumPriceEs() == null; } public Map> listSkipAsinsByShops(List shopNames) { @@ -312,10 +374,78 @@ public class SkipPriceAsinService { return result; } + public Map>> listAllSkipAsinDetailsByCountry() { + List rows = skipPriceAsinMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(SkipPriceAsinEntity::getId)); + Map>> grouped = new LinkedHashMap<>(); + grouped.put("DE", new java.util.ArrayList<>()); + grouped.put("UK", new java.util.ArrayList<>()); + grouped.put("FR", new java.util.ArrayList<>()); + grouped.put("IT", new java.util.ArrayList<>()); + grouped.put("ES", new java.util.ArrayList<>()); + + for (SkipPriceAsinEntity row : rows) { + addCountryAsinDetail(grouped.get("DE"), row.getAsinDe(), row.getMinimumPriceDe()); + addCountryAsinDetail(grouped.get("UK"), row.getAsinUk(), row.getMinimumPriceUk()); + addCountryAsinDetail(grouped.get("FR"), row.getAsinFr(), row.getMinimumPriceFr()); + addCountryAsinDetail(grouped.get("IT"), row.getAsinIt(), row.getMinimumPriceIt()); + addCountryAsinDetail(grouped.get("ES"), row.getAsinEs(), row.getMinimumPriceEs()); + } + return grouped; + } + + @Transactional + public boolean removeByShopCountryAndAsin(String shopName, String country, String asin) { + String normalizedShopName = normalizeBlank(shopName); + String normalizedCountry = normalizeCountry(country); + String normalizedAsin = normalizeAsin(asin); + if (normalizedShopName.isEmpty() || normalizedAsin.isEmpty()) { + return false; + } + List rows = skipPriceAsinMapper.selectList( + new LambdaQueryWrapper() + .eq(SkipPriceAsinEntity::getShopName, normalizedShopName)); + boolean changed = false; + for (SkipPriceAsinEntity row : rows) { + String existingAsin = switch (normalizedCountry) { + case "DE" -> normalizeBlank(row.getAsinDe()); + case "UK" -> normalizeBlank(row.getAsinUk()); + case "FR" -> normalizeBlank(row.getAsinFr()); + case "IT" -> normalizeBlank(row.getAsinIt()); + case "ES" -> normalizeBlank(row.getAsinEs()); + default -> ""; + }; + if (!normalizedAsin.equals(existingAsin)) { + continue; + } + setCountryData(row, normalizedCountry, "", null); + row.setUpdatedAt(LocalDateTime.now()); + if (isEmptyRow(row)) { + skipPriceAsinMapper.deleteById(row.getId()); + } else { + skipPriceAsinMapper.updateById(row); + } + changed = true; + } + return changed; + } + private void addCountryAsin(Set bucket, String asin) { String normalized = normalizeBlank(asin); if (!normalized.isEmpty()) { bucket.add(normalized); } } + + private void addCountryAsinDetail(List> bucket, String asin, BigDecimal minimumPrice) { + String normalizedAsin = normalizeBlank(asin); + if (normalizedAsin.isEmpty()) { + return; + } + Map detail = new LinkedHashMap<>(); + detail.put("asin", normalizedAsin); + detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice.toPlainString()); + bucket.add(detail); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java index e58159e..ecb3e2d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java @@ -6,7 +6,7 @@ import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchRowDto; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import java.io.File; @@ -23,10 +23,12 @@ public class ShopMatchExcelAssemblyService { public void writeWorkbook(File outputXlsx, Map> countries) { Map> safe = countries == null ? Map.of() : countries; - try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputXlsx)) { + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try (FileOutputStream fos = new FileOutputStream(outputXlsx)) { for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) { String sheetName = code.getSheetName(); - Sheet sheet = wb.createSheet(sheetName); + Sheet sheet = workbook.createSheet(sheetName); Row header = sheet.createRow(0); header.createCell(0).setCellValue(HEADER[0]); header.createCell(1).setCellValue(HEADER[1]); @@ -40,13 +42,19 @@ public class ShopMatchExcelAssemblyService { row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin()); row.createCell(1).setCellValue(item.getStatus() == null ? "" : item.getStatus()); } - sheet.autoSizeColumn(0); - sheet.autoSizeColumn(1); + sheet.setColumnWidth(0, 20 * 256); + sheet.setColumnWidth(1, 18 * 256); } - wb.write(fos); + workbook.write(fos); } catch (Exception ex) { log.warn("[shop-match] write workbook failed: {}", ex.getMessage()); - throw new BusinessException("生成 Excel 失败: " + ex.getMessage()); + throw new BusinessException("generate shop-match excel failed: " + ex.getMessage()); + } finally { + try { + workbook.close(); + } catch (Exception ignored) { + } + workbook.dispose(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java index 9925eae..4a10295 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java @@ -1,24 +1,20 @@ package com.nanri.aiimage.modules.shopmatch.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.common.exception.BusinessException; -import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; +import com.nanri.aiimage.config.TaskPressureProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.security.MessageDigest; import java.time.Duration; import java.util.ArrayList; -import java.util.Comparator; -import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -28,104 +24,34 @@ import java.util.concurrent.ConcurrentHashMap; @Slf4j public class ShopMatchTaskCacheService { + private static final String MODULE_TYPE = "SHOP_MATCH"; private static final long PAYLOAD_TTL_HOURS = 24; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; + private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final ConcurrentHashMap taskEntityLocalCache = new ConcurrentHashMap<>(); public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return null; - } - Path file = buildShopPayloadFile(taskId, shopKey); - if (!Files.isRegularFile(file)) { - return null; - } - try { - PayloadFile payloadFile = objectMapper.readValue(Files.readString(file), PayloadFile.class); - if (payloadFile == null || payloadFile.payload == null) { - return null; - } - if (payloadFile.shopKey == null || !shopKey.equals(payloadFile.shopKey)) { - log.warn("[shop-match] payload file shop key mismatch taskId={} shopKey={} storedKey={}", - taskId, shopKey, payloadFile.shopKey); - return null; - } - return payloadFile.payload; - } catch (Exception ex) { - throw new BusinessException("璇诲彇鍖归厤搴楅摵缂撳瓨澶辫触"); - } + return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ShopMatchShopPayloadDto.class); } public void saveShopMergedPayload(Long taskId, String shopKey, ShopMatchShopPayloadDto payload) { if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) { return; } - try { - Files.createDirectories(buildTaskDir(taskId)); - Files.writeString( - buildShopPayloadFile(taskId, shopKey), - objectMapper.writeValueAsString(new PayloadFile(shopKey, payload)), - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE - ); - } catch (Exception ex) { - throw new BusinessException("鏆傚瓨鍖归厤搴楅摵缁撴灉澶辫触"); - } + taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload); } public void removeShopMergedPayload(Long taskId, String shopKey) { - if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) { - return; - } - try { - Files.deleteIfExists(buildShopPayloadFile(taskId, shopKey)); - } catch (IOException ex) { - log.warn("[shop-match] delete shop payload failed taskId={} shopKey={} msg={}", taskId, shopKey, ex.getMessage()); - } + taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey); } public Map getAllShopMergedPayload(Long taskId) { - try { - Path taskDir = buildTaskDir(taskId); - if (!Files.isDirectory(taskDir)) { - return Map.of(); - } - LinkedHashMap out = new LinkedHashMap<>(); - try (var stream = Files.list(taskDir)) { - for (Path path : stream - .filter(file -> file.getFileName().toString().endsWith(".json")) - .sorted(Comparator.comparing(file -> file.getFileName().toString())) - .toList()) { - PayloadFile payloadFile = objectMapper.readValue(Files.readString(path), PayloadFile.class); - if (payloadFile == null || payloadFile.shopKey == null || payloadFile.shopKey.isBlank() || payloadFile.payload == null) { - continue; - } - out.put(payloadFile.shopKey, payloadFile.payload); - } - } - return out; - } catch (Exception ex) { - throw new BusinessException("璇诲彇鍖归厤搴楅摵缂撳瓨澶辫触"); - } + return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, ShopMatchShopPayloadDto.class); } public boolean hasAnyShopMergedPayload(Long taskId) { - if (taskId == null || taskId <= 0) { - return false; - } - Path taskDir = buildTaskDir(taskId); - if (!Files.isDirectory(taskDir)) { - return false; - } - try (var stream = Files.list(taskDir)) { - return stream.anyMatch(file -> Files.isRegularFile(file) - && file.getFileName().toString().endsWith(".json")); - } catch (IOException ex) { - log.warn("[shop-match] scan task cache failed taskId={} msg={}", taskId, ex.getMessage()); - return false; - } + return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE); } public void deleteTaskCache(Long taskId) { @@ -133,6 +59,7 @@ public class ShopMatchTaskCacheService { return; } taskEntityLocalCache.remove(taskId); + taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); try { Path taskDir = buildTaskDir(taskId); if (!Files.exists(taskDir)) { @@ -140,7 +67,7 @@ public class ShopMatchTaskCacheService { return; } try (var walk = Files.walk(taskDir)) { - walk.sorted(Comparator.reverseOrder()).forEach(path -> { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(path -> { try { Files.deleteIfExists(path); } catch (IOException ex) { @@ -225,37 +152,10 @@ public class ShopMatchTaskCacheService { return Path.of(System.getProperty("java.io.tmpdir"), "shop-match-cache", String.valueOf(taskId)); } - private Path buildShopPayloadFile(Long taskId, String shopKey) { - return buildTaskDir(taskId).resolve(hashShopKey(shopKey) + ".json"); - } - private Path buildTaskEntityFile(Long taskId) { return buildTaskDir(taskId).resolve("_task-entity.json"); } - private String hashShopKey(String shopKey) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] bytes = digest.digest(shopKey.trim().getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(bytes); - } catch (Exception ex) { - throw new IllegalStateException("failed to hash shop key", ex); - } - } - - private static class PayloadFile { - public String shopKey; - public ShopMatchShopPayloadDto payload; - - public PayloadFile() { - } - - public PayloadFile(String shopKey, ShopMatchShopPayloadDto payload) { - this.shopKey = shopKey; - this.payload = payload; - } - } - private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) { return cached != null && now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java index ad2477e..c47f882 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java @@ -44,8 +44,8 @@ public class SplitRunService { private static final String ZIP_CONTENT_TYPE = "application/zip"; private static final String SPLIT_MODE_ROWS_PER_FILE = "rows_per_file"; private static final String SPLIT_MODE_PARTS = "parts"; - private static final String HEADER_SUFFIX_MARKER = "idASIN\u56fd\u5bb6\u72b6\u6001\u4ef7\u683c\u53d8\u4f53\u6570\u91cf"; - private static final String HEADER_STOP_COLUMN = "\u7f29\u7565\u56fe\u5730\u57408"; + private static final String HEADER_SUFFIX_MARKER = "idASIN国家状态价格变体数量"; + private static final String HEADER_STOP_COLUMN = "缩略图地址8"; private static final DateTimeFormatter ZIP_NAME_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); private final FileTaskMapper fileTaskMapper; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskChunkMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskChunkMapper.java new file mode 100644 index 0000000..23c0895 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskChunkMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskChunkMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskScopeStateMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskScopeStateMapper.java new file mode 100644 index 0000000..c4c4961 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskScopeStateMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskScopeStateMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskChunkEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskChunkEntity.java new file mode 100644 index 0000000..dc9963e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskChunkEntity.java @@ -0,0 +1,26 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_task_chunk") +public class TaskChunkEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String moduleType; + private String scopeKey; + private String scopeHash; + private Integer chunkIndex; + private Integer chunkTotal; + private String payloadJson; + private String payloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java new file mode 100644 index 0000000..5d31c85 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java @@ -0,0 +1,29 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_task_scope_state") +public class TaskScopeStateEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String moduleType; + private String scopeKey; + private String scopeHash; + private String parsedPayloadJson; + private String stateJson; + private Integer chunkTotal; + private Integer receivedChunkCount; + private Integer completed; + private LocalDateTime lastChunkAt; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadBufferMaintenanceService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadBufferMaintenanceService.java new file mode 100644 index 0000000..cca7c59 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadBufferMaintenanceService.java @@ -0,0 +1,172 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.common.service.DistributedJobLockService; +import com.nanri.aiimage.config.TaskPressureProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TaskScopePayloadBufferMaintenanceService { + + private static final Duration RECOVERY_LOCK_TTL = Duration.ofMinutes(10); + private static final Duration CLEANUP_LOCK_TTL = Duration.ofMinutes(10); + + private final TaskScopePayloadStorageService taskScopePayloadStorageService; + private final TaskPressureProperties taskPressureProperties; + private final DistributedJobLockService distributedJobLockService; + + @EventListener(ApplicationReadyEvent.class) + public void recoverBufferedPayloadsOnStartup() { + DistributedJobLockService.LockHandle lockHandle = + distributedJobLockService.tryLock("task-scope-buffer:recover", RECOVERY_LOCK_TTL); + if (lockHandle == null) { + log.info("[task-scope-buffer] skip startup recovery because another instance holds the lock"); + return; + } + try (lockHandle) { + Path root = taskScopePayloadStorageService.getBufferedPayloadRoot(); + if (!Files.isDirectory(root)) { + return; + } + int limit = Math.max(0, taskPressureProperties.getScopePayloadRecoveryMaxFiles()); + if (limit == 0) { + return; + } + int recovered = 0; + int scanned = 0; + for (Path file : listBufferedPayloadFiles(root, limit)) { + scanned++; + try { + BufferFileRef ref = parseBufferFile(root, file); + if (ref == null) { + Files.deleteIfExists(file); + continue; + } + if (taskScopePayloadStorageService.recoverBufferedScopePayload(ref.taskId(), ref.moduleType(), ref.scopeHash())) { + recovered++; + } + } catch (Exception ex) { + log.warn("[task-scope-buffer] startup recovery failed path={} msg={}", file, ex.getMessage()); + } + } + if (scanned > 0) { + log.info("[task-scope-buffer] startup recovery finished scanned={} recovered={} limit={}", scanned, recovered, limit); + } + } + } + + @Scheduled(cron = "${aiimage.task-pressure.scope-payload-cleanup-cron:15 */30 * * * *}") + public void cleanupExpiredBufferedPayloads() { + DistributedJobLockService.LockHandle lockHandle = + distributedJobLockService.tryLock("task-scope-buffer:cleanup", CLEANUP_LOCK_TTL); + if (lockHandle == null) { + log.info("[task-scope-buffer] skip cleanup because another instance holds the lock"); + return; + } + try (lockHandle) { + Path root = taskScopePayloadStorageService.getBufferedPayloadRoot(); + if (!Files.isDirectory(root)) { + return; + } + Instant cutoff = Instant.now().minus(Duration.ofHours(Math.max(1L, taskPressureProperties.getScopePayloadBufferRetentionHours()))); + int deleted = 0; + for (Path file : listBufferedPayloadFiles(root, Integer.MAX_VALUE)) { + try { + FileTime lastModified = Files.getLastModifiedTime(file); + if (lastModified.toInstant().isAfter(cutoff)) { + continue; + } + Files.deleteIfExists(file); + deleted++; + deleteEmptyParents(file.getParent(), root); + } catch (Exception ex) { + log.warn("[task-scope-buffer] cleanup failed path={} msg={}", file, ex.getMessage()); + } + } + if (deleted > 0) { + log.info("[task-scope-buffer] cleanup finished deleted={} root={}", deleted, root); + } + } + } + + private List listBufferedPayloadFiles(Path root, int limit) { + try (var walk = Files.walk(root, 4)) { + return walk + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted(Comparator.comparing(this::safeLastModified)) + .limit(limit) + .toList(); + } catch (IOException ex) { + log.warn("[task-scope-buffer] scan failed root={} msg={}", root, ex.getMessage()); + return List.of(); + } + } + + private Instant safeLastModified(Path path) { + try { + return Files.getLastModifiedTime(path).toInstant(); + } catch (IOException ex) { + return Instant.EPOCH; + } + } + + private BufferFileRef parseBufferFile(Path root, Path file) { + Path relative = root.relativize(file); + if (relative.getNameCount() != 3) { + return null; + } + String moduleType = relative.getName(0).toString().toUpperCase(); + String taskIdRaw = relative.getName(1).toString(); + String fileName = relative.getName(2).toString(); + if (!fileName.endsWith(".json")) { + return null; + } + try { + long taskId = Long.parseLong(taskIdRaw); + String scopeHash = fileName.substring(0, fileName.length() - 5); + if (scopeHash.isBlank()) { + return null; + } + return new BufferFileRef(moduleType, taskId, scopeHash); + } catch (NumberFormatException ex) { + return null; + } + } + + private void deleteEmptyParents(Path start, Path root) { + Path current = start; + while (current != null && !current.equals(root)) { + try (var list = Files.list(current)) { + if (list.findAny().isPresent()) { + return; + } + } catch (IOException ex) { + return; + } + try { + Files.deleteIfExists(current); + } catch (IOException ex) { + return; + } + current = current.getParent(); + } + } + + private record BufferFileRef(String moduleType, Long taskId, String scopeHash) {} +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java new file mode 100644 index 0000000..f9d4971 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java @@ -0,0 +1,542 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.TaskPressureProperties; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class TaskScopePayloadStorageService { + + private static final Set OSS_BACKED_STATE_MODULES = new HashSet<>(Set.of( + "SHOP_MATCH", + "PRODUCT_RISK_RESOLVE", + "PRICE_TRACK", + "PATROL_DELETE" + )); + private static final String OSS_POINTER_PREFIX = "oss:"; + private static final String LOCAL_BUFFER_DIR = "task-scope-buffer"; + + private final TaskScopeStateMapper taskScopeStateMapper; + private final ObjectMapper objectMapper; + private final OssStorageService ossStorageService; + private final TaskPressureProperties taskPressureProperties; + + @Transactional + public void saveScopePayload(Long taskId, String moduleType, String scopeKey, T payload) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey) || payload == null) { + return; + } + String normalizedScopeKey = normalize(scopeKey); + String scopeHash = hash(normalizedScopeKey); + String payloadJson = writeJson(payload, "写入任务范围载荷失败"); + LocalDateTime now = LocalDateTime.now(); + TaskScopeStateEntity state = getScopeState(taskId, moduleType, scopeHash); + if (shouldStoreStatePayloadInOss(moduleType)) { + saveOssBackedScopePayload(taskId, moduleType, normalizedScopeKey, scopeHash, payloadJson, now, state); + return; + } + String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); + if (state == null) { + TaskScopeStateEntity entity = new TaskScopeStateEntity(); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setScopeKey(normalizedScopeKey); + entity.setScopeHash(scopeHash); + entity.setStateJson(stateJson); + entity.setChunkTotal(0); + entity.setReceivedChunkCount(0); + entity.setCompleted(0); + entity.setLastChunkAt(now); + entity.setLastError(null); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskScopeStateMapper.insert(entity); + return; + } catch (DuplicateKeyException ignored) { + state = getScopeState(taskId, moduleType, scopeHash); + } + } + if (state == null) { + throw new BusinessException("写入任务范围载荷失败"); + } + deleteReplacedStatePayloadIfNeeded(state.getStateJson(), stateJson); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getScopeKey, normalizedScopeKey) + .set(TaskScopeStateEntity::getStateJson, stateJson) + .set(TaskScopeStateEntity::getLastChunkAt, now) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + } + + public T getScopePayload(Long taskId, String moduleType, String scopeKey, Class clazz) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey)) { + return null; + } + TaskScopeStateEntity state = getScopeState(taskId, moduleType, hash(normalize(scopeKey))); + String payloadJson = resolveStatePayload(state); + if (payloadJson == null) { + return null; + } + try { + return objectMapper.readValue(payloadJson, clazz); + } catch (Exception ex) { + throw new BusinessException("读取任务范围载荷失败"); + } + } + + public Map getAllScopePayload(Long taskId, String moduleType, Class clazz) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return Map.of(); + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getScopeKey, TaskScopeStateEntity::getScopeHash, TaskScopeStateEntity::getStateJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, moduleType) + .isNotNull(TaskScopeStateEntity::getStateJson)); + if (states == null || states.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + for (TaskScopeStateEntity state : states) { + String payloadJson = resolveStatePayload(state); + if (payloadJson == null || isBlank(state.getScopeKey())) { + continue; + } + try { + result.put(state.getScopeKey(), objectMapper.readValue(payloadJson, clazz)); + } catch (Exception ex) { + throw new BusinessException("读取任务范围载荷失败"); + } + } + return result; + } + + @Transactional + public void removeScopePayload(Long taskId, String moduleType, String scopeKey) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey)) { + return; + } + TaskScopeStateEntity state = getScopeState(taskId, moduleType, hash(normalize(scopeKey))); + if (state == null) { + return; + } + deleteBufferedPayload(taskId, moduleType, state.getScopeHash()); + deleteStatePayloadIfNeeded(state.getStateJson()); + boolean hasParsedPayload = !isBlank(state.getParsedPayloadJson()); + if (hasParsedPayload) { + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getStateJson, null) + .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); + return; + } + taskScopeStateMapper.deleteById(state.getId()); + } + + public boolean hasAnyScopePayload(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return false; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, moduleType) + .isNotNull(TaskScopeStateEntity::getStateJson)); + return count != null && count > 0; + } + + public long countScopePayload(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return 0L; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, moduleType) + .isNotNull(TaskScopeStateEntity::getStateJson)); + return count == null ? 0L : count; + } + + @Transactional + public void deleteTaskScopePayloads(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return; + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, moduleType)); + if (states == null || states.isEmpty()) { + return; + } + for (TaskScopeStateEntity state : states) { + if (state == null || state.getId() == null) { + continue; + } + deleteBufferedPayload(taskId, moduleType, state.getScopeHash()); + deleteStatePayloadIfNeeded(state.getStateJson()); + if (!isBlank(state.getParsedPayloadJson())) { + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getStateJson, null) + .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); + } else { + taskScopeStateMapper.deleteById(state.getId()); + } + } + } + + @Transactional + public void saveParsedPayloadMap(Long taskId, String moduleType, Map payloadByScope) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || payloadByScope == null || payloadByScope.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(); + for (Map.Entry entry : payloadByScope.entrySet()) { + if (isBlank(entry.getKey()) || entry.getValue() == null) { + continue; + } + String scopeKey = normalize(entry.getKey()); + String scopeHash = hash(scopeKey); + String parsedPayloadJson = writeJson(entry.getValue(), "写入任务解析载荷失败"); + TaskScopeStateEntity state = getScopeState(taskId, moduleType, scopeHash); + if (state == null) { + TaskScopeStateEntity entity = new TaskScopeStateEntity(); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setScopeKey(scopeKey); + entity.setScopeHash(scopeHash); + entity.setParsedPayloadJson(parsedPayloadJson); + entity.setChunkTotal(0); + entity.setReceivedChunkCount(0); + entity.setCompleted(0); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskScopeStateMapper.insert(entity); + continue; + } catch (DuplicateKeyException ignored) { + state = getScopeState(taskId, moduleType, scopeHash); + } + } + if (state == null) { + throw new BusinessException("写入任务解析载荷失败"); + } + deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), parsedPayloadJson); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getScopeKey, scopeKey) + .set(TaskScopeStateEntity::getParsedPayloadJson, parsedPayloadJson) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + } + } + + public Map getParsedPayloadMap(Long taskId, String moduleType, Class clazz) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return Map.of(); + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getScopeKey, TaskScopeStateEntity::getParsedPayloadJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, moduleType) + .isNotNull(TaskScopeStateEntity::getParsedPayloadJson)); + if (states == null || states.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + for (TaskScopeStateEntity state : states) { + if (isBlank(state.getParsedPayloadJson()) || isBlank(state.getScopeKey())) { + continue; + } + try { + result.put(state.getScopeKey(), objectMapper.readValue(resolveParsedPayload(state.getParsedPayloadJson()), clazz)); + } catch (Exception ex) { + throw new BusinessException("读取任务原始载荷失败"); + } + } + return result; + } + + @Transactional + public boolean recoverBufferedScopePayload(Long taskId, String moduleType, String scopeHash) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeHash)) { + return false; + } + String payloadJson = readBufferedPayload(taskId, moduleType, scopeHash); + if (payloadJson == null) { + return false; + } + TaskScopeStateEntity state = getScopeState(taskId, moduleType, scopeHash); + if (state == null) { + deleteBufferedPayload(taskId, moduleType, scopeHash); + return false; + } + if (!shouldStoreStatePayloadInOss(moduleType)) { + deleteBufferedPayload(taskId, moduleType, scopeHash); + return false; + } + LocalDateTime now = LocalDateTime.now(); + String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); + deleteReplacedStatePayloadIfNeeded(state.getStateJson(), stateJson); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getStateJson, stateJson) + .set(TaskScopeStateEntity::getLastChunkAt, now) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + deleteBufferedPayload(taskId, moduleType, scopeHash); + return true; + } + + public Path getBufferedPayloadRoot() { + return Path.of(System.getProperty("java.io.tmpdir"), LOCAL_BUFFER_DIR); + } + + private TaskScopeStateEntity getScopeState(Long taskId, String moduleType, String scopeHash) { + return taskScopeStateMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, moduleType) + .eq(TaskScopeStateEntity::getScopeHash, scopeHash) + .last("limit 1")); + } + + private String storeStatePayload(String moduleType, Long taskId, String scopeHash, String payloadJson) { + if (!shouldStoreStatePayloadInOss(moduleType)) { + return payloadJson; + } + try { + String pointer = OSS_POINTER_PREFIX + ossStorageService.uploadTaskScopePayload(moduleType, taskId, scopeHash, payloadJson); + return objectMapper.writeValueAsString(pointer); + } catch (Exception ex) { + throw new BusinessException("写入任务范围载荷失败"); + } + } + + private String resolveStatePayload(TaskScopeStateEntity state) { + if (state == null || isBlank(state.getStateJson())) { + return null; + } + String bufferedPayload = readBufferedPayload(state.getTaskId(), state.getModuleType(), state.getScopeHash()); + if (bufferedPayload != null) { + return bufferedPayload; + } + String ossPointer = extractOssPointer(state.getStateJson()); + if (ossPointer == null) { + return state.getStateJson(); + } + try { + return ossStorageService.readObjectAsString(stripOssPointerPrefix(ossPointer)); + } catch (Exception ex) { + throw new BusinessException("读取任务范围载荷失败"); + } + } + + private void deleteStatePayloadIfNeeded(String value) { + String ossPointer = extractOssPointer(value); + if (ossPointer == null) { + return; + } + try { + ossStorageService.deleteObject(stripOssPointerPrefix(ossPointer)); + } catch (Exception ignored) { + } + } + + private String resolveParsedPayload(String value) { + if (isBlank(value) || !isOssPointer(value)) { + return value; + } + try { + return ossStorageService.readObjectAsString(stripOssPointerPrefix(value)); + } catch (Exception ex) { + throw new BusinessException("读取任务解析载荷失败"); + } + } + + private void deleteParsedPayloadIfNeeded(String oldValue, String newValue) { + if (isBlank(oldValue) || oldValue.equals(newValue) || !isOssPointer(oldValue)) { + return; + } + try { + ossStorageService.deleteObject(stripOssPointerPrefix(oldValue)); + } catch (Exception ignored) { + } + } + + private void deleteReplacedStatePayloadIfNeeded(String oldValue, String newValue) { + if (isBlank(oldValue) || oldValue.equals(newValue)) { + return; + } + deleteStatePayloadIfNeeded(oldValue); + } + + private void saveOssBackedScopePayload(Long taskId, + String moduleType, + String normalizedScopeKey, + String scopeHash, + String payloadJson, + LocalDateTime now, + TaskScopeStateEntity state) { + if (state == null) { + String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); + TaskScopeStateEntity entity = new TaskScopeStateEntity(); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setScopeKey(normalizedScopeKey); + entity.setScopeHash(scopeHash); + entity.setStateJson(stateJson); + entity.setChunkTotal(0); + entity.setReceivedChunkCount(0); + entity.setCompleted(0); + entity.setLastChunkAt(now); + entity.setLastError(null); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskScopeStateMapper.insert(entity); + deleteBufferedPayload(taskId, moduleType, scopeHash); + return; + } catch (DuplicateKeyException ignored) { + state = getScopeState(taskId, moduleType, scopeHash); + } + } + if (state == null) { + throw new BusinessException("刷新任务范围缓冲载荷失败"); + } + + writeBufferedPayload(taskId, moduleType, scopeHash, payloadJson); + if (!shouldFlushBufferedPayload(state, now)) { + return; + } + + String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); + deleteReplacedStatePayloadIfNeeded(state.getStateJson(), stateJson); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getScopeKey, normalizedScopeKey) + .set(TaskScopeStateEntity::getStateJson, stateJson) + .set(TaskScopeStateEntity::getLastChunkAt, now) + .set(TaskScopeStateEntity::getUpdatedAt, now)); + deleteBufferedPayload(taskId, moduleType, scopeHash); + } + + private String writeJson(Object value, String message) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(message); + } + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private boolean shouldStoreStatePayloadInOss(String moduleType) { + return OSS_BACKED_STATE_MODULES.contains(normalize(moduleType).toUpperCase()); + } + + private boolean isOssPointer(String value) { + return !isBlank(value) && value.startsWith(OSS_POINTER_PREFIX); + } + + private String extractOssPointer(String value) { + if (isBlank(value)) { + return null; + } + if (isOssPointer(value)) { + return value; + } + try { + String decoded = objectMapper.readValue(value, String.class); + return isOssPointer(decoded) ? decoded : null; + } catch (Exception ignored) { + return null; + } + } + + private String stripOssPointerPrefix(String value) { + return value.substring(OSS_POINTER_PREFIX.length()); + } + + private boolean shouldFlushBufferedPayload(TaskScopeStateEntity state, LocalDateTime now) { + long flushIntervalMillis = Math.max(0L, taskPressureProperties.getScopePayloadFlushIntervalMillis()); + if (flushIntervalMillis <= 0L || state.getUpdatedAt() == null) { + return true; + } + return java.time.Duration.between(state.getUpdatedAt(), now).toMillis() >= flushIntervalMillis; + } + + private void writeBufferedPayload(Long taskId, String moduleType, String scopeHash, String payloadJson) { + try { + Path path = buildBufferedPayloadPath(taskId, moduleType, scopeHash); + Files.createDirectories(path.getParent()); + Files.writeString(path, payloadJson, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } catch (IOException ex) { + throw new BusinessException("写入任务范围缓冲载荷失败"); + } + } + + private String readBufferedPayload(Long taskId, String moduleType, String scopeHash) { + try { + Path path = buildBufferedPayloadPath(taskId, moduleType, scopeHash); + if (!Files.isRegularFile(path)) { + return null; + } + return Files.readString(path); + } catch (IOException ex) { + return null; + } + } + + private void deleteBufferedPayload(Long taskId, String moduleType, String scopeHash) { + try { + Path path = buildBufferedPayloadPath(taskId, moduleType, scopeHash); + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + } + + private Path buildBufferedPayloadPath(Long taskId, String moduleType, String scopeHash) { + String normalizedModuleType = normalize(moduleType).toLowerCase(); + return Path.of(System.getProperty("java.io.tmpdir"), LOCAL_BUFFER_DIR, normalizedModuleType, String.valueOf(taskId), scopeHash + ".json"); + } + + private String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(normalize(value).getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("failed to hash scope key", ex); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java index cf9629b..250a60d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java @@ -50,7 +50,7 @@ public class ZiniaoShopSwitchService { if (value == null) { return ""; } - return value.replace("\u3000", " ").trim(); + return value.replace(" ", " ").trim(); } public boolean isSkippableMatchError(BusinessException ex) { @@ -59,9 +59,9 @@ public class ZiniaoShopSwitchService { return false; } return (message.contains("code=40004") - || message.contains("userId\u5b58\u5728\u65e0\u6548\u7684\u53c2\u6570\u503c") - || message.contains("\u65e0\u6743\u9650") - || message.contains("\u6ca1\u6709\u6743\u9650")) - && !message.contains("\u767d\u540d\u5355"); + || message.contains("userId存在无效的参数值") + || message.contains("无权限") + || message.contains("没有权限")) + && !message.contains("白名单"); } } diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index faf0ee3..b740648 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -99,6 +99,10 @@ aiimage: task-pressure: local-task-entity-cache-millis: ${AIIMAGE_TASK_LOCAL_ENTITY_CACHE_MILLIS:3000} db-select-batch-size: ${AIIMAGE_TASK_DB_SELECT_BATCH_SIZE:200} + scope-payload-flush-interval-millis: ${AIIMAGE_TASK_SCOPE_PAYLOAD_FLUSH_INTERVAL_MILLIS:15000} + scope-payload-buffer-retention-hours: ${AIIMAGE_TASK_SCOPE_PAYLOAD_BUFFER_RETENTION_HOURS:24} + scope-payload-recovery-max-files: ${AIIMAGE_TASK_SCOPE_PAYLOAD_RECOVERY_MAX_FILES:200} + scope-payload-cleanup-cron: ${AIIMAGE_TASK_SCOPE_PAYLOAD_CLEANUP_CRON:15 */30 * * * *} security: shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key} internal-token: ${AIIMAGE_INTERNAL_TOKEN:} diff --git a/backend-java/src/main/resources/db/V29__skip_price_asin_minimum_price.sql b/backend-java/src/main/resources/db/V29__skip_price_asin_minimum_price.sql new file mode 100644 index 0000000..2a2d5bc --- /dev/null +++ b/backend-java/src/main/resources/db/V29__skip_price_asin_minimum_price.sql @@ -0,0 +1,76 @@ +SET @schema_name = DATABASE(); + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND COLUMN_NAME = 'minimum_price_de' + ), + 'SELECT 1', + 'ALTER TABLE biz_skip_price_asin ADD COLUMN minimum_price_de DECIMAL(10,2) NULL DEFAULT NULL COMMENT ''DE minimum price'' AFTER asin_de' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND COLUMN_NAME = 'minimum_price_uk' + ), + 'SELECT 1', + 'ALTER TABLE biz_skip_price_asin ADD COLUMN minimum_price_uk DECIMAL(10,2) NULL DEFAULT NULL COMMENT ''UK minimum price'' AFTER asin_uk' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND COLUMN_NAME = 'minimum_price_fr' + ), + 'SELECT 1', + 'ALTER TABLE biz_skip_price_asin ADD COLUMN minimum_price_fr DECIMAL(10,2) NULL DEFAULT NULL COMMENT ''FR minimum price'' AFTER asin_fr' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND COLUMN_NAME = 'minimum_price_it' + ), + 'SELECT 1', + 'ALTER TABLE biz_skip_price_asin ADD COLUMN minimum_price_it DECIMAL(10,2) NULL DEFAULT NULL COMMENT ''IT minimum price'' AFTER asin_it' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name + AND TABLE_NAME = 'biz_skip_price_asin' + AND COLUMN_NAME = 'minimum_price_es' + ), + 'SELECT 1', + 'ALTER TABLE biz_skip_price_asin ADD COLUMN minimum_price_es DECIMAL(10,2) NULL DEFAULT NULL COMMENT ''ES minimum price'' AFTER asin_es' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend-java/src/main/resources/db/V30__task_chunk_storage.sql b/backend-java/src/main/resources/db/V30__task_chunk_storage.sql new file mode 100644 index 0000000..3b339be --- /dev/null +++ b/backend-java/src/main/resources/db/V30__task_chunk_storage.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS biz_task_scope_state ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'task id', + module_type VARCHAR(32) NOT NULL COMMENT 'module type', + scope_key VARCHAR(1000) NOT NULL COMMENT 'scope identifier', + scope_hash CHAR(64) NOT NULL COMMENT 'hash of scope key', + parsed_payload_json JSON NULL COMMENT 'parsed source payload', + state_json JSON NULL COMMENT 'merged intermediate state', + chunk_total INT NOT NULL DEFAULT 0 COMMENT 'expected chunk count', + received_chunk_count INT NOT NULL DEFAULT 0 COMMENT 'received chunk count', + completed TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'whether scope is complete', + last_chunk_at DATETIME NULL COMMENT 'last chunk received time', + last_error VARCHAR(1000) NULL COMMENT 'last error message', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + UNIQUE KEY uk_task_scope (task_id, module_type, scope_hash), + KEY idx_task_completed (task_id, module_type, completed), + KEY idx_scope_hash (scope_hash) +) COMMENT='task scope state'; + +CREATE TABLE IF NOT EXISTS biz_task_chunk ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'task id', + module_type VARCHAR(32) NOT NULL COMMENT 'module type', + scope_key VARCHAR(1000) NOT NULL COMMENT 'scope identifier', + scope_hash CHAR(64) NOT NULL COMMENT 'hash of scope key', + chunk_index INT NOT NULL COMMENT 'chunk index starting from 1', + chunk_total INT NOT NULL DEFAULT 0 COMMENT 'total chunk count', + payload_json JSON NOT NULL COMMENT 'chunk payload', + payload_hash CHAR(64) NOT NULL COMMENT 'hash of payload json', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + UNIQUE KEY uk_task_scope_chunk (task_id, module_type, scope_hash, chunk_index), + KEY idx_task_scope (task_id, module_type, scope_hash), + KEY idx_task_module (task_id, module_type) +) COMMENT='task chunk detail'; diff --git a/backend/__pycache__/config.cpython-312.pyc b/backend/__pycache__/config.cpython-312.pyc index a42abffa378f1cdf4de15a070cf5b66ad77759cc..7a86814bf5f8b7dec144efe63071f85c9cfb9d87 100644 GIT binary patch delta 84 zcmaFK{fwLYG%qg~0}xDfd9jiEJCmSTMoCG5mA<~Ak-44$5E^b4V-93exgjAnpKB)9 ne7>1{7u4-8I7D2Qh`cV5a8V-RLSo8giPQ@Wq7No_v&I1cKoA@k delta 87 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 W8^N(d9&xDyj-rDpFtl-j1^*545jybz delta 1704 zcmZWpeQZ-z6o2=9^u4yP?Q37RmUe3i`{=qshrsw|V`~U9L82Wo3}RFE9@u@f^Y%4E zEly)XVkB|pm}~@4jET_-#m5|B|8`CvUdhdB5VMSNyWI+q;CW2|S4WIm_k zWJ()JXW(zNpKTFf&bb0!W9=O+hA=P8Fw+YJhYaz5KCfY|`V2d~Bh(`qx-50F#tpSF z%ml(e88SGyIw$rTj%wtf2a5K9GvhMkMVA-4xNW3TG69oZ2azFJJa!t(;14c{f?(x? zzWQo-wWecSfg22yqGuQ&iMb}7*>%@vd1uH`nHMeFx!&Z|t&tW#f7{SGP zo8~@muYQ@#R3x@y-x3L@VP6b?ETldbC23FYb{$Wa-}bnq&2+yOHAJmuX-WSnr{;-r zg7A;f7@9yQoJYC+%ou85jFnZPMh3@^S>`KNaNW``80|1*X>%-HrWZc4L{KfX}x?6iMoI0jo&|h^WMb|?@b*4wK(D|i zvN;{brDZSpX%5{TlOM=tGA2h(fyv?Qn2Gcpm0mtS9tIAj28Q&EcJR<}ULVEjjFCH- z${R1mMqe?7;p~xg2CpUIPV(er7UrZ{OSv;q?%W5W6_6iJTek>vGNY*Qi?zM`t}ie- zRveppwd`x1@dhXN6!*Nhcg7c*96vce6K?*jqZIGI6*+J_e6aLlsuWI@{Hd8>^SMH4 z%k$-6qU240YKvL#%F4UFZ}*?o4e+0?v=S~>k#FeWx}spD8DWXY_A&xdkcFBE!-Y9IAAw< z>@KiynhOyl+^~3=Xre)zqB1mT@wAYW16E5AJqI9nakhLH+>v`&TnlxM_+cC(;eFUg zkTgRFd10y-wN)Adhp9uNK(9|s4?J$B5TVdYp^butK$OEV%G)WdCXk~?pZs^c2EK84 zZR?4(iMoG)pBQ8@ep^p9>qg&(|IC_qeQw=d!?99nFqvX@EQW9g)~++ zZ{W?u*bT>BjUuh%*{B~rcMYX5y*o`gZ={EFF>-ZlI>xmBOevY9M=P1cE@}#xXy}#p z#B?ncIlPfFDmVWrY3oQ<%Ma_FI8J=@Zp)p9qe_TR++q?isYG27)bxO3*!EeLu-QJi JsqEls{=c|Th%o>F diff --git a/backend/blueprints/__pycache__/main.cpython-312.pyc b/backend/blueprints/__pycache__/main.cpython-312.pyc index 35db3ff5c3d8245748582e2115ce42bbb7650ce3..05dca58ae41bfb19b85c523b726dfd9cdf0cae39 100644 GIT binary patch delta 879 zcmZ{i&ubGw6vt=wM|P9!rmbn3q_nk-70tmGQ$&Jk!BQ)NUaY5Fmb5dLjmaj=ZlqE% ztq3A`NE|^C@hu>MKc z1Ask`)8EZUbv+K>*H2x@K!6~U3&En6)gTZ67LgC@NO_|+L{0;c4Pls{=*In;pQ<+_ zk)sO9!wM(*{oNQJqahfd4<9ijd59y7{0*U-&qi=4e}*EMoR9M8e;R2G*qh`>`UBAD zA>Nvebvm?R62|EtSoH~Rs8Cv`R7lXz!m?Up{4?M{T9U_ZAm}wC6OQZ+^IDN%NAYrw z-;0podAxN6^mFu;xLlP9ce>&_b_jP8i3^|b8XPbMzeZPcYqzk4W>*UpTqdUF*t5l) zY0Z@HxUy*>yg{Oj8)UD2j>#7Iri31DZg1AoZDqWjINwgBcLt&_mFDExRxH&Tm}{zY zJHhbN%g?X2s3rMfglPn5@!iM$R1#_1%CQ~7OmZF z1*eNdWSr*|WD=evQ$6CQSzv^#E?!)@WGr2|PL6YS zl2>I?syMjwZ#sMLY~tSos+nR5Rgwj~KvK-aFO2K^xkod{|0z}lW#{d8T delta 895 zcma)4&ubGw6rS1LWYcUmiKgu~t;ROC)OaY?`h!vlShV25gBCAKU`R7#*_z$7v%8{H zY%7A`sby~B(SwrS{0sCdLLpIP5kwGrDcDj$5D(65+8XM`h5hEe?|tvReQ)+r=6gne zr|WURpP%cS*4H&X3qP(6Pn@weQ?nCh0s;(iq^jFVGYNqNh>W8oh1K;~T^3mYm}vs% z@@ycIF@{=9JN{2>Y`8t6Ws}Z2Y*~j-vunR5djm5^Nf+*@OD(Bsko4RrE5Sk}8(L3c zZji*W{utEvS{y#j-ca}!7GNLSf=k^>kgV2bEeBDu;9Hd7AX&yXTafZb&}F%(;!{c- z5BV;kh(|1kF=-0EV1YDp{*)Kl>vPbM<~;QuuG8z-=RA!S(cynCpavF zZ))<%;>zOccvC&z%wKHg$D76JX7SeNl<{2M=pSwLoNc7Wn5J|>gY_%diwT}ay9F2m zdN>5}aBwtVo+Y%86C&cKI4px7F_17;A2hVV4P}rTuj|Th2vh83^jS}sn&5l8OOWf8 zJ*z@b^PC(r)s-WnMB!MVB7|!dglLX!spCt3XAcs-XI0%1zI;q5)ww)~A>@>8!qrLc z(i4JHu|)+*PwpvhTUD+IVw0Druc9m0Zqfmv&5LM+)_jkI8w$q=Kc{`x3t^2*b`ASg zQl#g&EB;Ei!C_m55bj74RCc2P#=n5c4`6Z=R2rbd-p3whKf% 0] + column_ids = _get_column_ids_by_route_paths(route_paths, 'admin') + if not normalized_user_ids or not column_ids: + return + conn = get_db() + try: + with conn.cursor() as cur: + for user_id in normalized_user_ids: + for column_id in column_ids: + cur.execute( + "INSERT IGNORE INTO user_column_permission (user_id, column_id) VALUES (%s, %s)", + (user_id, column_id), + ) + conn.commit() + finally: + conn.close() + + @admin_api.route('/current-user') -@admin_required +@login_required def get_admin_current_user(): role, current_row = get_current_admin_role() if not role or not current_row: @@ -370,16 +444,16 @@ def get_admin_current_user(): @admin_api.route('/current-user/menus') -@admin_required +@login_required def get_admin_current_user_menus(): - _, _, items, denied = _load_current_admin_menu_items() + _, _, items, denied = _load_current_backend_menu_items() if denied: return denied return jsonify({'success': True, 'items': items}) @admin_api.route('/logout', methods=['POST']) -@admin_required +@login_required def admin_logout(): session.clear() return jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login'}) @@ -388,17 +462,14 @@ def admin_logout(): # ---------- 用户管理 ---------- @admin_api.route('/users') -@admin_required +@login_required def list_users(): - """分页获取用户列表,支持用户名模糊搜索和按管理员归属筛选普通用户""" + """分页获取用户列表,支持用户名模糊搜索和按管理员归属筛选普通用户。""" role, current_row = get_current_admin_role() if not role: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 + return jsonify({'success': False, 'error': '需要登录'}), 403 page = max(1, int(request.args.get('page', 1))) - _, _, denied = _ensure_admin_menu_access('users', 'history', 'shop-manage', 'skip-price-asin') - if denied: - return denied - _, _, denied = _ensure_admin_menu_access('users', 'history', 'shop-manage', 'skip-price-asin') + _, _, denied = _ensure_backend_menu_access('users', 'history', 'shop-manage', 'skip-price-asin') if denied: return denied page_size = min(999, max(5, int(request.args.get('page_size', 15)))) @@ -407,6 +478,7 @@ def list_users(): created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id') created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None can_view_all_users = role == 'super_admin' or (current_row and (current_row.get('username') or '').strip().lower() == 'admin') + can_view_child_users = role == 'admin' and not can_view_all_users if not can_view_all_users: created_by_id = None try: @@ -435,7 +507,7 @@ def list_users(): total = cur.fetchone()['total'] cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id") admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()] - else: + elif can_view_child_users: admin_id = current_row['id'] where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"] params = [admin_id, admin_id] @@ -458,6 +530,28 @@ def list_users(): ) total = cur.fetchone()['total'] admins = [] + else: + where_parts = ["u.id = %s"] + params = [current_row['id']] + if search_username: + where_parts.append("u.username LIKE %s") + params.append("%" + search_username + "%") + where_sql = " AND ".join(where_parts) + cur.execute( + """SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id, + creator.username AS creator_username + FROM users u + LEFT JOIN users creator ON creator.id = u.created_by_id + WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""", + tuple(params) + (page_size, offset), + ) + rows = cur.fetchall() + cur.execute( + "SELECT COUNT(*) as total FROM users u WHERE " + where_sql, + tuple(params), + ) + total = cur.fetchone()['total'] + admins = [] items = [ { 'id': r['id'], @@ -485,7 +579,6 @@ def list_users(): except Exception as e: return jsonify({'success': False, 'error': str(e)}) - @admin_api.route('/user', methods=['POST']) @admin_required def create_user(): @@ -1341,9 +1434,9 @@ def delete_dedupe_total_data(item_id): # ---------- 店铺管理 ---------- @admin_api.route('/shop-manages') -@admin_required +@login_required def list_shop_manages(): - role, current_row, denied = _ensure_admin_menu_access('shop-manage') + role, current_row, denied = _ensure_backend_menu_access('shop-manage') if denied: return denied page = max(1, int(request.args.get('page', 1))) @@ -1397,9 +1490,9 @@ def list_shop_manages(): @admin_api.route('/shop-manage', methods=['POST']) -@admin_required +@login_required def create_shop_manage(): - role, current_row, denied = _ensure_admin_menu_access('shop-manage') + role, current_row, denied = _ensure_backend_menu_access('shop-manage') if denied: return denied data = request.get_json() or {} @@ -1441,9 +1534,9 @@ def create_shop_manage(): @admin_api.route('/shop-manage/', methods=['PUT']) -@admin_required +@login_required def update_shop_manage(item_id): - role, current_row, denied = _ensure_admin_menu_access('shop-manage') + role, current_row, denied = _ensure_backend_menu_access('shop-manage') if denied: return denied data = request.get_json() or {} @@ -1483,9 +1576,9 @@ def update_shop_manage(item_id): @admin_api.route('/shop-manage/', methods=['DELETE']) -@admin_required +@login_required def delete_shop_manage(item_id): - role, current_row, denied = _ensure_admin_menu_access('shop-manage') + role, current_row, denied = _ensure_backend_menu_access('shop-manage') if denied: return denied result, error_response, status = _proxy_backend_java( @@ -1505,9 +1598,9 @@ def delete_shop_manage(item_id): @admin_api.route('/shop-manage-groups') -@admin_required +@login_required def list_shop_manage_groups(): - role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') if denied: return denied params = {} @@ -1543,12 +1636,13 @@ def list_shop_manage_groups(): @admin_api.route('/shop-manage-group', methods=['POST']) -@admin_required +@login_required def create_shop_manage_group(): - role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') if denied: return denied data = request.get_json() or {} + grant_menu_routes = data.get('grant_menu_routes') or [] payload = { 'groupName': (data.get('group_name') or '').strip(), 'memberUserIds': data.get('member_user_ids') or [], @@ -1565,6 +1659,7 @@ def create_shop_manage_group(): ) if error_response is not None: return error_response, status + _grant_backend_menu_permissions(data.get('member_user_ids') or [], grant_menu_routes) item = result.get('data') or {} return jsonify({ 'success': True, @@ -1586,12 +1681,13 @@ def create_shop_manage_group(): @admin_api.route('/shop-manage-group/', methods=['PUT']) -@admin_required +@login_required def update_shop_manage_group(item_id): - role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') if denied: return denied data = request.get_json() or {} + grant_menu_routes = data.get('grant_menu_routes') or [] payload = { 'groupName': (data.get('group_name') or '').strip(), 'memberUserIds': data.get('member_user_ids') or [], @@ -1607,6 +1703,7 @@ def update_shop_manage_group(item_id): ) if error_response is not None: return error_response, status + _grant_backend_menu_permissions(data.get('member_user_ids') or [], grant_menu_routes) item = result.get('data') or {} return jsonify({ 'success': True, @@ -1628,9 +1725,9 @@ def update_shop_manage_group(item_id): @admin_api.route('/skip-price-asins') -@admin_required +@login_required def list_skip_price_asins(): - role, current_row, denied = _ensure_admin_menu_access('skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('skip-price-asin') if denied: return denied page = max(1, int(request.args.get('page', 1))) @@ -1668,10 +1765,15 @@ def list_skip_price_asins(): 'group_name': item.get('groupName') or '', 'shop_name': item.get('shopName') or '', 'asin_de': item.get('asinDe') or '', + 'minimum_price_de': item.get('minimumPriceDe'), 'asin_uk': item.get('asinUk') or '', + 'minimum_price_uk': item.get('minimumPriceUk'), 'asin_fr': item.get('asinFr') or '', + 'minimum_price_fr': item.get('minimumPriceFr'), 'asin_it': item.get('asinIt') or '', + 'minimum_price_it': item.get('minimumPriceIt'), 'asin_es': item.get('asinEs') or '', + 'minimum_price_es': item.get('minimumPriceEs'), 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], } @@ -1687,25 +1789,34 @@ def list_skip_price_asins(): @admin_api.route('/skip-price-asin', methods=['POST']) -@admin_required +@login_required def create_skip_price_asin(): - role, current_row, denied = _ensure_admin_menu_access('skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('skip-price-asin') if denied: return denied data = request.get_json() or {} asin_mappings = data.get('asin_mappings') or {} + minimum_price_mappings = data.get('minimum_price_mappings') or {} fallback_asin = (data.get('asin') or '').strip() if not fallback_asin and isinstance(asin_mappings, dict): for value in asin_mappings.values(): fallback_asin = (value or '').strip() if fallback_asin: break + fallback_minimum_price = data.get('minimum_price') + if fallback_minimum_price in ('', None) and isinstance(minimum_price_mappings, dict): + for value in minimum_price_mappings.values(): + if value not in ('', None): + fallback_minimum_price = value + break payload = { 'groupId': data.get('group_id'), 'shopName': (data.get('shop_name') or '').strip(), 'countries': data.get('countries') or [], 'asin': fallback_asin, 'asinMappings': asin_mappings, + 'minimumPrice': fallback_minimum_price, + 'minimumPriceMappings': minimum_price_mappings, } result, error_response, status = _proxy_backend_java( 'POST', @@ -1728,10 +1839,15 @@ def create_skip_price_asin(): 'group_name': item.get('groupName') or '', 'shop_name': item.get('shopName') or '', 'asin_de': item.get('asinDe') or '', + 'minimum_price_de': item.get('minimumPriceDe'), 'asin_uk': item.get('asinUk') or '', + 'minimum_price_uk': item.get('minimumPriceUk'), 'asin_fr': item.get('asinFr') or '', + 'minimum_price_fr': item.get('minimumPriceFr'), 'asin_it': item.get('asinIt') or '', + 'minimum_price_it': item.get('minimumPriceIt'), 'asin_es': item.get('asinEs') or '', + 'minimum_price_es': item.get('minimumPriceEs'), 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], }, @@ -1739,9 +1855,9 @@ def create_skip_price_asin(): @admin_api.route('/skip-price-asin//country/', methods=['DELETE']) -@admin_required +@login_required def delete_skip_price_asin_country(item_id, country): - role, current_row, denied = _ensure_admin_menu_access('skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('skip-price-asin') if denied: return denied result, error_response, status = _proxy_backend_java( @@ -1758,9 +1874,9 @@ def delete_skip_price_asin_country(item_id, country): @admin_api.route('/shop-manage-group/', methods=['DELETE']) -@admin_required +@login_required def delete_shop_manage_group(item_id): - role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') if denied: return denied result, error_response, status = _proxy_backend_java( @@ -1777,13 +1893,16 @@ def delete_shop_manage_group(item_id): @admin_api.route('/skip-price-asin//country/', methods=['PUT']) -@admin_required +@login_required def update_skip_price_asin_country(item_id, country): - role, current_row, denied = _ensure_admin_menu_access('skip-price-asin') + role, current_row, denied = _ensure_backend_menu_access('skip-price-asin') if denied: return denied data = request.get_json() or {} - payload = {'asin': (data.get('asin') or '').strip()} + payload = { + 'asin': (data.get('asin') or '').strip(), + 'minimumPrice': data.get('minimum_price'), + } result, error_response, status = _proxy_backend_java( 'PUT', f'/api/admin/skip-price-asins/{item_id}/countries/{country}', @@ -1805,10 +1924,15 @@ def update_skip_price_asin_country(item_id, country): 'group_name': item.get('groupName') or '', 'shop_name': item.get('shopName') or '', 'asin_de': item.get('asinDe') or '', + 'minimum_price_de': item.get('minimumPriceDe'), 'asin_uk': item.get('asinUk') or '', + 'minimum_price_uk': item.get('minimumPriceUk'), 'asin_fr': item.get('asinFr') or '', + 'minimum_price_fr': item.get('minimumPriceFr'), 'asin_it': item.get('asinIt') or '', + 'minimum_price_it': item.get('minimumPriceIt'), 'asin_es': item.get('asinEs') or '', + 'minimum_price_es': item.get('minimumPriceEs'), 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], }, diff --git a/backend/blueprints/auth.py b/backend/blueprints/auth.py index 479918b..4d1e5ff 100644 --- a/backend/blueprints/auth.py +++ b/backend/blueprints/auth.py @@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, url_for, session, jsonify from werkzeug.security import check_password_hash from utils.db import get_db -from utils.auth import login_required, is_session_user_valid, is_current_user_admin +from utils.auth import login_required, is_session_user_valid from utils.render import render_html auth = Blueprint('auth', __name__, url_prefix='') @@ -13,7 +13,7 @@ auth = Blueprint('auth', __name__, url_prefix='') @auth.route('/login', methods=['GET', 'POST']) def login(): - if session.get('user_id') and is_session_user_valid() and is_current_user_admin(): + if session.get('user_id') and is_session_user_valid(): return redirect(url_for('main.admin_page')) if request.method == 'POST': data = request.get_json() if request.is_json else request.form @@ -31,22 +31,17 @@ def login(): (username,) ) row = cur.fetchone() + conn.close() if row and check_password_hash(row['password_hash'], password): session.permanent = True session['user_id'] = row['id'] session['username'] = username - if not row.get('is_admin'): - session.clear() - if request.is_json: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - return render_html('login.html', error='需要管理员权限') if request.is_json: return jsonify({'success': True, 'redirect': url_for('main.admin_page')}) return redirect(url_for('main.admin_page')) - conn.close() - except Exception as e: + except Exception as exc: if request.is_json: - return jsonify({'success': False, 'error': str(e)}) + return jsonify({'success': False, 'error': str(exc)}) return render_html('login.html', error='登录失败,请稍后重试') if request.is_json: return jsonify({'success': False, 'error': '用户名或密码错误'}) @@ -57,7 +52,7 @@ def login(): @auth.route('/api/auth/check') @login_required def api_auth_check(): - """校验登录状态,用于页面加载时判断是否已登录""" + """校验登录状态,用于页面加载时判断是否已登录。""" if not session.get('user_id'): return jsonify({'logged_in': False}) try: @@ -66,7 +61,7 @@ def api_auth_check(): cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],)) row = cur.fetchone() conn.close() - if not row or not row.get('is_admin'): + if not row: return jsonify({'logged_in': False}) except Exception: return jsonify({'logged_in': False}) diff --git a/backend/blueprints/main.py b/backend/blueprints/main.py index 1781403..f7d572e 100644 --- a/backend/blueprints/main.py +++ b/backend/blueprints/main.py @@ -4,7 +4,7 @@ import os from flask import Blueprint, redirect, url_for, send_file, session -from utils.auth import login_required, admin_required, is_session_user_valid, is_current_user_admin +from utils.auth import login_required, is_session_user_valid from utils.render import render_html main = Blueprint('main', __name__, url_prefix='') @@ -15,14 +15,13 @@ STATIC_DIR = os.path.join(BASE_DIR, 'static') @main.route('/') def index(): - if session.get('user_id') and is_session_user_valid() and is_current_user_admin(): + if session.get('user_id') and is_session_user_valid(): return redirect(url_for('main.admin_page')) return redirect(url_for('auth.login')) @main.route('/admin') @login_required -@admin_required def admin_page(): return render_html('admin.html') diff --git a/backend/config.py b/backend/config.py index 6692838..97a3eb7 100644 --- a/backend/config.py +++ b/backend/config.py @@ -23,8 +23,8 @@ bucket_path = "nanri-image/" file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" import os -# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') -backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/') +backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') +# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/') os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" diff --git a/backend/run.sh b/backend/run.sh new file mode 100644 index 0000000..83112e6 --- /dev/null +++ b/backend/run.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PORT="${PORT:-15124}" +HOST="${HOST:-0.0.0.0}" +APP_MODULE="${APP_MODULE:-app.py}" +PID_FILE="${PID_FILE:-backend.pid}" +LOG_FILE="${LOG_FILE:-backend.log}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "检查端口 ${PORT} ..." +if command -v ss >/dev/null 2>&1; then + if ss -ltn | awk '{print $4}' | grep -Eq "(^|:)${PORT}$"; then + echo "错误: 端口 ${PORT} 已被占用,请先释放后再启动。" + exit 1 + fi +elif command -v netstat >/dev/null 2>&1; then + if netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${PORT}$"; then + echo "错误: 端口 ${PORT} 已被占用,请先释放后再启动。" + exit 1 + fi +else + echo "警告: 未找到 ss 或 netstat,跳过端口占用检查。" +fi +echo "端口 ${PORT} 未被占用。" +echo "工作目录已切换至: ${SCRIPT_DIR}" + +PYTHON_BIN="" +if [[ -x "${SCRIPT_DIR}/venv/bin/python" ]]; then + PYTHON_BIN="${SCRIPT_DIR}/venv/bin/python" +elif [[ -x "${SCRIPT_DIR}/.venv/bin/python" ]]; then + PYTHON_BIN="${SCRIPT_DIR}/.venv/bin/python" +elif command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python3)" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python)" +else + echo "错误: 未找到可用的 Python,请先安装 Python 3.9+。" + exit 1 +fi + +echo "使用 Python: ${PYTHON_BIN}" +"${PYTHON_BIN}" - <<'PY' +import sys +major, minor = sys.version_info[:2] +if (major, minor) < (3, 9): + print(f"错误: 当前 Python 版本为 {major}.{minor},项目至少需要 Python 3.9。") + print("原因: requirement.txt 中的 Flask 3.x、pandas 2.x 等依赖不支持 Python 3.6。") + raise SystemExit(1) +print(f"Python 版本检查通过: {major}.{minor}") +PY + +if [[ ! -f "requirement.txt" ]]; then + echo "错误: 未找到 requirement.txt。" + exit 1 +fi + +echo "检查关键依赖 ..." +if ! "${PYTHON_BIN}" - <<'PY' +import importlib.util +modules = ("flask", "flask_cors", "pymysql", "werkzeug") +missing = [name for name in modules if importlib.util.find_spec(name) is None] +if missing: + print("缺少依赖: " + ", ".join(missing)) + raise SystemExit(1) +print("关键依赖已安装。") +PY +then + echo "开始安装 requirement.txt ..." + "${PYTHON_BIN}" -m pip install --upgrade pip + "${PYTHON_BIN}" -m pip install -r requirement.txt +fi + +if [[ -f "${PID_FILE}" ]]; then + OLD_PID="$(cat "${PID_FILE}" 2>/dev/null || true)" + if [[ -n "${OLD_PID}" ]] && kill -0 "${OLD_PID}" 2>/dev/null; then + echo "错误: 检测到服务已在运行,PID=${OLD_PID}" + exit 1 + fi + rm -f "${PID_FILE}" +fi + +echo "启动 Flask 服务 ${HOST}:${PORT} ..." +nohup "${PYTHON_BIN}" "${APP_MODULE}" >> "${LOG_FILE}" 2>&1 & +NEW_PID=$! +echo "${NEW_PID}" > "${PID_FILE}" + +sleep 2 +if kill -0 "${NEW_PID}" 2>/dev/null; then + echo "启动成功,PID=${NEW_PID}" + echo "日志文件: ${SCRIPT_DIR}/${LOG_FILE}" +else + echo "错误: 服务启动失败,请检查日志 ${SCRIPT_DIR}/${LOG_FILE}" + exit 1 +fi diff --git a/backend/utils/__pycache__/auth.cpython-312.pyc b/backend/utils/__pycache__/auth.cpython-312.pyc index b681092da185760b7a6dc0207062ff1ab1bb3ea8..f8172f67ddca9266ff28dfa131c5f0f34e6fba1a 100644 GIT binary patch delta 1159 zcmZWoO>7%Q6rS1j?yUcI?Ko~-I|)v0lDG}AQqm;OPf=^?luEz>2dPr4#`0RqT8{0` zZb(wO*cAwZ1cGP|O?x3oNVKXdfr1clfszXn9PBuf-Gv*f#4$?wkpLlPHjR2L^0h zNq!PS&Ucsj3t$OGfYCvq7=kX+Eq?5^;Zh-I0#nlT%Ncb+E0VexahPJUgcmY}n1lQ- zMr2da4Xl+-p-@`NVG=-HFhowGUHOf8)s?yw__G_xq18)&|JDOs{yhMN-~;8z zE)PeA;9q*2CUq;AjDydk$q0Wxz{LB{o{rOHD!y)p8bW9kaN`gF;rlmiqbORWG%sIM9${vkkxWXIMxIgmZf%Ig}+sT^X zQ)~yV+TZc`0KfNt%_m-=TLCJnfY$)?U6EpTQnW|Z^i?6ADQj_4Je|$v$_DPDL9Ti- z<#IvGW(=)VjOX=IF%=WBKp7Lw=Q3&z>)6dSTRCIQn-1N`U_)QhjCmZP<`^`^jCGpM z-e8#Avj?ifR60eco~2L&KO_0=>h)?11sf<>4?WXFL-iA9w$Yik;$vLpdZmSW8mMP| zX$SQ)qwIgb^iHWI_cr9-je(|od|MvAja%;VhI_o}o~WhT=*ar34HTiyZKnsvXq@ch zKL7xE-SyJuiw*?gFqw7Ek*Xq)ubs0U%nS1r?r?aD$f$%ULU?&NhbG zsNn#kr^yAS56*7Zl%ufEx-^cO=fW0i%GRg4jB{5OHJnrN^OQ@I?>#hE^2n1;Si#xr z!d7am6wBuhu33y5^iWuR6A=q|UE$E;ibF2zt;L(^#|JaN!4?tGPgP z(2LT8i}RupR1k=ikQmrQ1lnU5GlQn|QWQNz5Gx2Wq;m$*f%E&H|L@2Dle6UAQl(d- z$W!=yeEcQt71pF-h8>)oNRQ})XofiichR)rGYxI%o3R4T@@8H)qzu!{WxAIll*RN2 zkZ6j^0NnvfoQ?g*W$6?*22y5p(o`u7W|)3z3aHeCgp#I8z@r({LkpMI6Uo$QdTieS z$44~O!m6~7LkU(zIo1u_Xq64rD>#2OzWNIcS?UvSTL{bta_L=O$wv1CsbD$!otDrc zduDb~z2{x>-uJ(CH5TNGMLE}%FLM_x_JyhWsob?&vu`XzPof*~wfM7hyTP;hVBdpK z{(N%IP_W3k-nr{}&iTthNtJUK4t|zXWsV{RjIKSLp27CF-h}JlaYe$^)7nTYv*~QY z&n-4Uu+_qr*>L5ctsdn><9fp80iXljuqz;hsw5}cu$SXT zUfcV+6_SW7urxR%eZmj{AA$di;<`u2QVl O#{tKWav3;Ev3~*bq{$cn diff --git a/backend/utils/auth.py b/backend/utils/auth.py index d81fff1..772cd13 100644 --- a/backend/utils/auth.py +++ b/backend/utils/auth.py @@ -42,9 +42,13 @@ def get_current_admin_role(): ) row = cur.fetchone() conn.close() - if not row or not row.get('is_admin'): - return None, row - role = row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin') + if not row: + return None, None + role = (row.get('role') or '').strip().lower() + if not role: + role = 'super_admin' if row.get('is_admin') and row.get('created_by_id') is None else ( + 'admin' if row.get('is_admin') else 'normal' + ) return role, row except Exception: return None, None @@ -56,7 +60,14 @@ def is_current_user_admin(): def _is_ajax_request(): - return request.headers.get('X-Requested-With') == 'XMLHttpRequest' + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return True + if request.path.startswith('/api/'): + return True + accept = (request.headers.get('Accept') or '').lower() + if 'application/json' in accept: + return True + return False def login_required(f): diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 2aa0b12..875370d 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -769,10 +769,10 @@ -
- +
+
-
请选择国家后输入对应 ASIN
+
请选择国家后输入对应 ASIN 和最低价
@@ -966,7 +966,7 @@ -
可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。
+
可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。
@@ -1034,6 +1034,35 @@
+ +