199 Commits

Author SHA1 Message Date
super
1e087c1aae 提交登录修改 2026-05-23 18:32:45 +08:00
super
2e2de02476 Merge gitee/master into local master (sync from old repo)
# Conflicts:
#	app/web_source/admin.html
#	app/web_source/brand-旧.html
#	app/web_source/home.html
#	app/web_source/index.html
#	app/web_source/login.html
#	backend/web_source/login.html
#	web_source/templates_backup/login.html
2026-05-23 12:00:12 +08:00
super
b4b80cb572 更新这个货源 2026-05-22 09:41:31 +08:00
super
14cebf22c3 提交一些更改 2026-05-20 09:05:28 +08:00
super
01ecde45c5 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-05-15 16:06:15 +08:00
super
1dc2cac19a 修改完善这个专利部分 2026-05-15 16:06:12 +08:00
铭坤
01f4dbb83a modified: amazon/similar_asin.py 2026-05-15 11:33:02 +08:00
铭坤
fe692fcd1d modified: amazon/chrome_base.py
modified:   amazon/detail_spider.py
	modified:   amazon/match_action.py
	modified:   amazon/similar_asin.py
	deleted:    amazon/user_data/chrome_data/BrowserMetrics-spare.pma
	deleted:    amazon/user_data/chrome_data/BrowserMetrics/BrowserMetrics-69FAEF07-558.pma
	deleted:    amazon/user_data/chrome_data/Crashpad/metadata
	deleted:    amazon/user_data/chrome_data/Crashpad/settings.dat
2026-05-13 16:51:29 +08:00
super
18b0c2d211 软件前端更新 2026-05-12 23:25:12 +08:00
super
648e7d2f14 后台管理接口修复 后端BUG修复 2026-05-12 21:03:42 +08:00
super
289b1c67ce 提交货源采集更新 2026-05-10 01:00:08 +08:00
super
52095eb992 更新处理相关内容 2026-05-09 00:20:52 +08:00
super
a2d0bd3c1c Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-05-07 16:22:42 +08:00
super
683a3934fc 提交打包 2026-05-07 16:21:35 +08:00
铭坤
b0514c3e9a modified: amazon/price_match.py
modified:   config.py
2026-05-07 11:14:15 +08:00
铭坤
8be12ca9bc new file: requirements.txt 2026-05-07 11:00:44 +08:00
super
afeeb8327c 更新 2026-05-06 23:00:57 +08:00
super
4d2da94692 同步更新 2026-05-06 23:00:39 +08:00
super
b746812e23 更新外观模块 2026-05-06 22:41:26 +08:00
铭坤
3fbbf87c10 modified: .env
modified:   amazon/approve.py
	modified:   amazon/chrome_base.py
	modified:   amazon/detail_spider.py
	modified:   amazon/main.py
	modified:   amazon/price_match.py
	modified:   amazon/similar_asin.py
	modified:   amazon/tool.py
	new file:   amazon/user_data/chrome_data/BrowserMetrics-spare.pma
	new file:   amazon/user_data/chrome_data/BrowserMetrics/BrowserMetrics-69FAEF07-558.pma
2026-05-06 22:30:44 +08:00
super
c3810fdb7b 提交更新 2026-05-06 00:01:49 +08:00
super
b16c63225f 处理一些合并冲突 2026-05-05 14:11:35 +08:00
铭坤
45a45dc0fe Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin
modified:   amazon/detail_spider.py
	modified:   amazon/main.py
2026-05-04 19:17:11 +08:00
铭坤
c315bb350c new file: amazon/amazon_base.py
modified:   amazon/approve.py
	modified:   amazon/asin_status.py
	new file:   amazon/chrome_base.py
	modified:   amazon/del_brand.py
	modified:   amazon/detail_spider.py
	new file:   "amazon/detail_spider_\345\244\207\344\273\275.py"
	modified:   amazon/main.py
	modified:   amazon/match_action.py
	modified:   amazon/price_match.py
	new file:   amazon/similar_asin.py
	modified:   app.py
	new file:   assets/appearance-patent-Br1dtmol.css
	new file:   assets/appearance-patent-D1DgeYhe.css
	new file:   assets/delete-brand-BfMLVSQU.css
	new file:   assets/patrol-delete-BAzbYtgc.css
	new file:   assets/price-track-Hozgt_em.css
	new file:   assets/product-risk-CbX7bwZi.css
	new file:   assets/query-asin-B4RsOiza.css
	new file:   assets/shop-match-B2HWAgBY.css
	modified:   main.py
2026-05-04 19:15:50 +08:00
super
6d398b66bc 异常记录检测修改 2026-05-04 19:04:55 +08:00
super
934919c699 稳定组成这个文件优化 2026-05-02 16:40:13 +08:00
super
12d84a22e4 更新 2026-05-02 09:31:14 +08:00
super
dc457aff9e Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-05-02 09:29:43 +08:00
super
578847d596 提交更新 2026-05-02 09:28:26 +08:00
koko
6416c14f20 chore: serve bundled assets from app directory 2026-05-02 02:12:14 +08:00
koko
add6e6aeb5 chore: remove stale web assets 2026-05-02 02:07:13 +08:00
koko
7968cef8c3 fix: update patrol delete progress handling 2026-05-02 02:03:49 +08:00
koko
34148b5d8a Merge patrol delete tests and remove pycache artifacts 2026-05-01 22:46:11 +08:00
koko
79e408333d Merge branch 'dev/koko'
# Conflicts:
#	app/amazon/patrol_delete.py
2026-05-01 22:41:09 +08:00
koko
74a7cd22b0 Fix patrol delete condition handling 2026-05-01 22:36:37 +08:00
super
e1cca219b8 完善多店铺开启多窗口、外观进度条等 2026-05-01 21:09:54 +08:00
super
4419c5bacd 更新处理这个外观部分 2026-05-01 00:09:14 +08:00
铭坤
4eec0dd5a4 modified: amazon/__pycache__/price_match.cpython-39.pyc
modified:   amazon/price_match.py
	modified:   assets/appearance-patent.js
	modified:   assets/delete-brand.js
	modified:   assets/patrol-delete.js
	modified:   assets/price-track.js
	modified:   assets/product-risk.js
	modified:   assets/query-asin.js
	modified:   assets/shop-match.js
	modified:   new_web_source/delete-brand.html
2026-04-30 21:57:37 +08:00
koko
8e60f50616 Merge origin master into local master 2026-04-30 21:46:00 +08:00
koko
f85e3be40e Harden patrol delete logging 2026-04-30 21:43:15 +08:00
koko
7f9116f12a Merge patrol delete changes into master 2026-04-30 21:40:35 +08:00
super
11f0d2c745 修复商品风险多店铺问题 2026-04-30 21:31:11 +08:00
super
955a6439a0 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-30 11:38:13 +08:00
super
172917ac41 优化后台业务 2026-04-30 11:36:58 +08:00
铭坤
a5f6898d06 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin
app/amazon/__pycache__/approve.cpython-39.pyc
	app/amazon/__pycache__/asin_status.cpython-39.pyc
	app/amazon/__pycache__/del_brand.cpython-39.pyc
	app/amazon/__pycache__/match_action.cpython-39.pyc
	app/amazon/__pycache__/price_match.cpython-39.pyc
2026-04-30 11:00:02 +08:00
铭坤
83bc90ecf2 modified: amazon/__pycache__/approve.cpython-39.pyc
modified:   amazon/__pycache__/asin_status.cpython-39.pyc
	modified:   amazon/__pycache__/del_brand.cpython-39.pyc
	modified:   amazon/__pycache__/match_action.cpython-39.pyc
	modified:   amazon/__pycache__/price_match.cpython-39.pyc
	modified:   amazon/approve.py
	modified:   amazon/asin_status.py
	modified:   amazon/del_brand.py
	modified:   amazon/match_action.py
	modified:   amazon/price_match.py
	modified:   main.py
2026-04-30 10:52:52 +08:00
koko
cbfc29e953 Remove ts script 2026-04-28 22:41:56 +08:00
koko
b66465aa6a chore: update env 2026-04-28 22:40:39 +08:00
koko
fbd1a4dbea Remove README from master 2026-04-28 22:39:38 +08:00
koko
be8a8492a9 Remove tracked .rtk config 2026-04-28 22:38:17 +08:00
koko
b352eea21d Stop tracking remaining Python bytecode on master
Master still tracked a small set of generated __pycache__ artifacts after merging dev/koko. The ignore rules already match dev/koko, so this commit only removes the remaining bytecode files from the Git index while leaving local files on disk.

Constraint: dev/koko .gitignore already contains recursive __pycache__ ignore coverage

Rejected: Delete local cache directories | repository tracking cleanup only requires --cached removal

Confidence: high

Scope-risk: narrow

Tested: git ls-files shows no remaining paths containing __pycache__/

Not-tested: Application runtime; generated bytecode tracking only
2026-04-28 22:33:27 +08:00
koko
51bf0aaa8c Merge dev/koko into master preserving master conflict behavior
Conflict resolution kept master-side changes for non-patrol-delete files as requested. No app/amazon/patrol_delete.py conflict was present. Existing dev/koko assets were retained so /assets resources required by new_web_source pages resolve in the Flask app.

Constraint: User requested authentication-requiring git operations be reported as commands only

Rejected: Rework non-conflicting dev/koko changes during merge | scope was conflict resolution, not feature review

Confidence: high

Scope-risk: moderate

Tested: uv run python -c Flask test client returned 200 for /new_web_source/patrol-delete.html, /assets/patrol-delete.js, /assets/pywebview-C66x_2Dh.js

Tested: uv run python -m py_compile amazon\\patrol_delete.py amazon\\detail_spider.py amazon\\price_match.py main.py

Tested: uv run --group dev pytest tests\\test_patrol_delete.py tests\\test_amazon_base.py

Not-tested: authenticated Git push/pull; Java backend build
2026-04-28 22:31:38 +08:00
koko
28a752265c fix: brand html 2026-04-28 22:14:41 +08:00
koko
1d9d138423 fix: update frontend 2026-04-28 21:42:30 +08:00
铭坤
8c5edc6ad6 new file: assets/_plugin-vue_export-helper-Dh4yMZWw.js
new file:   assets/_plugin-vue_export-helper-xvHHTGU_.css
	new file:   assets/appearance-patent-BNTrbLJh.css
	new file:   assets/appearance-patent-BPCrG75P.css
	new file:   assets/appearance-patent.js
	new file:   assets/brand-1nUVsWrm.js
	new file:   assets/brand-5-W0Wonr.js
	new file:   assets/brand-BATjYfEL.js
	new file:   assets/brand-BZije8D7.js
	new file:   assets/brand-C0Q5LZ0P.js
	new file:   assets/brand-CFZZtTv8.js
	new file:   assets/brand-CKgrwtni.js
	new file:   assets/brand-CRmRvyGD.js
	new file:   assets/brand-CXHemZwJ.js
	new file:   assets/brand-DBpLCgAF.js
	new file:   assets/brand-DRn3N4FA.js
	new file:   assets/brand-DZ5_PGnw.js
	new file:   assets/brand-DysFlQbZ.js
	new file:   assets/brand-UU-ckLq6.js
	new file:   assets/convert-1dkFPbKa.css
	new file:   assets/convert-6b0yq-M0.css
	new file:   assets/convert-7wWJ02Tw.css
	new file:   assets/convert-BKSNvX8i.css
	new file:   assets/convert-BxkpMHO9.css
	new file:   assets/convert-CQ22EBpm.css
	new file:   assets/convert-Cfr3mUjs.css
	new file:   assets/convert-D4Yv2oRy.css
	new file:   assets/convert-DwbQug2y.css
	new file:   assets/dedupe-B0eqpVpj.css
	new file:   assets/dedupe-BHE_BCAJ.css
	new file:   assets/dedupe-BpNHwt51.css
	new file:   assets/dedupe-CS18ls2z.css
	new file:   assets/dedupe-DNlVfFj-.css
	new file:   assets/dedupe-Day_nGxq.css
	new file:   assets/dedupe-DyzjKbsT.css
	new file:   assets/dedupe-DzFvTfoj.css
	new file:   assets/dedupe-rhGu1E8E.css
	new file:   assets/delete-brand-BNvh4ru4.css
	new file:   assets/delete-brand-BP3XWKAC.css
	new file:   assets/delete-brand-BRIDBaoM.css
	new file:   assets/delete-brand-BcZomrN3.css
	new file:   assets/delete-brand-BgPrpPsj.css
	new file:   assets/delete-brand-ByeMdpEk.css
	new file:   assets/delete-brand-C-Rj3NMH.css
	new file:   assets/delete-brand-CDZTH98q.css
	new file:   assets/delete-brand-CS44Bk-y.css
	new file:   assets/delete-brand-CWLpe7lu.css
	new file:   assets/delete-brand-CdxGWtvK.css
	new file:   assets/delete-brand-Chu8awlt.css
	new file:   assets/delete-brand-CndjrFFS.css
	new file:   assets/delete-brand-CpNTe9WM.css
	new file:   assets/delete-brand-D7t16Ukk.css
	new file:   assets/delete-brand-DE0hLEak.css
	new file:   assets/delete-brand-DLyg-H8J.css
	new file:   assets/delete-brand-DO6pdlC3.css
	new file:   assets/delete-brand-DXSeQxXc.css
	new file:   assets/delete-brand-DZ6zYt8o.css
	new file:   assets/delete-brand-DfRLfrtm.css
	new file:   assets/delete-brand-Dl7T0pmm.css
	new file:   assets/delete-brand-Dt_MS9as.css
	new file:   assets/delete-brand-DvND7vBL.css
	new file:   assets/delete-brand-_jGFWTAn.css
	new file:   assets/el-alert-Ct0RpqUD.css
	new file:   assets/el-input-CezJelDw.css
	new file:   assets/el-input-xaztPnzw.css
	new file:   assets/el-table-column-Cy4YJvw0.css
	new file:   assets/listingFilters-B6-M_FNM.js
	new file:   assets/listingFilters-BmlAzw7M.css
	new file:   assets/listingFilters-BpGOU_pJ.js
	new file:   assets/listingFilters-CK58rX4v.css
	new file:   assets/patrol-delete-BPu1AAPM.css
	new file:   assets/patrol-delete-BctopIU4.css
	new file:   assets/patrol-delete-CSekhzSS.css
	new file:   assets/patrol-delete-DIvFZUTh.css
	new file:   assets/patrol-delete.js
	new file:   assets/price-track-1SjcUfP4.css
	new file:   assets/price-track-85NbckiJ.css
	new file:   assets/price-track-B5FVfJb_.css
	new file:   assets/price-track-BO5PtL0W.css
	new file:   assets/price-track-BccJT4Qr.css
	new file:   assets/price-track-BlSD-0un.css
	new file:   assets/price-track-BlVVl2eV.css
	new file:   assets/price-track-BwgEuRxe.css
	new file:   assets/price-track-CWQD_A7D.css
	new file:   assets/price-track-DNTcdRH8.css
	new file:   assets/price-track-Dv4EZPhf.css
	new file:   assets/price-track-Ji5JAoeR.css
	new file:   assets/price-track-RLJUgMl_.css
	new file:   assets/price-track-z8RU_FlW.css
	new file:   assets/price-track.js
	new file:   assets/product-risk-1G_ztqNY.css
	new file:   assets/product-risk-A5bhfaTH.css
	new file:   assets/product-risk-BBMcuGAw.css
	new file:   assets/product-risk-C3Yn2wAL.css
	new file:   assets/product-risk-CG-1Iq4P.css
	new file:   assets/product-risk-COeM91v5.css
	new file:   assets/product-risk-ClWs7d57.css
	new file:   assets/product-risk-CtpNfape.css
	new file:   assets/product-risk-DBsBE_mg.css
	new file:   assets/product-risk-DRYaWmI2.css
	new file:   assets/product-risk-Db49F4J5.css
	new file:   assets/product-risk-DoO8ZHM0.css
	new file:   assets/product-risk-DpSMKBCA.css
	new file:   assets/product-risk-DqFPFWEv.css
	new file:   assets/product-risk-cfL3Ch5-.css
	new file:   assets/product-risk-eXMv239F.css
	new file:   assets/product-risk-un2cxu--.css
	new file:   assets/product-risk.js
	new file:   assets/pywebview-3PePjYSu.js
	new file:   assets/pywebview-8G_9KMXd.css
	new file:   assets/pywebview-9YBa--7x.js
	new file:   assets/pywebview-B6ZNXusn.css
	new file:   assets/pywebview-BCPdlDdb.js
	new file:   assets/pywebview-BLoeiUcF.css
	new file:   assets/pywebview-BRrok0pS.js
	new file:   assets/pywebview-BT0f6KST.css
	new file:   assets/pywebview-BbIq-QPS.css
	new file:   assets/pywebview-Bc09DixN.js
	new file:   assets/pywebview-Bl6M97uH.js
	new file:   assets/pywebview-BnpJyyGZ.js
	new file:   assets/pywebview-Bt854mYs.js
	new file:   assets/pywebview-C5RGB8QW.css
	new file:   assets/pywebview-C66x_2Dh.js
	new file:   assets/pywebview-C8Bik-Sc.js
	new file:   assets/pywebview-CGwF8TuM.js
	new file:   assets/pywebview-CJLylbJu.js
	new file:   assets/pywebview-CeWJDVeG.js
	new file:   assets/pywebview-Chz98E9c.css
	new file:   assets/pywebview-ClWy2SbE.js
	new file:   assets/pywebview-Cq_E2BnJ.js
	new file:   assets/pywebview-Cs1Kot1q.js
	new file:   assets/pywebview-D-PKWjUg.js
	new file:   assets/pywebview-D-mMH8F6.css
	new file:   assets/pywebview-D4gpiFjY.js
	new file:   assets/pywebview-D74jYk6P.css
	new file:   assets/pywebview-D7_PdvyM.js
	new file:   assets/pywebview-D808cNhy.js
	new file:   assets/pywebview-DVgP2-kW.js
	new file:   assets/pywebview-Dee3nQjE.js
	new file:   assets/pywebview-DiP0HdY6.js
	new file:   assets/pywebview-Dp5dN8OO.css
	new file:   assets/pywebview-DuyK2jB1.js
	new file:   assets/pywebview-IqgMfeBe.css
	new file:   assets/pywebview-MkjZQlBu.css
	new file:   assets/pywebview-Q4glvYu5.css
	new file:   assets/pywebview-Rm5Hhqpf.js
	new file:   assets/pywebview-V_UIajJm.js
	new file:   assets/pywebview-hj_EXwAP.css
	new file:   assets/pywebview-ij4pgMq8.css
	new file:   assets/pywebview-leZCedEt.js
	new file:   assets/query-asin-B0yMJ9Or.css
	new file:   assets/query-asin-B5e5m-vs.css
	new file:   assets/query-asin-Bv7Jtgqa.css
	new file:   assets/query-asin-DZwJfwHU.css
	new file:   assets/query-asin.js
	new file:   assets/shop-match-Banz7DWL.css
	new file:   assets/shop-match-BiZP8IGM.css
	new file:   assets/shop-match-BrrGkYV7.css
	new file:   assets/shop-match-CVFsXUvd.css
	new file:   assets/shop-match-CfGJEHz0.css
	new file:   assets/shop-match-CfGWwDtw.css
	new file:   assets/shop-match-D0DGETCs.css
	new file:   assets/shop-match-DFEKOpvV.css
	new file:   assets/shop-match-DNFA8ceR.css
	new file:   assets/shop-match-DhT1CvMP.css
	new file:   assets/shop-match-L0xHTaQ2.css
	new file:   assets/shop-match-MsxXMG_G.css
	new file:   assets/shop-match-g3eRKhTP.css
	new file:   assets/shop-match.js
	new file:   assets/split-BoBVrLdC.css
	new file:   assets/split-BsJfbBqn.css
	new file:   assets/split-CRUIYKS6.css
	new file:   assets/split-DAa_Usoh.css
	new file:   assets/split-DidKJ84l.css
	new file:   assets/split-RNZYJ-zZ.css
	new file:   assets/split-R_8V5xgh.css
	new file:   assets/split-_O6NHqDj.css
	new file:   assets/split-mwLUJT1K.css
	new file:   assets/zh-cn-BjWx61Lu.js
	new file:   assets/zh-cn-C0iS6aoJ.js
	new file:   assets/zh-cn-CuE9Zzgz.js
	new file:   assets/zh-cn-DyKmB71i.js
	new file:   assets/zh-cn-Z9PBnl-n.js
2026-04-28 21:36:49 +08:00
铭坤
ee6fb5d33a modified: amazon/__pycache__/detail_spider.cpython-39.pyc
modified:   amazon/__pycache__/price_match.cpython-39.pyc
	modified:   amazon/detail_spider.py
	modified:   amazon/price_match.py
	modified:   blueprints/__pycache__/admin.cpython-39.pyc
	modified:   blueprints/__pycache__/brand.cpython-39.pyc
	modified:   blueprints/__pycache__/main.cpython-39.pyc
	modified:   brand_spider/__pycache__/main.cpython-39.pyc
	modified:   main.py
	modified:   web_source/brand.html
2026-04-28 21:11:48 +08:00
koko
cd2b437be5 Stop tracking generated Python bytecode
Generated __pycache__ files were already ignored in some paths but remained tracked in the repository, which caused merge noise and binary conflicts. This removes the tracked bytecode from the index and adds an explicit recursive ignore rule so future Python runs do not reintroduce them.

Constraint: Existing repository history already tracked pycache files across app, backend, and source_code

Rejected: Delete local pycache directories from disk | only repository tracking needed to be removed

Confidence: high

Scope-risk: narrow

Directive: Do not force-add __pycache__ or *.pyc files unless there is a documented runtime packaging reason

Tested: git ls-files shows no remaining paths containing __pycache__/

Not-tested: Application runtime; change only affects tracked generated artifacts and ignore rules
2026-04-28 20:57:53 +08:00
koko
a504e7a13b Merge master into dev/koko after patrol delete fixes
The merge keeps dev/koko patrol-delete asset fallback behavior while accepting the latest master feature work. Conflicts were limited to ignore rules, local environment/config artifacts, tracked pycache binaries, and the Flask main blueprint.

Constraint: master and dev/koko both edited app/blueprints/main.py around brand and asset serving

Rejected: Drop dev/koko asset fallback logic | patrol delete and rebuilt Vite assets still need hashed asset resolution across app/new_web_source locations

Confidence: medium

Scope-risk: broad

Directive: app/.env remains a tracked local-config file in this repository; do not print or normalize secrets during conflict handling

Tested: uv run python -m py_compile blueprints\\main.py amazon\\base.py amazon\\main.py

Tested: uv run --group dev pytest tests\\test_amazon_base.py tests\\test_patrol_delete.py

Not-tested: Full backend-java/frontend-vue test suites
2026-04-28 20:55:27 +08:00
koko
325687d532 Stabilize patrol delete automation against delayed Amazon UI
The patrol-delete flow now waits for country and listing-status controls before acting, uses the dedicated PatrolDeleteTask entry point, and includes the browser helper scripts required by the bulk-delete path. Tests cover delayed dropdown readiness, payload rejection/retry behavior, complete-draft normalization, filtered bulk deletion, and asset fallback lookup.

Constraint: Amazon listing UI exposes status controls through dynamic KAT components and delayed option rendering

Rejected: Keep row-by-row delete flow | bulk selection is the current implemented path and is covered by the added helper scripts

Confidence: high

Scope-risk: moderate

Directive: Do not remove the patrol_delete JS helper files without checking app/amazon/patrol_delete.py script loading

Tested: uv run --group dev pytest tests\\test_amazon_base.py tests\\test_patrol_delete.py

Tested: uv run python -m py_compile amazon\\base.py amazon\\main.py blueprints\\main.py

Not-tested: Real Amazon Seller Central browser session
2026-04-28 20:46:50 +08:00
super
9760d1171c 提交跟新 2026-04-28 17:16:04 +08:00
super
d42ed57119 提交暂存 2026-04-28 16:57:05 +08:00
super
18208f691c 更新内容 2026-04-28 16:56:53 +08:00
super
c06a4d0e06 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-28 15:33:42 +08:00
super
f21934e55b 合并错误信息 2026-04-28 15:25:34 +08:00
铭坤
515135c120 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-28 15:23:55 +08:00
铭坤
42bdd59c71 modified: .env
modified:   amazon/__pycache__/approve.cpython-39.pyc
	modified:   amazon/__pycache__/detail_spider.cpython-39.pyc
	modified:   amazon/__pycache__/price_match.cpython-39.pyc
	modified:   amazon/approve.py
	modified:   amazon/detail_spider.py
	modified:   amazon/price_match.py
	modified:   assets/convert.js
	modified:   assets/dedupe.js
	modified:   assets/delete-brand.js
	modified:   assets/split.js
	modified:   blueprints/__pycache__/admin.cpython-39.pyc
	modified:   blueprints/__pycache__/brand.cpython-39.pyc
	modified:   blueprints/__pycache__/main.cpython-39.pyc
	modified:   blueprints/main.py
	modified:   brand_spider/__pycache__/main.cpython-39.pyc
	modified:   new_web_source/convert.html
	modified:   new_web_source/dedupe.html
	modified:   new_web_source/delete-brand.html
	modified:   new_web_source/split.html
	modified:   tool/__pycache__/devices.cpython-39.pyc
2026-04-28 15:21:57 +08:00
koko
8be85fd332 Expose patrol delete progress in logs
Patrol delete can spend a long time switching countries, reading listing states, and confirming deletes, so the flow now emits concise progress logs around each high-value step. The logs avoid full DOM dumps outside existing diagnostics and focus on task, shop, country, status, deletion counts, and callback boundaries.

Constraint: Existing patrol delete runs are browser-driven and need observable progress without changing deletion behavior
Rejected: Log every DOM probe | too noisy for routine task monitoring
Confidence: high
Scope-risk: narrow
Tested: uv run --group dev pytest tests\\test_patrol_delete.py
Tested: uv run --group dev ruff check amazon\\patrol_delete.py
Tested: uv run python -m py_compile amazon\\patrol_delete.py
Not-tested: Live Seller Central browser run
2026-04-28 01:23:58 +08:00
super
e4bf104ae2 架构更改完成 2026-04-27 18:45:56 +08:00
super
5105bf7049 增加rufts 2026-04-27 18:45:33 +08:00
koko
08997e9e20 Rename patrol delete tests
Keep the patrol deletion test module aligned with the renamed patrol_delete implementation and update test function names so future searches no longer point at the old product module name.

Constraint: This is a naming-only follow-up to the patrol_delete module rename

Confidence: high

Scope-risk: narrow

Tested: uv run pytest tests/test_patrol_delete.py
2026-04-27 17:59:46 +08:00
koko
9146d17625 Align patrol delete module naming
Rename the patrol deletion implementation and browser helper scripts away from the generic product name so the filesystem matches the queued patrol-delete task domain. Update imports, script loading, and tests to use the patrol_delete module path.

Constraint: Existing task class remains ProductTask to avoid broad API churn beyond the requested file and script naming

Rejected: Rename ProductTask class now | would expand the change into a larger API update across tests and task dispatch

Confidence: high

Scope-risk: narrow

Directive: Keep patrol-delete script paths under app/amazon/scripts/patrol_delete when adding new helper scripts

Tested: uv run pytest tests/test_product.py

Not-tested: Remote push before this commit due previous SSH publickey failure
2026-04-27 17:58:40 +08:00
koko
1aca3d6152 Bring Amazon collection tasks into dev/koko
Merge origin/master into dev/koko while preserving the patrol delete task added on dev/koko. The task dispatcher now recognizes both patrol deletion and appearance patent collection, and the master-side SpiderTask implementation is included.

Constraint: origin fetch and push over SSH are blocked in this environment by Gitee publickey authentication

Rejected: Overwrite dev/koko with master | would drop the patrol delete branch work

Confidence: medium

Scope-risk: moderate

Directive: Do not remove either patrol-delete-run or appearance-patent-run without checking queued task producers

Tested: python -m py_compile app/amazon/approve.py app/amazon/asin_status.py app/amazon/detail_spider.py app/amazon/main.py app/amazon/match_action.py app/amazon/price_match.py

Not-tested: Remote push, blocked by SSH publickey authentication
2026-04-27 17:36:30 +08:00
super
052bb31aea Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-27 09:20:04 +08:00
super
225b525ba1 后端架构更新 2026-04-27 09:18:10 +08:00
koko
e1543416e3 feat: patrol delete 2026-04-27 00:23:44 +08:00
铭坤
0b57aea5f6 modified: amazon/__pycache__/approve.cpython-39.pyc
modified:   amazon/__pycache__/asin_status.cpython-39.pyc
	new file:   amazon/__pycache__/detail_spider.cpython-39.pyc
	modified:   amazon/__pycache__/main.cpython-39.pyc
	modified:   amazon/__pycache__/match_action.cpython-39.pyc
	modified:   amazon/__pycache__/price_match.cpython-39.pyc
	modified:   amazon/approve.py
	modified:   amazon/asin_status.py
	new file:   amazon/detail_spider.py
	modified:   amazon/main.py
	modified:   amazon/match_action.py
	modified:   amazon/price_match.py
	modified:   assets/convert.js
	modified:   assets/dedupe.js
	modified:   assets/delete-brand.js
	modified:   assets/split.js
	modified:   new_web_source/convert.html
	modified:   new_web_source/dedupe.html
	modified:   new_web_source/delete-brand.html
	modified:   new_web_source/split.html
	modified:   web_source/brand.html
2026-04-27 00:21:37 +08:00
koko
a04c2f9a19 feat: update amazon driver 2026-04-25 17:59:12 +08:00
koko
c352c34501 feat: update amazon driver 2026-04-25 17:58:28 +08:00
koko
5c9671df44 feat: update driver BaseClass 2026-04-25 17:14:23 +08:00
koko
8a8d3c5cd9 Merge branch 'master' into dev/koko 2026-04-25 15:47:02 +08:00
铭坤
d272afae1c modified: amazon/__pycache__/approve.cpython-39.pyc
new file:   amazon/__pycache__/asin_status.cpython-39.pyc
	modified:   amazon/__pycache__/main.cpython-39.pyc
	modified:   amazon/__pycache__/match_action.cpython-39.pyc
	modified:   amazon/__pycache__/price_match.cpython-39.pyc
	modified:   amazon/approve.py
	modified:   amazon/asin_status.py
	modified:   amazon/main.py
	modified:   amazon/match_action.py
	modified:   amazon/price_match.py
	modified:   web_source/brand.html
	modified:   web_source/templates_backup/brand.html
2026-04-24 23:00:59 +08:00
super
03e697f5d3 后台查询Asin文档更新 2026-04-24 15:14:14 +08:00
super
c502afb588 完成查询asin开发 2026-04-23 20:48:57 +08:00
koko
3fada5d198 Merge branch 'master' into dev/koko 2026-04-23 11:49:50 +00:00
super
c0fdea6570 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-23 15:25:49 +08:00
super
0391cb223f 完成后端架构重构等 2026-04-23 15:25:41 +08:00
koko
b25111e4b4 Merge remote-tracking branch 'origin/master' into dev/koko 2026-04-23 03:16:15 +00:00
铭坤
2f2db4986e modified: app/amazon/__pycache__/approve.cpython-39.pyc
modified:   app/amazon/__pycache__/del_brand.cpython-39.pyc
	modified:   app/amazon/__pycache__/match_action.cpython-39.pyc
	modified:   app/amazon/__pycache__/price_match.cpython-39.pyc
	modified:   app/amazon/approve.py
	modified:   app/amazon/del_brand.py
	modified:   app/amazon/match_action.py
	modified:   app/amazon/price_match.py
	modified:   app/assets/convert.js
	modified:   app/assets/dedupe.js
	modified:   app/assets/delete-brand.js
	modified:   app/assets/split.js
	modified:   app/new_web_source/convert.html
	modified:   app/new_web_source/dedupe.html
	modified:   app/new_web_source/delete-brand.html
	modified:   app/new_web_source/split.html
2026-04-23 10:41:07 +08:00
koko
6f970b3783 Clarify the current automation example in onboarding docs
Update the README so new contributors start from the latest patrol-delete automation flow instead of treating delete-brand as the primary example. Keep the older delete-brand path as contrast for the file-driven workflow.

Constraint: Recent repository changes made patrol-delete the newest automation module, so the onboarding example needed to match current development reality
Rejected: Leave delete-brand as the primary example | would mislead readers about the latest automation entry point
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Revisit this section when a newer automation module replaces patrol-delete as the primary example
Tested: README diff review
Not-tested: lint, typecheck, unit/integration tests not run (documentation-only change)
2026-04-22 08:33:02 +00:00
铭坤
95d5c82474 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-22 15:58:46 +08:00
铭坤
87507708ce new file: admin.html
new file:   "brand - \345\211\257\346\234\254.html"
	new file:   "brand-\346\227\247.html"
	new file:   brand.html
	new file:   home.html
	new file:   index.html
	new file:   login.html
2026-04-22 15:57:56 +08:00
koko
c6ae7ca170 Document repo structure and ignore local OMX state
Add a root README that explains the active runtime layers and onboarding path, and keep local OMX session state out of version control so developer tooling does not interfere with pulls.

Constraint: Local OMX state is developer-specific and should not block branch sync or appear in shared history
Rejected: Keep onboarding notes untracked | easy to lose and hard to share with the team
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the README aligned with the active app/backend-java/frontend-vue execution path; revisit if the runtime architecture changes
Tested: git diff review; git status after staging
Not-tested: lint, typecheck, unit/integration tests not run (docs and ignore rules only)
2026-04-22 07:16:04 +00:00
super
6894f9cc57 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-22 01:09:51 +08:00
super
524d8763ce 处理后台跳转错误 2026-04-22 01:09:47 +08:00
super
1e845a1510 处理后台管理系统、修复BUG、处理权限 2026-04-22 01:09:28 +08:00
铭坤
0341838d19 modified: amazon/__pycache__/approve.cpython-39.pyc
modified:   amazon/__pycache__/main.cpython-39.pyc
	modified:   amazon/__pycache__/match_action.cpython-39.pyc
	new file:   amazon/__pycache__/price_match.cpython-39.pyc
	modified:   amazon/__pycache__/tool.cpython-39.pyc
	modified:   amazon/approve.py
	new file:   amazon/asin_status.py
	modified:   amazon/main.py
	modified:   amazon/price_match.py
	new file:   "amazon/price_match_\346\227\247.py"
	modified:   amazon/tool.py
	modified:   assets/convert.js
	modified:   assets/dedupe.js
	modified:   assets/delete-brand.js
	modified:   assets/split.js
	modified:   new_web_source/convert.html
	modified:   new_web_source/dedupe.html
	modified:   new_web_source/delete-brand.html
	modified:   new_web_source/split.html
	deleted:    web_source/admin.html
	deleted:    "web_source/brand - \345\211\257\346\234\254.html"
	deleted:    "web_source/brand-\346\227\247.html"
	deleted:    web_source/brand.html
	deleted:    web_source/home.html
	deleted:    web_source/index.html
	deleted:    web_source/login.html
2026-04-22 00:51:41 +08:00
super
ea35273597 改造后端服务,处理后台管理新增组(部门概念) 2026-04-22 00:32:58 +08:00
super
72c8167472 更新跟价相关内容修改 2026-04-20 00:41:49 +08:00
super
9b1138c83e Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-19 15:47:37 +08:00
super
dc5e23892f 提交改造app权限 2026-04-19 15:47:20 +08:00
铭坤
4f8cbc3e38 new file: update/PyQt5/Qt/.keep_dir.txt
new file:   update/PyQt5/Qt5/.keep_dir.txt
	new file:   update/PyQt5/QtCore.pyd
	new file:   update/PyQt5/QtGui.pyd
	new file:   update/PyQt5/QtWidgets.pyd
	new file:   update/PyQt5/qt-plugins/iconengines/qsvgicon.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qgif.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qicns.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qico.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qjpeg.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qsvg.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qtga.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qtiff.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qwbmp.dll
	new file:   update/PyQt5/qt-plugins/imageformats/qwebp.dll
	new file:   update/PyQt5/qt-plugins/mediaservice/dsengine.dll
	new file:   update/PyQt5/qt-plugins/mediaservice/qtmedia_audioengine.dll
	new file:   update/PyQt5/qt-plugins/mediaservice/wmfengine.dll
	new file:   update/PyQt5/qt-plugins/platforms/qminimal.dll
	new file:   update/PyQt5/qt-plugins/platforms/qoffscreen.dll
	new file:   update/PyQt5/qt-plugins/platforms/qwebgl.dll
	new file:   update/PyQt5/qt-plugins/platforms/qwindows.dll
	new file:   update/PyQt5/qt-plugins/platformthemes/qxdgdesktopportal.dll
	new file:   update/PyQt5/qt-plugins/printsupport/windowsprintersupport.dll
	new file:   update/PyQt5/qt-plugins/styles/qwindowsvistastyle.dll
	new file:   update/PyQt5/sip.pyd
	new file:   update/_bz2.pyd
	new file:   update/_ctypes.pyd
	new file:   update/_decimal.pyd
	new file:   update/_elementtree.pyd
	new file:   update/_hashlib.pyd
	new file:   update/_hashlib.zip
	new file:   update/_lzma.pyd
	new file:   update/_socket.pyd
	new file:   update/libcrypto-1_1-x64.dll
	new file:   update/libeay32.dll
	new file:   update/libffi-7.dll
	new file:   update/msvcp140.dll
	new file:   update/msvcp140_1.dll
	new file:   update/psutil/_psutil_windows.pyd
	new file:   update/pyexpat.pyd
	new file:   update/python3.dll
	new file:   update/python39.dll
	new file:   update/qt5core.dll
	new file:   update/qt5dbus.dll
	new file:   update/qt5gui.dll
	new file:   update/qt5multimedia.dll
	new file:   update/qt5network.dll
	new file:   update/qt5printsupport.dll
	new file:   update/qt5qml.dll
	new file:   update/qt5qmlmodels.dll
	new file:   update/qt5quick.dll
	new file:   update/qt5svg.dll
	new file:   update/qt5websockets.dll
	new file:   update/qt5widgets.dll
	new file:   update/select.pyd
	new file:   update/ssleay32.dll
	new file:   update/unicodedata.pyd
	new file:   update/update.exe
	new file:   update/vcruntime140.dll
	new file:   update/vcruntime140_1.dll
2026-04-19 15:39:46 +08:00
铭坤
3ffbb2b004 modified: ali_oss.py
modified:   config.py
	modified:   main.py
	new file:   winsrc.bat
2026-04-19 15:38:09 +08:00
super
34ed15a9bd Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-19 15:06:13 +08:00
super
4b6295dd44 改造后台权限和APP权限 2026-04-19 15:06:09 +08:00
supernijia
01116d5607 删除文件 desktop 2026-04-18 16:23:13 +00:00
铭坤
07904a29aa modified: amazon/match_action.py
modified:   amazon/price_match.py
	modified:   config.py
2026-04-19 00:18:28 +08:00
铭坤
8d683a791d Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-19 00:18:02 +08:00
super
ef0e0df0ac 完成匹配、跟价、权限部分 2026-04-17 12:52:16 +08:00
铭坤
ac07416352 modified: amazon/__pycache__/approve.cpython-39.pyc
modified:   amazon/__pycache__/del_brand.cpython-39.pyc
	modified:   amazon/__pycache__/match_action.cpython-39.pyc
	modified:   amazon/approve.py
	modified:   amazon/del_brand.py
	modified:   amazon/match_action.py
	new file:   amazon/price_match.py
	modified:   main.py
2026-04-17 10:04:24 +08:00
super
025ca6d4fd 后台管理跳过asin、后台相关数据做数据隔离 2026-04-14 10:04:50 +08:00
951a353881 提交任务更新 2026-04-13 20:07:00 +08:00
super
eb04caccf1 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-13 13:02:45 +08:00
super
cd84def61d 提交工作更新 2026-04-13 13:02:41 +08:00
铭坤
d8098b0378 modified: app/amazon/__pycache__/del_brand.cpython-39.pyc
modified:   app/amazon/__pycache__/match_action.cpython-39.pyc
	modified:   app/amazon/del_brand.py
	modified:   app/amazon/match_action.py
	modified:   app/assets/delete-brand.js
	modified:   app/new_web_source/delete-brand.html
	new file:   "app/web_source/brand - \345\211\257\346\234\254.html"
2026-04-12 20:52:26 +08:00
super
62d30ec190 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-12 17:15:54 +08:00
super
d0d3c6ee67 完成菜单权限添加和软件菜单改造 2026-04-12 17:15:15 +08:00
铭坤
21b6b5b270 modified: app/amazon/__pycache__/approve.cpython-39.pyc
modified:   app/amazon/__pycache__/del_brand.cpython-39.pyc
	modified:   app/amazon/__pycache__/main.cpython-39.pyc
	modified:   app/amazon/__pycache__/match_action.cpython-39.pyc
	modified:   app/amazon/__pycache__/tool.cpython-39.pyc
	modified:   app/amazon/approve.py
	modified:   app/amazon/del_brand.py
	modified:   app/amazon/main.py
	modified:   app/amazon/match_action.py
	modified:   app/amazon/tool.py
	modified:   app/assets/delete-brand.js
	modified:   app/new_web_source/delete-brand.html
2026-04-11 23:00:55 +08:00
super
365050b890 提交bug修复 后台权限管理 2026-04-11 22:25:14 +08:00
super
73392a9b83 修复BUG,完善匹配店铺 2026-04-11 00:57:34 +08:00
super
c47c03fde4 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-10 19:35:08 +08:00
super
86fd9475e4 匹配回收,bug修复 2026-04-10 19:33:58 +08:00
铭坤
2d0d1d3461 modified: app/amazon/__pycache__/approve.cpython-39.pyc
modified:   app/amazon/__pycache__/del_brand.cpython-39.pyc
	modified:   app/amazon/__pycache__/main.cpython-39.pyc
	new file:   app/amazon/__pycache__/match_action.cpython-39.pyc
	new file:   app/amazon/__pycache__/tool.cpython-39.pyc
	new file:   "app/amazon/approve - \345\211\257\346\234\254.py"
	modified:   app/amazon/approve.py
	modified:   app/amazon/del_brand.py
	modified:   app/amazon/main.py
	new file:   app/amazon/match_action.py
	new file:   app/amazon/tool.py
	modified:   app/main.py
	modified:   app/web_source/brand.html
2026-04-10 16:51:52 +08:00
super
ccaec4a984 完善后台管理店铺搜索 2026-04-08 21:08:52 +08:00
super
169ba7edb6 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-08 16:14:30 +08:00
super
0327d1cc51 修复接口内容缺失 2026-04-08 16:14:26 +08:00
铭坤
e02ddab599 modified: amazon/__pycache__/approve.cpython-39.pyc
modified:   amazon/__pycache__/del_brand.cpython-39.pyc
	modified:   amazon/__pycache__/main.cpython-39.pyc
	modified:   amazon/approve.py
	new file:   amazon/backend_api.py
	modified:   amazon/del_brand.py
	modified:   amazon/main.py
	modified:   blueprints/main.py
	modified:   main.py
2026-04-08 16:03:48 +08:00
super
336338cff5 完善后台接口字段 2026-04-07 17:57:03 +08:00
super
acff31e652 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-07 14:50:09 +08:00
super
ad304a6880 完成店铺增删改查 2026-04-07 14:50:06 +08:00
铭坤
cb34788e53 modified: "web_source/brand-\346\227\247.html"
modified:   web_source/brand.html
	modified:   web_source/templates_backup/brand.html
2026-04-07 14:10:33 +08:00
铭坤
13b0ffb5d8 new file: amazon/__pycache__/approve.cpython-39.pyc
modified:   amazon/__pycache__/del_brand.cpython-39.pyc
	modified:   amazon/__pycache__/main.cpython-39.pyc
	new file:   amazon/approve.py
	modified:   amazon/del_brand.py
	modified:   amazon/main.py
	modified:   main.py
2026-04-07 14:09:42 +08:00
super
c9947c4ac8 更改下载方法名 2026-04-06 22:26:44 +08:00
super
afffb7aad2 更改下载类型处理 2026-04-06 16:58:58 +08:00
super
9720453ac4 更新商品风险处理 2026-04-05 22:04:21 +08:00
super
1ff6d5220e 新增删除增加公司名 2026-04-04 00:10:08 +08:00
super
46d91fd8ca 提交接口进度完善 2026-04-03 00:53:52 +08:00
super
a24db2f73c Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-02 23:29:19 +08:00
super
ca9537f862 测试多任务暂存 2026-04-02 23:29:15 +08:00
铭坤
94ae7e68ff modified: app/amazon/__pycache__/del_brand.cpython-39.pyc
modified:   app/amazon/__pycache__/main.cpython-39.pyc
	modified:   app/amazon/main.py
	modified:   app/main.py
2026-04-02 23:03:13 +08:00
super
94e1930538 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-02 20:37:38 +08:00
super
5bbe5a3077 修改多文件 2026-04-02 20:37:35 +08:00
铭坤
8cead01270 modified: app/amazon/del_brand.py
backend/blueprints/get_resource.py
2026-04-02 20:35:50 +08:00
铭坤
99fc14b9f9 modified: backend/app.py
backend/blueprints/get_resource.py
2026-04-02 20:35:12 +08:00
super
d3671206e1 完善后端轮询记忆紫鸟店铺逻辑 2026-04-02 12:26:48 +08:00
super
0a8202788b Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-02 01:15:54 +08:00
super
855f1affd9 紫鸟查询店铺更新 2026-04-02 01:15:50 +08:00
铭坤
5a294e5c85 new file: app/amazon/__pycache__/del_brand.cpython-39.pyc
new file:   app/amazon/__pycache__/main.cpython-39.pyc
	modified:   app/amazon/del_brand.py
	modified:   app/amazon/main.py
	modified:   app/blueprints/__pycache__/main.cpython-39.pyc
	modified:   app/blueprints/main.py
	modified:   app/config.py
	modified:   app/main.py
	new file:   "app/web_source/brand-\346\227\247.html"
	modified:   app/web_source/brand.html
2026-04-02 00:03:16 +08:00
super
df606a1087 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-04-01 21:20:04 +08:00
super
ece3552b89 更新部分内容 2026-04-01 21:20:01 +08:00
铭坤
4df3945131 modified: app/ali_oss.py
new file:   app/amazon/del_brand.py
	new file:   app/amazon/main.py
	modified:   app/blueprints/__pycache__/brand.cpython-39.pyc
	modified:   app/blueprints/__pycache__/communication.cpython-39.pyc
	modified:   app/blueprints/__pycache__/main.cpython-39.pyc
	modified:   app/blueprints/brand.py
	new file:   "app/blueprints/brand_\345\244\207\344\273\275.py"
	modified:   app/blueprints/communication.py
	modified:   app/blueprints/main.py
	modified:   app/config.py
	modified:   app/main.py
2026-04-01 11:53:12 +08:00
super
6eb0f33421 删除模块 取消打开紫鸟 2026-03-31 23:12:46 +08:00
super
25ce9f74b8 处理这个python打开紫鸟 2026-03-30 23:15:08 +08:00
super
0f1f471590 处理掉一些BUG 2026-03-30 22:47:19 +08:00
super
d9487b6885 品牌多文件chunk修复 2026-03-30 21:06:10 +08:00
super
9845ab8f2a 暂存 2026-03-30 20:54:05 +08:00
super
6fa130386a Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-03-30 20:48:02 +08:00
super
53df8b9971 暂存 2026-03-30 20:39:13 +08:00
铭坤
d6625762cf modified: app/.env
modified:   app/ali_oss.py
	modified:   app/app.py
	new file:   app/blueprints/__pycache__/communication.cpython-39.pyc
	modified:   app/blueprints/communication.py
	modified:   app/brand_spider/__pycache__/main.cpython-39.pyc
	modified:   app/config.py
	modified:   app/main.py
	modified:   app/static/bg.jpg
	modified:   app/tool/__pycache__/devices.cpython-39.pyc
	modified:   app/web_source/brand.html
	modified:   app/web_source/templates_backup/brand.html
2026-03-30 19:44:04 +08:00
super
cb3b9e9cfe 完善删除品牌asin 2026-03-29 19:03:24 +08:00
super
3d39236b13 完成删除品牌 2026-03-29 13:41:03 +08:00
super
9e57fa5762 完成品牌删除模块 2026-03-29 00:22:45 +08:00
super
9c6232bc85 完成前后端紫鸟部分开发 2026-03-28 15:42:18 +08:00
super
d3c7938627 完成前后端删除、紫鸟部分开发 2026-03-28 15:42:02 +08:00
super
06256946fa 提交暂存 2026-03-27 22:00:01 +08:00
super
2042d386e2 提交过滤 2026-03-27 21:58:43 +08:00
铭坤
7176754564 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-03-27 21:56:53 +08:00
铭坤
a20222192e modified: assets/convert.js
modified:   assets/dedupe.js
	modified:   assets/split.js
	modified:   blueprints/__pycache__/brand.cpython-311.pyc
	modified:   blueprints/__pycache__/brand.cpython-39.pyc
	new file:   blueprints/communication.py
	modified:   config.py
	modified:   main.py
	modified:   new_web_source/convert.html
	modified:   new_web_source/dedupe.html
	modified:   new_web_source/split.html
2026-03-27 21:56:19 +08:00
super
a5c2681e88 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-03-27 21:51:34 +08:00
super
5e2f750359 更新运行日志
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:51:23 +08:00
super
27602890ab 品牌 2026-03-27 21:48:13 +08:00
铭坤
dfe0d652ce new file: assets/_plugin-vue_export-helper-Dh4yMZWw.js
new file:   assets/_plugin-vue_export-helper-xvHHTGU_.css
	new file:   assets/convert-1dkFPbKa.css
	new file:   assets/convert-6b0yq-M0.css
	new file:   assets/convert-BxkpMHO9.css
	new file:   assets/convert-D4Yv2oRy.css
	new file:   assets/convert-DwbQug2y.css
	modified:   assets/convert.js
	new file:   assets/dedupe-B0eqpVpj.css
	new file:   assets/dedupe-BHE_BCAJ.css
	new file:   assets/dedupe-Day_nGxq.css
	new file:   assets/dedupe-DzFvTfoj.css
	new file:   assets/dedupe-rhGu1E8E.css
	modified:   assets/dedupe.js
	new file:   assets/el-alert-Ct0RpqUD.css
	new file:   assets/pywebview-B6ZNXusn.css
	new file:   assets/pywebview-BRrok0pS.js
	new file:   assets/pywebview-BnpJyyGZ.js
	new file:   assets/pywebview-CGwF8TuM.js
	new file:   assets/pywebview-Chz98E9c.css
	new file:   assets/pywebview-D-PKWjUg.js
	new file:   assets/pywebview-DVgP2-kW.js
	new file:   assets/pywebview-IqgMfeBe.css
	new file:   assets/pywebview-Rm5Hhqpf.js
	new file:   assets/pywebview-leZCedEt.js
	new file:   assets/split-BsJfbBqn.css
	new file:   assets/split-DAa_Usoh.css
	new file:   assets/split-DidKJ84l.css
	new file:   assets/split-R_8V5xgh.css
	new file:   assets/split-_O6NHqDj.css
	modified:   assets/split.js
	modified:   blueprints/__pycache__/brand.cpython-311.pyc
	modified:   blueprints/__pycache__/brand.cpython-39.pyc
	modified:   blueprints/brand.py
	modified:   config.py
	modified:   main.py
	modified:   new_web_source/convert.html
	modified:   new_web_source/dedupe.html
	modified:   new_web_source/split.html
2026-03-27 21:35:28 +08:00
super
24076adf98 品牌 2026-03-27 21:33:07 +08:00
super
15a78abc1b 删除品牌 2026-03-27 21:32:00 +08:00
super
7fcdf1a761 提交品牌删除更新 2026-03-26 17:39:20 +08:00
super
d0ca28ae2d 提交品牌删除更新 2026-03-26 17:39:01 +08:00
super
32a9494dd6 更新后端新增内容 2026-03-26 16:42:25 +08:00
铭坤
a5da06537e modified: blueprints/__pycache__/__init__.cpython-39.pyc
modified:   blueprints/__pycache__/admin.cpython-39.pyc
	modified:   blueprints/__pycache__/auth.cpython-39.pyc
	modified:   blueprints/__pycache__/brand.cpython-311.pyc
	modified:   blueprints/__pycache__/brand.cpython-39.pyc
	modified:   blueprints/brand.py
	new file:   brand_spider/__pycache__/main.cpython-311.pyc
	modified:   brand_spider/__pycache__/main.cpython-39.pyc
	new file:   brand_spider/__pycache__/web_dec.cpython-311.pyc
	modified:   brand_spider/main.py
	modified:   config.py
	modified:   web_source/admin.html
	modified:   web_source/brand.html
	modified:   web_source/home.html
	modified:   web_source/index.html
	modified:   web_source/login.html
	new file:   web_source/templates_backup/admin.html
	new file:   web_source/templates_backup/brand.html
	new file:   web_source/templates_backup/home.html
	new file:   web_source/templates_backup/index.html
	new file:   web_source/templates_backup/login.html
2026-03-26 16:39:39 +08:00
super
21c6f41c69 增加紫鸟相关接口 2026-03-25 21:33:49 +08:00
super
506eb0faef 完善打包部分的压缩包 2026-03-25 09:52:04 +08:00
super
485b4dd485 完成品牌部分改造 2026-03-24 22:22:37 +08:00
super
4f728107d1 增加用户访问数据总表 2026-03-22 21:27:41 +08:00
super
01e491e332 数据去重完善 2026-03-22 20:58:36 +08:00
super
ed91c2ae7d 总表更新 2026-03-22 17:51:34 +08:00
super
55ac81f0cc 修复tab切换问题 2026-03-22 13:39:32 +08:00
super
64e32523cd 完成后台管理开发 2026-03-22 13:33:37 +08:00
super
5b636c3dfd 提交所有内容 2026-03-22 12:45:58 +08:00
super
c5d62c71a3 暂替 2026-03-22 11:37:23 +08:00
super
fa384a6ec8 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-03-22 11:37:03 +08:00
super
24cf47e1e3 暂提更新 2026-03-22 11:35:45 +08:00
super
8e66926b9e 更新新增三个模块 2026-03-22 11:20:09 +08:00
铭坤
574b372f42 new file: app/assets/_plugin-vue_export-helper-Dh4yMZWw.js
new file:   app/assets/_plugin-vue_export-helper-xvHHTGU_.css
	new file:   app/assets/convert-1dkFPbKa.css
	new file:   app/assets/convert-BxkpMHO9.css
	new file:   app/assets/convert-DwbQug2y.css
	modified:   app/assets/convert.js
	new file:   app/assets/dedupe-B0eqpVpj.css
	new file:   app/assets/dedupe-DzFvTfoj.css
	new file:   app/assets/dedupe-rhGu1E8E.css
	modified:   app/assets/dedupe.js
	new file:   app/assets/el-alert-Ct0RpqUD.css
	new file:   app/assets/pywebview-B6ZNXusn.css
	new file:   app/assets/pywebview-BnpJyyGZ.js
	new file:   app/assets/pywebview-CGwF8TuM.js
	new file:   app/assets/pywebview-D-PKWjUg.js
	new file:   app/assets/pywebview-IqgMfeBe.css
	new file:   app/assets/split-DAa_Usoh.css
	new file:   app/assets/split-DidKJ84l.css
	new file:   app/assets/split-R_8V5xgh.css
	modified:   app/assets/split.js
	modified:   app/blueprints/__pycache__/main.cpython-39.pyc
	modified:   app/blueprints/main.py
	modified:   app/main.py
	modified:   app/new_web_source/convert.html
	modified:   app/new_web_source/dedupe.html
	modified:   app/new_web_source/split.html
2026-03-22 10:29:12 +08:00
super
74a5fb9ce1 增加log输出、跳转tab顺序 2026-03-21 20:34:49 +08:00
super
4b83bf76b4 环境搭建配置 2026-03-21 10:00:16 +08:00
铭坤
82ccd9a8ea Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-03-20 23:16:13 +08:00
铭坤
2dbeec7d6d new file: app/blueprints/__init__.py
new file:   app/blueprints/__pycache__/__init__.cpython-311.pyc
	new file:   app/blueprints/__pycache__/__init__.cpython-39.pyc
	new file:   app/blueprints/__pycache__/admin.cpython-311.pyc
	new file:   app/blueprints/__pycache__/admin.cpython-39.pyc
	new file:   app/blueprints/__pycache__/auth.cpython-311.pyc
	new file:   app/blueprints/__pycache__/auth.cpython-39.pyc
	new file:   app/blueprints/__pycache__/brand.cpython-311.pyc
	new file:   app/blueprints/__pycache__/brand.cpython-39.pyc
	new file:   app/blueprints/__pycache__/image.cpython-311.pyc
	new file:   app/blueprints/__pycache__/image.cpython-39.pyc
	new file:   app/blueprints/__pycache__/main.cpython-311.pyc
	new file:   app/blueprints/__pycache__/main.cpython-39.pyc
	new file:   app/blueprints/admin.py
	new file:   app/blueprints/auth.py
	new file:   app/blueprints/brand.py
	new file:   app/blueprints/image.py
	new file:   app/blueprints/main.py
	new file:   app/brand_spider/__pycache__/main.cpython-39.pyc
	new file:   app/brand_spider/__pycache__/web_dec.cpython-39.pyc
	new file:   app/brand_spider/main.py
	new file:   app/brand_spider/web_dec.py
	new file:   app/static/bg.jpg
	new file:   "app/static/\345\223\201\347\211\214\346\226\207\346\241\243\346\240\274\345\274\217_\346\250\241\346\235\277.xlsx"
	new file:   "app/static/\346\250\241\346\235\2772-\344\273\245\346\226\207\344\273\266\345\244\271\346\226\271\345\274\217\344\270\212\344\274\240.zip"
	new file:   app/tool/.device_id
	new file:   app/tool/__pycache__/devices.cpython-311.pyc
	new file:   app/tool/__pycache__/devices.cpython-39.pyc
	new file:   app/tool/devices.py
2026-03-20 23:04:19 +08:00
super
fb32f7bd35 新增接口文档 2026-03-20 19:31:02 +08:00
super
63d3e4047c 打包项目 2026-03-20 18:13:47 +08:00
super
7c6d9dd989 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-03-20 14:26:41 +08:00
铭坤
ce121cf0d0 new file: ali_oss.py
new file:   app.py
	new file:   app/ali_oss.py
	new file:   app/app.py
	new file:   app/app_common.py
	new file:   app/config.py
	new file:   app/coze.py
	new file:   app/generate_api.py
	new file:   app/html_crypto.py
	new file:   app/main.py
	new file:   app/web_source/admin.html
	new file:   app/web_source/brand.html
	new file:   app/web_source/home.html
	new file:   app/web_source/index.html
	new file:   app/web_source/login.html
	new file:   app_common.py
	new file:   blueprints/__init__.py
	new file:   blueprints/__pycache__/__init__.cpython-311.pyc
	new file:   blueprints/__pycache__/__init__.cpython-39.pyc
	new file:   blueprints/__pycache__/admin.cpython-311.pyc
	new file:   blueprints/__pycache__/admin.cpython-39.pyc
	new file:   blueprints/__pycache__/auth.cpython-311.pyc
	new file:   blueprints/__pycache__/auth.cpython-39.pyc
	new file:   blueprints/__pycache__/brand.cpython-311.pyc
	new file:   blueprints/__pycache__/brand.cpython-39.pyc
	new file:   blueprints/__pycache__/image.cpython-311.pyc
	new file:   blueprints/__pycache__/image.cpython-39.pyc
	new file:   blueprints/__pycache__/main.cpython-311.pyc
	new file:   blueprints/__pycache__/main.cpython-39.pyc
	new file:   blueprints/admin.py
	new file:   blueprints/auth.py
	new file:   blueprints/brand.py
	new file:   blueprints/image.py
	new file:   blueprints/main.py
	new file:   brand_spider/__pycache__/main.cpython-39.pyc
	new file:   brand_spider/__pycache__/web_dec.cpython-39.pyc
	new file:   brand_spider/main.py
	new file:   brand_spider/web_dec.py
	new file:   config.py
	new file:   coze.py
	new file:   generate_api.py
	new file:   html_crypto.py
	new file:   main.py
	new file:   static/bg.jpg
	new file:   "static/\345\223\201\347\211\214\346\226\207\346\241\243\346\240\274\345\274\217_\346\250\241\346\235\277.xlsx"
	new file:   "static/\346\250\241\346\235\2772-\344\273\245\346\226\207\344\273\266\345\244\271\346\226\271\345\274\217\344\270\212\344\274\240.zip"
	new file:   tool/.device_id
	new file:   tool/__pycache__/devices.cpython-311.pyc
	new file:   tool/__pycache__/devices.cpython-39.pyc
	new file:   tool/devices.py
2026-03-20 11:23:57 +08:00
85 changed files with 12558 additions and 1471 deletions

View File

@@ -15,6 +15,9 @@ client_name=ShuFuAI
java_api_base=http://127.0.0.1:18080
# java_api_base=http://121.196.149.225:18080
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes

View File

@@ -15,4 +15,9 @@ java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080
# java_api_base=http://47.111.163.154:18080
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes
# JWT cookie 名称,默认 aiimage_token改动需与 Java 端 aiimage.auth.cookie-name 保持一致
# AIIMAGE_AUTH_COOKIE_NAME=aiimage_token

View File

@@ -3,9 +3,8 @@
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员(admin)、图片(image)、品牌(brand)
"""
import os
import secrets
import logging
from datetime import timedelta, datetime
from datetime import datetime
from logging.handlers import RotatingFileHandler
from flask import Flask
@@ -73,12 +72,6 @@ def create_app():
supports_credentials=True,
resources={r"/api/*": {"origins": cors_origins}, r"/login": {"origins": cors_origins}},
)
# 生产环境必须通过环境变量注入固定 SECRET_KEY否则服务重启会使旧会话失效。
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-me')
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', '0') == '1'
# 注册蓝图(不设 url_prefix保持原有 URL 路径不变,前端无需改动)
app.register_blueprint(auth_bp)

View File

@@ -1,5 +1,5 @@
"""
公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染
公共模块:数据库连接、初始化、JWT 鉴权装饰器、模板渲染
供各蓝图复用
"""
import os
@@ -8,18 +8,19 @@ from datetime import timedelta
from functools import wraps
import pymysql
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
from flask import request, redirect, url_for, jsonify, render_template, render_template_string, g
from config import mysql_host, mysql_user, mysql_password, mysql_database
from jwt_util import parse_token, get_token_from_request
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, 'static')
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source')
TEMPLATE_FALLBACK_DIRS = (
WEB_SOURCE_DIR,
os.path.join(WEB_SOURCE_DIR, 'templates_backup'),
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, 'static')
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source')
TEMPLATE_FALLBACK_DIRS = (
WEB_SOURCE_DIR,
os.path.join(WEB_SOURCE_DIR, 'templates_backup'),
)
def get_db():
@@ -35,13 +36,13 @@ def get_db():
def _render_html(template_name: str, **context):
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
path = next(
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
if os.path.isfile(candidate)),
None,
)
if path is None:
return render_template(template_name, **context)
path = next(
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
if os.path.isfile(candidate)),
None,
)
if path is None:
return render_template(template_name, **context)
with open(path, "rb") as f:
raw = f.read()
try:
@@ -54,13 +55,13 @@ def _render_html(template_name: str, **context):
def _render_html_new(template_name: str, **context):
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
path = next(
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
if os.path.isfile(candidate)),
None,
)
if path is None:
return render_template(template_name, **context)
path = next(
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
if os.path.isfile(candidate)),
None,
)
if path is None:
return render_template(template_name, **context)
with open(path, "rb") as f:
raw = f.read()
try:
@@ -72,9 +73,36 @@ def _render_html_new(template_name: str, **context):
def _resolve_jwt_user():
"""从 Authorization/cookie 解析 JWT命中后写入 flask.g 缓存。无效返回 None。"""
cached = getattr(g, "_aiimage_user", None)
if cached is not None:
return cached or None
token = get_token_from_request()
payload = parse_token(token) if token else None
if not payload:
g._aiimage_user = False
return None
g._aiimage_user = payload
g.aiimage_user_id = payload.get("user_id")
g.aiimage_username = payload.get("username") or ""
g.aiimage_device_id = payload.get("device_id") or ""
return payload
def current_user_id():
user = _resolve_jwt_user()
return user.get("user_id") if user else None
def current_username():
user = _resolve_jwt_user()
return user.get("username") if user else ""
def _is_session_user_valid():
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
uid = session.get('user_id')
"""兼容旧调用:判断当前 JWT 用户是否仍存在于数据库。"""
uid = current_user_id()
if not uid:
return False
try:
@@ -83,23 +111,22 @@ def _is_session_user_valid():
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
row = cur.fetchone()
conn.close()
if not row:
session.clear()
return False
return True
return bool(row)
except Exception:
session.clear()
return False
def _get_current_admin_role():
"""获取当前登录用户的管理角色super_admin / admin / None非管理员"""
uid = current_user_id()
if not uid:
return None, None
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(session['user_id'],)
(uid,)
)
row = cur.fetchone()
conn.close()
@@ -110,13 +137,19 @@ def _get_current_admin_role():
return None, None
def _unauthorized_response():
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
or request.path.startswith('/api/') \
or 'application/json' in (request.headers.get('Accept') or ''):
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id') or not _is_session_user_valid():
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
if not current_user_id():
return _unauthorized_response()
return f(*args, **kwargs)
return decorated
@@ -124,22 +157,24 @@ def login_required(f):
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
uid = current_user_id()
if not uid:
return _unauthorized_response()
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (uid,))
row = cur.fetchone()
conn.close()
if not row or not row.get('is_admin'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
or request.path.startswith('/api/') \
or 'application/json' in (request.headers.get('Accept') or ''):
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
return redirect(url_for('main.home'))
except Exception as e:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
or request.path.startswith('/api/'):
return jsonify({'success': False, 'error': str(e)}), 500
return redirect(url_for('main.home'))
return f(*args, **kwargs)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{b8 as r}from"./pywebview-3bFwh9Cg.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
const r=new Map;function c(n){let t=r.get(n);return t||(t=new Map,r.set(n,t)),t}function u(n,t){const e=r.get(n);e&&(e.delete(t),e.size||r.delete(n))}function l(n,t,e){const i=window.setTimeout(()=>{u(n,i),t()},e);return c(n).set(i,{id:i,kind:"timeout",category:n}),i}function a(n,t,e){const i=window.setInterval(t,e);return c(n).set(i,{id:i,kind:"interval",category:n}),i}function f(n,t){if(t==null)return;const i=r.get(n)?.get(t);i?.kind==="interval"?window.clearInterval(t):window.clearTimeout(t),i?.cancel?.(),u(n,t)}function s(n){const t=r.get(n);if(t){for(const e of t.values())e.kind==="interval"?window.clearInterval(e.id):window.clearTimeout(e.id),e.cancel?.();r.delete(n)}}function d(n,t){return new Promise(e=>{const i=window.setTimeout(()=>{u(n,i),e()},t);c(n).set(i,{id:i,kind:"timeout",category:n,cancel:e})})}function m(n){const t=`${n}:`;for(const e of Array.from(r.keys()))(e===n||e.startsWith(t))&&s(e)}function w(n){const t=e=>`${n}:${e}`;return{setTimeout(e,i,o){return l(t(e),i,o)},setInterval(e,i,o){return a(t(e),i,o)},clearTimer(e,i){f(t(e),i)},clearCategory(e){s(t(e))},clearScope(){m(n)},sleep(e,i){return d(t(e),i)}}}export{w as c};

File diff suppressed because one or more lines are too long

1
app/assets/convert.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
app/assets/dedupe.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
const e=[{value:"SearchSuppressed",label:"在搜索结果中禁止显示"},{value:"ApprovalRequired",label:"需要批准"},{value:"Active",label:"在售"},{value:"DetailPageRemoved",label:"详情页面已删除"}];export{e as L};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
app/assets/query-asin.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
app/assets/shop-match.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.module-page[data-v-49f8fcb3]{min-height:100vh;background:#1a1a1a}.main-content[data-v-49f8fcb3]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-49f8fcb3]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-49f8fcb3]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-49f8fcb3],.subsection-title[data-v-49f8fcb3]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-49f8fcb3]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-49f8fcb3],.loading-msg[data-v-49f8fcb3],.files[data-v-49f8fcb3],.muted[data-v-49f8fcb3]{color:#888;font-size:12px;line-height:1.5}.link[data-v-49f8fcb3]{color:#6ea8fe;text-decoration:none}.link[data-v-49f8fcb3]:hover{color:#9fc5ff}.btns[data-v-49f8fcb3],.run-row[data-v-49f8fcb3]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-49f8fcb3],.btn-run[data-v-49f8fcb3],.btn-delete[data-v-49f8fcb3],.download[data-v-49f8fcb3]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-49f8fcb3]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-49f8fcb3]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-49f8fcb3]{background:#27ae60}.btn-run[data-v-49f8fcb3]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-49f8fcb3]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-49f8fcb3]{display:block;margin:4px 0}.parse-card[data-v-49f8fcb3],.queue-payload[data-v-49f8fcb3]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-49f8fcb3]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-49f8fcb3]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-49f8fcb3]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-49f8fcb3]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-49f8fcb3]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-49f8fcb3]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-49f8fcb3]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-49f8fcb3]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-49f8fcb3]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-49f8fcb3]{color:#666;font-size:13px;padding:18px;text-align:center}.task-list[data-v-49f8fcb3]{list-style:none;margin:0;padding:12px}.task-item[data-v-49f8fcb3]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-49f8fcb3]{flex:1;min-width:0}.id[data-v-49f8fcb3]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-49f8fcb3]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-49f8fcb3]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-49f8fcb3]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-49f8fcb3]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-49f8fcb3]{background:#3498db2e;color:#3498db}.status.pending[data-v-49f8fcb3]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-49f8fcb3]{margin-top:6px;color:#e0b96d}.file-progress[data-v-49f8fcb3]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-49f8fcb3]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-49f8fcb3]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-49f8fcb3]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.file-progress-count[data-v-49f8fcb3]{margin-top:4px;color:#858585;font-size:11px}.download[data-v-49f8fcb3]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-49f8fcb3]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-49f8fcb3]{flex-direction:column;height:auto}.left-panel[data-v-49f8fcb3]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-49f8fcb3]{grid-template-columns:repeat(2,minmax(0,1fr))}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
app/assets/split.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,379 +1,17 @@
"""
管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页
"""
import json
import pymysql
from flask import Blueprint, request, jsonify
from werkzeug.security import generate_password_hash
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
from flask import session
admin_bp = Blueprint('admin', __name__)
def _parse_json(val, default=None):
if val is None:
return default if default is not None else []
if isinstance(val, (list, dict)):
return val
try:
return json.loads(val)
except Exception:
return default if default is not None else []
@admin_bp.route('/admin')
@login_required
@admin_required
def admin_page():
return _render_html('admin.html')
@admin_bp.route('/api/admin/users')
@admin_required
def admin_list_users():
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
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
if role != 'super_admin':
created_by_id = None
try:
conn = get_db()
with conn.cursor() as cur:
if role == 'super_admin':
where_parts = ["1=1"]
params = []
if search_username:
where_parts.append("u.username LIKE %s")
params.append("%" + search_username + "%")
if created_by_id is not None:
where_parts.append("u.created_by_id = %s")
params.append(created_by_id)
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']
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:
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]
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'],
'username': r['username'],
'is_admin': bool(r.get('is_admin')),
'role': r.get('role') or 'normal',
'created_by_id': r.get('created_by_id'),
'creator_username': r.get('creator_username') or '',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
for r in rows
]
conn.close()
payload = {
'success': True,
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'current_user_role': role,
'admins': admins,
}
return jsonify(payload)
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user', methods=['POST'])
@admin_required
def admin_create_user():
data = request.get_json() or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
want_role = (data.get('role') or 'normal').strip() or 'normal'
if want_role not in ('admin', 'normal'):
want_role = 'normal'
if role == 'admin' and want_role == 'admin':
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
if want_role == 'admin':
want_created_by = current_row['id']
elif role == 'super_admin':
want_created_by = data.get('created_by_id')
else:
want_created_by = current_row['id']
if not username or not password:
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
if len(username) < 2:
return jsonify({'success': False, 'error': '用户名至少2个字符'})
if len(password) < 6:
return jsonify({'success': False, 'error': '密码至少6个字符'})
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
r = cur.fetchone()
conn.close()
want_created_by = r['id'] if r else current_row['id']
except Exception:
want_created_by = current_row['id']
if want_role == 'normal' and want_created_by is None:
want_created_by = current_row['id']
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
(username, pwd_hash, is_admin, want_role, want_created_by),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '用户创建成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
@admin_required
def admin_update_user(uid):
data = request.get_json() or {}
password = data.get('password')
want_role = (data.get('role') or '').strip() or data.get('role')
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
if want_role is None and not password:
return jsonify({'success': False, 'error': '请提供要修改的内容'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
target = cur.fetchone()
if not target:
conn.close()
return jsonify({'success': False, 'error': '用户不存在'})
if role == 'admin':
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
conn.close()
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
want_role = None
else:
if target.get('role') == 'super_admin':
conn.close()
return jsonify({'success': False, 'error': '不能修改超级管理员'})
if want_role == 'super_admin':
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
if want_role not in ('admin', 'normal', None, ''):
want_role = None
if password:
if len(password) < 6:
conn.close()
return jsonify({'success': False, 'error': '密码至少6个字符'})
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
if want_role is not None and want_role != '':
is_admin = 1 if want_role == 'admin' else 0
cur.execute(
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
(is_admin, want_role, uid),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
@admin_required
def admin_delete_user(uid):
from flask import session
if session.get('user_id') == uid:
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
target = cur.fetchone()
if not target:
conn.close()
return jsonify({'success': False, 'error': '用户不存在'})
if target.get('role') == 'super_admin':
conn.close()
return jsonify({'success': False, 'error': '不能删除超级管理员'})
if role == 'admin':
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
conn.close()
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
affected = cur.rowcount
conn.commit()
conn.close()
if affected == 0:
return jsonify({'success': False, 'error': '用户不存在'})
return jsonify({'success': True, 'msg': '删除成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
@login_required
def admin_user_column_permissions(uid):
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
current_uid = session.get('user_id')
menu_type = (request.args.get('menu_type') or '').strip().lower()
if current_uid != uid:
role, _ = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
user_row = cur.fetchone()
valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
sql = """
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
FROM columns
"""
params = []
if valid_menu_type:
sql += " WHERE menu_type = %s"
params.append(valid_menu_type)
sql += " ORDER BY sort_order ASC, id ASC"
cur.execute(sql, tuple(params))
rows = cur.fetchall()
else:
sql = """
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
FROM columns c
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
WHERE ucp.user_id = %s
"""
params = [uid]
if valid_menu_type:
sql += " AND c.menu_type = %s"
params.append(valid_menu_type)
sql += " ORDER BY c.sort_order ASC, c.id ASC"
cur.execute(sql, tuple(params))
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'name': r['name'],
'column_key': r['column_key'],
'menu_type': r.get('menu_type') or 'app',
'route_path': r.get('route_path') or '',
'sort_order': r.get('sort_order') or 0,
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
for r in rows
]
return jsonify({'success': True, 'items': items})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/history')
@admin_required
def admin_history():
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
user_id = request.args.get('user_id', type=int)
time_start = (request.args.get('time_start') or '').strip()
time_end = (request.args.get('time_end') or '').strip()
conditions, params = [], []
if user_id:
conditions.append("h.user_id = %s")
params.append(user_id)
if time_start:
conditions.append("h.created_at >= %s")
params.append(time_start)
if time_end:
conditions.append("h.created_at <= %s")
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
where_clause = " AND ".join(conditions) if conditions else "1=1"
params_count = params[:]
params.extend([page_size, offset])
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
h.long_image_url, u.username
FROM image_history h
LEFT JOIN users u ON h.user_id = u.id
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
params,
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
total = cur.fetchone()['total']
conn.close()
items = []
for r in rows:
items.append({
'id': r['id'],
'user_id': r['user_id'],
'username': r.get('username') or '-',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
'panel_type': r['panel_type'] or '',
'original_urls': _parse_json(r['original_urls'], []),
'params': _parse_json(r['params'], {}),
'result_urls': _parse_json(r['result_urls'], []),
'long_image_url': (r.get('long_image_url') or '').strip() or None,
})
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
"""
管理员蓝图:仅保留 /admin 页面渲染。
用户管理、栏目权限、生成历史等接口已全部迁移至 Java 端。
"""
from flask import Blueprint
from app_common import _render_html, admin_required, login_required
from config import JAVA_API_BASE
admin_bp = Blueprint('admin', __name__)
@admin_bp.route('/admin')
@login_required
@admin_required
def admin_page():
return _render_html('admin.html', java_api_base=JAVA_API_BASE)

View File

@@ -1,107 +1,55 @@
"""
认证蓝图:登录、登出、登录状态校验
"""
from flask import Blueprint, request, redirect, url_for, session, jsonify
from werkzeug.security import check_password_hash
from app_common import (
get_db,
_render_html,
_is_session_user_valid,
login_required,
BASE_DIR,
)
from tool.devices import DeviceIDGenerator
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if session.get('user_id') and _is_session_user_valid():
return redirect(url_for('main.home'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
if request.is_json:
return jsonify({'success': False, 'error': '请输入用户名和密码'})
return _render_html('login.html', error='请输入用户名和密码')
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
(username,)
)
row = cur.fetchone()
if row and check_password_hash(row['password_hash'], password):
current_machine = DeviceIDGenerator().get_device_id()
stored_machine = (row.get('machine') or '').strip()
if not stored_machine:
with conn.cursor() as cur:
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
conn.commit()
conn.close()
session.permanent = True
session['user_id'] = row['id']
session['username'] = username
if request.is_json:
return jsonify({'success': True, 'redirect': url_for('main.home')})
return redirect(url_for('main.home'))
print("验证设备",stored_machine)
print("当前设备",current_machine)
if stored_machine != current_machine and row.get("is_admin") != 1:
conn.close()
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
if request.is_json:
return jsonify({'success': False, 'error': err_msg})
return _render_html('login.html', error=err_msg)
conn.close()
session.permanent = True
session['user_id'] = row['id']
session['username'] = username
if request.is_json:
return jsonify({'success': True, 'redirect': url_for('main.home')})
return redirect(url_for('main.home'))
conn.close()
except Exception as e:
if request.is_json:
return jsonify({'success': False, 'error': str(e)})
return _render_html('login.html', error='登录失败,请稍后重试')
if request.is_json:
return jsonify({'success': False, 'error': '用户名或密码错误'})
return _render_html('login.html', error='用户名或密码错误')
return _render_html('login.html')
@auth_bp.route('/api/auth/check')
@login_required
def api_auth_check():
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
if not session.get('user_id'):
return jsonify({'logged_in': False})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
if not row:
return jsonify({'logged_in': False})
stored_machine = (row.get('machine') or '').strip()
if stored_machine:
current_machine = DeviceIDGenerator().get_device_id()
if stored_machine != current_machine and row.get("is_admin") != 1:
session.clear()
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
except Exception:
return jsonify({'logged_in': False})
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
@auth_bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('auth.login'))
"""
认证蓝图:登录页(仅 GET 渲染模板)+ 登出(清 JWT cookie+ token 同步
登录表单提交已经直连 Java 后端 /loginPython 这边只负责:
1. 渲染登录页模板
2. 把 Java 签发的 JWT 从前端写到 Python 同源 cookie供后续页面跳转携带
3. 登出:清 cookie 跳回登录页
"""
import sys
from flask import Blueprint, redirect, url_for, make_response, request, jsonify
from app_common import _render_html, current_user_id
from config import JAVA_API_BASE
from jwt_util import COOKIE_NAME, parse_token_with_reason
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET'])
def login():
if current_user_id():
return redirect(url_for('main.home'))
return _render_html('login.html', java_api_base=JAVA_API_BASE)
@auth_bp.route('/api/auth/sync', methods=['POST'])
def api_auth_sync():
"""前端拿到 Java 返回的 JWT 后调用,把 token 写进 Python 同源 cookie。"""
data = request.get_json(silent=True) or {}
token = (data.get('token') or '').strip()
if not token:
return jsonify({'success': False, 'error': '缺少 token'}), 400
payload, reason = parse_token_with_reason(token)
if not payload:
# 把根因打到服务端日志,并回传给前端,便于现场排查
print(f"[auth] /api/auth/sync 校验失败: {reason}", file=sys.stderr)
return jsonify({'success': False, 'error': f'token 无效: {reason}'}), 401
resp = make_response(jsonify({'success': True}))
resp.set_cookie(
COOKIE_NAME,
token,
max_age=7 * 24 * 3600,
path='/',
httponly=True,
samesite='Lax',
)
return resp
@auth_bp.route('/logout')
def logout():
resp = make_response(redirect(url_for('auth.login')))
resp.delete_cookie(COOKIE_NAME, path='/')
return resp

View File

@@ -13,9 +13,9 @@ from queue import Queue, Empty
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
from flask import Blueprint, request, jsonify, send_file, redirect, Response
from app_common import get_db, login_required, BASE_DIR
from app_common import get_db, login_required, current_user_id, BASE_DIR
from config import bucket_path,JAVA_API_BASE
from brand_spider.main import single_file_handle, TaskCancelledError
@@ -70,12 +70,12 @@ def _get_uid_from_request_headers():
def _resolve_user_id():
"""
优先使用请求头 uid没有请求头 uid 时回退到 session['user_id']
优先使用请求头 uid没有请求头 uid 时回退到 JWT 解析的当前用户
若二者同时存在但不一致,则视为无效请求并返回 None。
"""
req_uid = _get_uid_from_request_headers()
if req_uid is not None:
sess_uid = session.get('user_id')
sess_uid = current_user_id()
if sess_uid is not None:
try:
if int(sess_uid) != int(req_uid):
@@ -84,7 +84,7 @@ def _resolve_user_id():
if str(sess_uid) != str(req_uid):
return None
return req_uid
return session.get('user_id')
return current_user_id()
def _get_user_id_or_error():

View File

@@ -1,726 +0,0 @@
"""
品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果
"""
import os
import json
import threading
import traceback
import zipfile
import io
import time
import requests
from queue import Queue, Empty
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
from app_common import get_db, login_required, BASE_DIR
from config import bucket_path,JAVA_API_BASE
from brand_spider.main import single_file_handle, TaskCancelledError
brand_bp = Blueprint('brand', __name__)
BRAND_OUTPUT_DIR = os.path.join(BASE_DIR, 'brand_output')
OSS_PREFIX = "brand_results"
# 任务完成时推送给前端的 SSE 队列task_id -> [Queue, ...]
_task_event_queues = {}
_task_event_lock = threading.Lock()
# 任务行级进度(当前处理文件的行数/总行数),仅存内存,不落库:
# task_id -> {
# 'file_index': int, # 当前处理第几个文件
# 'file_total': int, # 总文件数
# 'file_path': str, # 当前文件路径
# 'current_line': int, # 当前行号(或当前品牌序号)
# 'total_lines': int, # 总行数(或总品牌数)
# }
_task_line_progress = {}
_task_line_progress_lock = threading.Lock()
_cancelled_brand_tasks = set()
_cancelled_brand_tasks_lock = threading.Lock()
def _mark_task_cancelled(task_id):
with _cancelled_brand_tasks_lock:
_cancelled_brand_tasks.add(task_id)
def _is_task_cancelled(task_id):
with _cancelled_brand_tasks_lock:
return task_id in _cancelled_brand_tasks
def _get_uid_from_request_headers():
"""
从请求头读取 uid。
前端会在 headers 里携带 uid用于确定本次请求的归属用户。
"""
uid = request.headers.get('uid')
if uid is None:
return None
uid = str(uid).strip()
if not uid:
return None
try:
return int(uid)
except Exception:
return None
def _resolve_user_id():
"""
优先使用请求头 uid没有请求头 uid 时回退到 session['user_id']。
若二者同时存在但不一致,则视为无效请求并返回 None。
"""
req_uid = _get_uid_from_request_headers()
if req_uid is not None:
sess_uid = session.get('user_id')
if sess_uid is not None:
try:
if int(sess_uid) != int(req_uid):
return None
except Exception:
if str(sess_uid) != str(req_uid):
return None
return req_uid
return session.get('user_id')
def _get_user_id_or_error():
user_id = _resolve_user_id()
if not user_id:
return None, (jsonify({'success': False, 'error': '未登录或缺少uid'}), 401)
try:
return int(user_id), None
except Exception:
return None, (jsonify({'success': False, 'error': 'uid无效'}), 400)
def _push_task_event(task_id, event):
"""向订阅了该任务的所有 SSE 连接推送事件,并移除该任务的队列列表。
同时清理内存中的行级进度,不通过数据库中转行级进度。"""
with _task_event_lock:
queues = _task_event_queues.pop(task_id, [])
for q in queues:
try:
q.put_nowait(event)
except Exception:
pass
# 任务进入终态时,顺便清理行级进度缓存
with _task_line_progress_lock:
_task_line_progress.pop(task_id, None)
status = (event or {}).get('status') if isinstance(event, dict) else None
if status in ('success', 'failed', 'cancelled'):
with _cancelled_brand_tasks_lock:
_cancelled_brand_tasks.discard(task_id)
def _expand_folder_xlsx(folder_path):
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
if not folder_path or not os.path.isdir(folder_path):
return []
paths = []
for name in os.listdir(folder_path):
if name.endswith('.xlsx') or name.endswith('.XLSX'):
paths.append(os.path.normpath(os.path.join(folder_path, name)))
return paths
def _upload_local_xlsx_to_oss(local_path, user_id):
"""将本地 xlsx 文件上传到 OSS返回 (url, None) 或 (None, error_message)。"""
if not local_path or not os.path.isfile(local_path):
return None, "文件不存在"
try:
from ali_oss import upload_file as oss_upload_file
except ImportError:
return None, "OSS 模块未配置"
base = os.path.basename(local_path)
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
if not safe_base.endswith('.xlsx'):
safe_base = safe_base + '.xlsx'
key = f"{bucket_path}brand_input/{user_id}/{int(time.time() * 1000)}/{safe_base}"
try:
with open(local_path, "rb") as f:
content = f.read()
url = oss_upload_file(content, key)
return url, None
except Exception as e:
return None, str(e)
def _run_brand_single(taskid,brand_ls, fileUrl,strategy,totalLines,chunkTotal,chunkIndex):
""""""
invalidBrands = [] # 不符合品牌的数据
queryFailedBrands = [] # 查询失败的数据
keptRows = []
for brand in brand_ls:
if _is_task_cancelled(taskid):
raise TaskCancelledError("任务已取消")
faild_data,query_faild_data = single_file_handle(brand,strategy)
invalidBrands.extend(faild_data)
queryFailedBrands.extend(query_faild_data)
if len(faild_data) == 0 and len(query_faild_data) == 0:
keptRows.append(brand)
data = { "strategy": strategy ,
"files": [
{
"fileUrl": fileUrl,
"originalFilename": "",
"relativePath": "",
"mainSheetName": "",
"chunkIndex": chunkIndex,
"chunkTotal": chunkTotal,
"totalLines": totalLines,
"keptRows": keptRows,
"invalidBrands": invalidBrands,
"queryFailedBrands": queryFailedBrands
}
]
}
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks/{taskid}/result",headers={"accept":"application/json"},
# json={ "strategy": strategy ,
# "files": [
# { "fileUrl": fileUrl,"originalFilename": "","relativePath": "",
# "mainSheetName": "","columns": [],"keptRows": [],
# "invalidBrands": invalidBrands,"queryFailedBrands": queryFailedBrands
# }]})
json=data)
# print(data)
# print(taskid,brand_ls, fileUrl)
print("提交结果",data,"\n-->",resp.text)
return True
def _background_brand_task(task_id,data):
"""
后台执行爬虫任务
参数示例:
data : {
"taskId": 348,
"strategy": "Terms",
"files": [
{
"fileIndex": 1,
"fileUrl": "ab8cce4878754bfd89b4cd3ed20e1395",
"originalFilename": "品牌样例.xlsx",
"relativePath": "店铺A/品牌样例.xlsx",
"sheetName": "Sheet1",
"columns": [
"品牌",
"ASIH",
"状态",
"时间"
],
"rows": [
{
"品牌": "YQAUTEC",
"ASIH": "B0F28NZ752",
"状态": "",
"时间": "",
"__rowIndex": 2
}
],
"uniqueBrands": [
"YQAUTEC"
]
}
]
}
"""
try:
file_ls = data.get("files")
strategy = data.get("strategy")
if not isinstance(file_ls, list):
file_ls = []
tasks = []
for file_data in file_ls:
rows = file_data.get("rows") or []
brand_ls = [i.get("品牌") for i in rows if i.get("品牌")]
fileUrl = file_data.get("fileUrl")
if not brand_ls:
continue
for i in range(0, len(brand_ls), 5):
chunk = brand_ls[i:i + 5]
tasks.append((chunk, fileUrl))
if tasks:
max_workers = min(8, len(tasks))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
if _is_task_cancelled(task_id):
_push_task_event(task_id, {'status': 'cancelled'})
return
futures = [
executor.submit(_run_brand_single, task_id, chunk, file_url, strategy,len(brand_ls),len(tasks),chunkIndex+1)
for chunkIndex,(chunk, file_url) in enumerate(tasks)
]
for future in as_completed(futures):
if _is_task_cancelled(task_id):
for f in futures:
f.cancel()
_push_task_event(task_id, {'status': 'cancelled'})
return
future.result()
_push_task_event(task_id, {'status': 'success'})
except Exception as e:
print("执行出错",traceback.format_exc())
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
print("执行完成")
def _parse_json(val, default=None):
if val is None:
return default if default is not None else []
if isinstance(val, (list, dict)):
return val
try:
return json.loads(val)
except Exception:
return default if default is not None else []
def _is_url(s):
if not isinstance(s, str) or not s.strip():
return False
return s.strip().startswith('http://') or s.strip().startswith('https://')
def _normalize_result_paths(raw):
"""
将 result_paths 规范化为 (url_list, zip_url)。
支持旧格式 list 或新格式 dict {"urls": [...], "zip_url": "..."}。
"""
if not raw:
return [], None
if isinstance(raw, dict):
urls = raw.get('urls') or []
if not isinstance(urls, list):
urls = []
zip_url = raw.get('zip_url')
if zip_url and not isinstance(zip_url, str):
zip_url = None
return urls, zip_url
if isinstance(raw, list):
return raw, None
return [], None
@brand_bp.route('/api/brand/expand-folder', methods=['POST'])
@login_required
def api_brand_expand_folder():
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
try:
_, err = _get_user_id_or_error()
if err:
return err
data = request.get_json() or {}
folder = (data.get('folder') or '').strip()
if not folder:
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
paths = _expand_folder_xlsx(folder)
return jsonify({'success': True, 'paths': paths})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
def _expand_folder_xlsx_recursive_items(folder_path):
"""返回文件夹下所有 .xlsx 文件的绝对路径和相对路径列表(递归)"""
if not folder_path or not os.path.isdir(folder_path):
return []
items = []
root = os.path.normpath(folder_path)
for current_root, _, filenames in os.walk(root):
for name in filenames:
if not (name.endswith('.xlsx') or name.endswith('.XLSX')):
continue
absolute_path = os.path.normpath(os.path.join(current_root, name))
relative_path = os.path.relpath(absolute_path, root).replace('\\', '/')
items.append({
'absolutePath': absolute_path,
'relativePath': relative_path,
})
items.sort(key=lambda item: item['relativePath'])
return items
@brand_bp.route('/api/brand/expand-folder-recursive', methods=['POST'])
@login_required
def api_brand_expand_folder_recursive():
"""递归展开文件夹,返回 .xlsx 文件绝对路径及相对路径列表"""
try:
_, err = _get_user_id_or_error()
if err:
return err
data = request.get_json() or {}
folder = (data.get('folder') or '').strip()
if not folder:
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
items = _expand_folder_xlsx_recursive_items(folder)
return jsonify({'success': True, 'items': items})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@brand_bp.route('/api/brand/run', methods=['POST'])
# @login_required
def api_brand_run():
"""立即运行:创建任务并后台执行,立即返回 task_id前端可轮询进度与取消"""
try:
data = request.get_json() or {}
paths = data.get('paths') or []
# task_type: 1=立即执行2=添加任务;此接口默认 1
try:
task_type = int(data.get('task_type') or 1)
except Exception:
task_type = 1
if task_type not in (1, 2):
task_type = 1
strategy = (data.get('strategy') or 'Terms').strip()
if strategy not in ('Terms', 'Simple'):
strategy = 'Terms'
if not isinstance(paths, list):
paths = []
paths = [p.strip() for p in paths if p and isinstance(p, str) and os.path.isfile(p.strip())]
if not paths:
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
# 先上传到 OSS获取链接再入库
user_id, err = _get_user_id_or_error()
if err:
return err
urls = []
for p in paths:
url, err = _upload_local_xlsx_to_oss(p, user_id)
if err:
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
base_name = os.path.basename(p)
urls.append({"fileUrl": url,"originalFilename": base_name,"relativePath": p })
# 请求提交
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
"content-type":"application/json",
},data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
print({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""})
print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}")
resp_data = resp.json()
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id} 返回:",resp_data,"状态:",resp.status_code)
if resp_data.get("success"):
task_id = resp_data.get("data").get("taskId")
data = resp_data.get("data")
threading.Thread(target=_background_brand_task, args=(task_id,data), daemon=True).start()
return jsonify({'success': True, 'task_id': task_id})
else:
print(resp_data)
return jsonify({'success': False, 'error': "请求接口失败"}), 500
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
# @login_required
def api_brand_tasks():
"""GET: 获取当前用户的任务列表POST: 添加任务(后台执行)"""
if request.method == 'GET':
try:
user_id, err = _get_user_id_or_error()
if err:
return err
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks", params={
"userId": user_id
})
resp_data = resp.json()
print("获取列表",resp.text)
if resp_data.get("success"):
items = resp_data.get("data").get("items")
return jsonify({'success': True, 'items': items})
return jsonify({'success': True, 'items': []})
except Exception as e:
traceback.print_exc()
print("获取列表失败",e)
return jsonify({'success': False, 'error': str(e)}), 500
# POST
try:
data = request.get_json() or {}
paths = data.get('paths') or []
# task_type: 1=立即执行2=添加任务;此接口默认 2
try:
task_type = int(data.get('task_type') or 2)
except Exception:
task_type = 2
if task_type not in (1, 2):
task_type = 2
strategy = (data.get('strategy') or 'Terms').strip()
if strategy not in ('Terms', 'Simple'):
strategy = 'Terms'
if not isinstance(paths, list):
paths = []
paths = [p.strip() for p in paths if p and isinstance(p, str)]
if not paths:
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
user_id, err = _get_user_id_or_error()
if err:
return err
urls = []
for p in paths:
url, err = _upload_local_xlsx_to_oss(p, user_id)
if err:
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
base_name = os.path.basename(p)
urls.append({"fileUrl": url, "originalFilename": base_name, "relativePath": p})
params = {
"userId": user_id
}
# resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
# "content-type":"application/json",
# },data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
req_data = {"files": urls, "strategy": strategy, "taskType": 2, "archiveName": ""}
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}", headers={"content-type": "application/json"},
data=json.dumps(req_data))
print(req_data)
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id},返回", resp.text)
resp_data = resp.json()
if resp_data.get("success"):
task_id = resp_data.get("data").get("taskId")
data = resp_data.get("data")
return jsonify({'success': True, 'task_id': task_id})
return jsonify({'success': False, 'task_id': "请求异常"})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@brand_bp.route('/api/brand/tasks/<int:task_id>')
# @login_required
def api_brand_task_detail(task_id):
"""获取单个任务详情(用于轮询状态)"""
try:
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
resp_data = resp.json()
if resp_data.get("success"):
task = resp_data["data"]["task"]
return jsonify({
'success': True,
'task': task
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
# @login_required
def api_brand_task_line_progress(task_id):
"""
获取任务的“当前文件行级进度”(当前行数/总行数)。
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
"""
try:
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
resp_data = resp.json()
if resp_data.get("success"):
if resp_data["data"]["line_progress"]["has_progress"]:
info = resp_data["data"]["line_progress"]["info"]
resp = {
'file_index': int(info.get('file_index') or 0),
'file_total': int(info.get('file_total') or 0),
'file_name': info.get("file_name",""),
'current_line': int(info.get('current_line') or 0),
'total_lines': int(info.get('total_lines') or 0),
}
return jsonify({'success': True, 'has_progress': True, 'info': resp})
raise RuntimeError("获取进度失败")
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
# @login_required
def api_brand_task_events(task_id):
"""SSE任务完成时后端主动推送事件前端监听后隐藏进度条与取消按钮"""
user_id, err = _get_user_id_or_error()
if err:
return err
def _task_status_event():
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
(task_id, user_id)
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return None
st = (row.get('status') or '').lower()
if st in ('success', 'failed', 'cancelled'):
return {'status': st, 'error_message': (row.get('error_message') or '').strip() or None}
return None
# 若任务已处于终态,直接返回一条事件后结束
ev = _task_status_event()
if ev is not None:
def _one_shot():
yield "data: " + json.dumps(ev, ensure_ascii=False) + "\n\n"
return Response(
_one_shot(),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
)
# 否则注册队列,等待后台任务完成时推送
q = Queue()
with _task_event_lock:
_task_event_queues.setdefault(task_id, []).append(q)
def _stream():
try:
while True:
try:
event = q.get(timeout=20)
yield "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
return
except Empty:
yield ": keepalive\n\n"
finally:
with _task_event_lock:
lst = _task_event_queues.get(task_id, [])
if q in lst:
lst.remove(q)
if not lst:
_task_event_queues.pop(task_id, None)
return Response(
_stream(),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
)
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
# @login_required
def api_brand_task_cancel(task_id):
"""取消正在执行或等待中的任务pending/running 均可取消)"""
try:
user_id, err = _get_user_id_or_error()
if err:
return err
resp = requests.post(
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}/cancel",
headers={"accept": "application/json"},
timeout=20
)
try:
resp_data = resp.json()
except Exception:
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
if not resp_data.get('success'):
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
_mark_task_cancelled(task_id)
_push_task_event(task_id, {'status': 'cancelled'})
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@brand_bp.route('/api/brand/tasks/<int:task_id>', methods=['DELETE'])
@login_required
def api_brand_task_delete(task_id):
"""删除任务(仅限非 running 状态)"""
try:
user_id, err = _get_user_id_or_error()
if err:
return err
resp = requests.delete(
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}",
headers={"accept": "application/json"},
timeout=20
)
try:
resp_data = resp.json()
except Exception:
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
if not resp_data.get('success'):
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@brand_bp.route('/api/brand/download/<int:task_id>')
@login_required
def api_brand_download(task_id):
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
try:
user_id, err = _get_user_id_or_error()
if err:
return err
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
(task_id, user_id)
)
row = cur.fetchone()
conn.close()
if not row or not row.get('result_paths'):
return jsonify({'success': False, 'error': '无结果可下载'}), 404
raw = row['result_paths']
if isinstance(raw, str):
raw = _parse_json(raw)
url_list, zip_url = _normalize_result_paths(raw)
# 新格式:有 zip_url 直接重定向到 OSS
if zip_url and _is_url(zip_url):
return redirect(zip_url, code=302)
# 兼容旧格式result_paths 可能为 list本地路径或 url
if not url_list and isinstance(raw, list):
url_list = raw
local_paths = [p for p in url_list if isinstance(p, str) and not _is_url(p) and os.path.isfile(p)]
url_paths = [p.strip() for p in url_list if isinstance(p, str) and _is_url(p)]
if len(url_paths) == 1 and not local_paths:
return redirect(url_paths[0], code=302)
if len(local_paths) == 1 and not url_paths:
return send_file(
local_paths[0],
as_attachment=True,
download_name=os.path.basename(local_paths[0])
)
if local_paths or url_paths:
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for p in local_paths:
zf.write(p, os.path.basename(p))
for url in url_paths:
try:
import requests as req
r = req.get(url, timeout=30, stream=True)
r.raise_for_status()
name = os.path.basename(urlparse(url).path) or ('file_%s' % (url_paths.index(url)))
if not name or name == 'file_%s' % url_paths.index(url):
name = 'download_%s' % url_paths.index(url)
zf.writestr(name, r.content)
except Exception:
pass
buf.seek(0)
return send_file(
buf,
mimetype='application/zip',
as_attachment=True,
download_name='brand_task_%s.zip' % task_id
)
return jsonify({'success': False, 'error': '结果文件不存在或链接不可用'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -13,10 +13,10 @@ import subprocess
import requests
from urllib.parse import urlparse, quote
from flask import Blueprint, request, jsonify, Response, session, send_file
from flask import Blueprint, request, jsonify, Response, send_file
from PIL import Image
from app_common import get_db, login_required, BASE_DIR
from app_common import get_db, login_required, current_user_id, BASE_DIR
from config import STITCH_WORKFLOW_ID,client_name
image_bp = Blueprint('image', __name__)
@@ -146,11 +146,12 @@ def api_generate():
except (TypeError, ValueError):
hid = None
conn = get_db()
_uid = current_user_id()
with conn.cursor() as cur:
if hid is not None and hid > 0:
cur.execute(
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
(hid, session['user_id']),
(hid, _uid),
)
row = cur.fetchone()
existing_urls = []
@@ -182,7 +183,7 @@ def api_generate():
_json.dumps(_sanitize_params_for_history(params)),
_json.dumps(merged_result_urls),
hid,
session['user_id'],
_uid,
),
)
if cur.rowcount > 0:
@@ -192,7 +193,7 @@ def api_generate():
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
VALUES (%s, %s, %s, %s, %s, %s)""",
(
session['user_id'],
_uid,
params.get('panel_type', ''),
_json.dumps(result.get('original_urls') or []),
_json.dumps(_sanitize_params_for_history(params)),
@@ -366,7 +367,7 @@ def api_history():
conn = get_db()
with conn.cursor() as cur:
where_user = "user_id = %s"
params_where = [session['user_id']]
params_where = [current_user_id()]
if panel_type:
where_user += " AND panel_type = %s"
params_where.append(panel_type)

View File

@@ -1,23 +1,24 @@
"""
主页面蓝图首页、home、图片工作台、品牌页、静态文件、Logo
"""
"""
主页面蓝图首页、home、图片工作台、品牌页、静态文件、Logo
"""
import os
from flask import Blueprint, send_file, render_template_string
from app_common import (
get_db,
_render_html,
_is_session_user_valid,
login_required,
admin_required,
STATIC_DIR,
BASE_DIR,
ASSETS_DIR
)
from flask import redirect, url_for, session
from flask import Flask, request, Response, stream_with_context
import requests
from app_common import (
get_db,
_render_html,
login_required,
admin_required,
current_user_id,
current_username,
STATIC_DIR,
BASE_DIR,
ASSETS_DIR
)
from flask import redirect, url_for
from flask import Flask, request, Response, stream_with_context
import requests
from config import base_url,version,JAVA_API_BASE
main_bp = Blueprint('main', __name__)
@@ -63,35 +64,36 @@ def _resolve_asset_filename(filename):
return matches[0]
return safe_name
@main_bp.route('/')
def index():
if session.get('user_id') and _is_session_user_valid():
return redirect(url_for('main.home'))
return redirect(url_for('auth.login'))
@main_bp.route('/home')
@login_required
def home():
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
except Exception:
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
@main_bp.route('/image')
@login_required
def wb():
return _render_html('index.html')
@main_bp.route('/')
def index():
if current_user_id():
return redirect(url_for('main.home'))
return redirect(url_for('auth.login'))
@main_bp.route('/home')
@login_required
def home():
uid = current_user_id()
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (uid,))
row = cur.fetchone()
conn.close()
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE)
except Exception:
return _render_html('home.html', username=current_username(), is_admin=False, user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE)
@main_bp.route('/image')
@login_required
def wb():
return _render_html('index.html')
@main_bp.route('/brand')
@login_required
def brand_page():
@@ -112,41 +114,41 @@ def brand_page():
if raw[:7] == b'gAAAAAB':
raise
content = raw.decode('utf-8', errors='replace')
return render_template_string(content, user_id=session.get('user_id'))
return render_template_string(content, user_id=current_user_id(), java_api_base=JAVA_API_BASE)
except Exception:
continue
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(
'<header class="top-bar">',
'<header class="top-bar" style="display:none !important;">',
1,
return _render_html('brand.html', user_id=current_user_id(), java_api_base=JAVA_API_BASE)
@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(
'<header class="top-bar">',
'<header class="top-bar" style="display:none !important;">',
1,
)
content = content.replace('height: calc(100vh - 56px);', 'height: 100vh;', 1)
return render_template_string(content, user_id=session.get('user_id'))
@main_bp.route('/static/<path:filename>')
def serve_static(filename):
"""提供 static 目录及子目录下的静态文件访问。"""
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
static_abs = os.path.abspath(STATIC_DIR)
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)
return render_template_string(content, user_id=current_user_id())
@main_bp.route('/static/<path:filename>')
def serve_static(filename):
"""提供 static 目录及子目录下的静态文件访问。"""
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
static_abs = os.path.abspath(STATIC_DIR)
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)
@main_bp.route('/assets/<path:filename>')
def serve_assets(filename):
@@ -165,8 +167,8 @@ def serve_assets(filename):
if os.path.isfile(file_abs):
return send_file(file_abs, as_attachment=False)
return '', 404
@main_bp.route('/new_web_source/<path:filename>')
@login_required
def serve_new_web_source(filename):
@@ -179,7 +181,7 @@ def serve_new_web_source(filename):
if filename.lower().endswith('.html'):
with open(file_abs, 'rb') as f:
content = f.read().decode('utf-8', errors='replace')
raw_uid = session.get('user_id')
raw_uid = current_user_id()
uid_value = str(int(raw_uid)) if str(raw_uid or '').isdigit() else '""'
uid_script = (
"\n<script>"
@@ -195,22 +197,22 @@ def serve_new_web_source(filename):
@main_bp.route('/logo.jpg', methods=['GET'])
def get_logo_image():
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
@main_bp.route('/newApi/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
def proxy(path):
target_url = f"{JAVA_API_BASE}/{path}"
# 复制请求参数
params = request.args.to_dict()
# 处理请求头
headers = {}
for key, value in request.headers:
if key.lower() in ['host', 'content-length', 'connection']:
continue
headers[key] = value
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
@main_bp.route('/newApi/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
def proxy(path):
target_url = f"{JAVA_API_BASE}/{path}"
# 复制请求参数
params = request.args.to_dict()
# 处理请求头
headers = {}
for key, value in request.headers:
if key.lower() in ['host', 'content-length', 'connection']:
continue
headers[key] = value
ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch",
f"{JAVA_API_BASE}/api/delete-brand/tasks/progress/batch",
f"{JAVA_API_BASE}/api/delete-brand/history",
@@ -229,36 +231,36 @@ def proxy(path):
print("=============================")
except Exception as e:
print("打印失败", e)
try:
# 使用流式请求
req = requests.request(
method=request.method,
url=target_url,
params=params,
headers=headers,
data=request.get_data() if request.get_data() else None,
cookies=request.cookies,
stream=True, # 启用流式传输
try:
# 使用流式请求
req = requests.request(
method=request.method,
url=target_url,
params=params,
headers=headers,
data=request.get_data() if request.get_data() else None,
cookies=request.cookies,
stream=True, # 启用流式传输
timeout=proxy_timeout
)
# 流式响应
def generate():
for chunk in req.iter_content(chunk_size=8192):
if chunk:
yield chunk
# 构建响应
response = Response(stream_with_context(generate()), status=req.status_code)
# 复制响应头
for key, value in req.headers.items():
if key.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
response.headers[key] = value
return response
)
# 流式响应
def generate():
for chunk in req.iter_content(chunk_size=8192):
if chunk:
yield chunk
# 构建响应
response = Response(stream_with_context(generate()), status=req.status_code)
# 复制响应头
for key, value in req.headers.items():
if key.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
response.headers[key] = value
return response
except requests.exceptions.Timeout:
print(f"后端服务超时target_url={target_url}, timeout={proxy_timeout}")
return Response("后端服务超时", status=504)

View File

@@ -50,7 +50,6 @@ file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
debug = True

83
app/jwt_util.py Normal file
View File

@@ -0,0 +1,83 @@
"""
JWT 工具:解析 Java 端签发的 token、从请求中提取 token。
与 Java 端 JwtService 共用同一个 HS256 密钥AIIMAGE_JWT_SECRET
"""
import os
import sys
from typing import Optional, Tuple
from flask import request
try:
import jwt as pyjwt
_PYJWT_IMPORT_ERROR = None
except ImportError as _e:
pyjwt = None
_PYJWT_IMPORT_ERROR = _e
# 启动时立刻给出醒目提示,避免上线后才发现一直 401
print(
"[auth] WARNING: PyJWT 未安装,所有依赖 JWT 的接口都会返回 401。"
" 请执行 `pip install PyJWT==2.10.1` 或 `pip install -r requirements.txt`。",
file=sys.stderr,
)
_DEFAULT_SECRET = "please-change-this-secret-please-rotate-at-least-32-bytes"
COOKIE_NAME = os.getenv("AIIMAGE_AUTH_COOKIE_NAME", "aiimage_token")
def _signing_key() -> bytes:
"""与 Java JwtService.signingKey 保持一致UTF-8 字节,不足 32 字节右侧补 0。"""
secret = os.getenv("AIIMAGE_JWT_SECRET", _DEFAULT_SECRET)
key_bytes = secret.encode("utf-8")
if len(key_bytes) < 32:
key_bytes = key_bytes + b"\x00" * (32 - len(key_bytes))
return key_bytes
def parse_token_with_reason(token: str) -> Tuple[Optional[dict], Optional[str]]:
"""解析 JWT返回 (payload, error_reason)。
payload 命中时 error_reason 为 None失败时 payload 为 Noneerror_reason 描述根因。"""
if not token:
return None, "token 为空"
if pyjwt is None:
return None, f"PyJWT 未安装({_PYJWT_IMPORT_ERROR}"
# 打印 token 头,便于发现 alg 不是 HS256 等情况(不验签,仅 base64 解码 header
try:
header = pyjwt.get_unverified_header(token)
except Exception as e:
return None, f"无法解析 token header: {type(e).__name__}: {e}"
alg = header.get("alg") or "?"
try:
payload = pyjwt.decode(token, _signing_key(), algorithms=["HS256", "HS384", "HS512"])
except Exception as e:
# 常见ExpiredSignatureError / InvalidSignatureError / DecodeError / InvalidAlgorithmError
return None, f"{type(e).__name__}: {e} (token alg={alg})"
sub = payload.get("sub")
try:
user_id = int(sub) if sub is not None else None
except (TypeError, ValueError):
return None, f"sub 字段非法: {sub!r}"
if user_id is None:
return None, "payload 缺少 sub 字段"
return {
"user_id": user_id,
"username": payload.get("username") or "",
"device_id": payload.get("deviceId") or "",
}, None
def parse_token(token: str) -> Optional[dict]:
"""解析 JWT返回 {user_id, username, device_id};失败返回 None。"""
payload, _ = parse_token_with_reason(token)
return payload
def get_token_from_request() -> str:
"""优先从 Authorization: Bearer 取,其次从 cookie 取。"""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[len("Bearer "):].strip()
if token:
return token
return (request.cookies.get(COOKIE_NAME) or "").strip()

View File

@@ -20,6 +20,7 @@ import subprocess
from amazon.del_brand import kill_process
from app import run_app
from generate_api import generate
from tool.devices import DeviceIDGenerator
with open("version.txt","w",encoding="utf-8") as file:
@@ -347,6 +348,13 @@ class WindowAPI:
except Exception as e:
return {'success': False, 'error': str(e)}
def get_device_id(self):
"""暴露给 HTML 的设备指纹接口:复用桌面端硬件特征生成 64 位 SHA256 ID。"""
try:
return {'success': True, 'device_id': DeviceIDGenerator().get_device_id()}
except Exception as e:
return {'success': False, 'error': str(e)}
def get_detail_del(self,task_id):
"""获取正在执行的删除品牌任务详情"""
task_info = runing_task.get(task_id)
@@ -432,7 +440,7 @@ def main():
api = WindowAPI(window)
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new)
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new,api.get_device_id)
webview.start(
debug=True,
storage_path=cache_path,

View File

@@ -54,6 +54,7 @@ pyinstaller==6.19.0
pyinstaller-hooks-contrib==2026.3
PyMsgBox==2.0.1
PyMySQL==1.1.2
PyJWT==2.10.1
pyperclip==1.11.0
PyQt5==5.15.11
PyQt5-Qt5==5.15.2

583
app/web_source/admin.html Normal file
View File

@@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - 数富AI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
background: #f5f5f5;
padding: 24px;
}
.nav { margin-bottom: 24px; }
.nav a { color: #667eea; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
h1 { font-size: 20px; margin-bottom: 20px; }
/* Tabs */
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: #666;
font-size: 15px;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab:hover { color: #667eea; }
.tab.active { color: #667eea; font-weight: 600; border-bottom-color: #667eea; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Form & Table */
.form-box, .panel-box {
background: #fff;
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 14px; margin-bottom: 6px; }
.form-group input, .form-group select {
width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;
}
.form-group input[type="checkbox"] { width: auto; margin-right: 8px; }
.form-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 16px; }
.form-row .form-group { margin-bottom: 0; flex: 1; min-width: 120px; }
.btn { padding: 10px 20px; background: #667eea; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
.btn:hover { opacity: 0.9; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.btn-danger { background: #e74c3c; }
.btn-secondary { background: #95a5a6; }
.msg { margin-top: 12px; font-size: 14px; }
.msg.ok { color: #27ae60; }
.msg.err { color: #e74c3c; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
th { background: #f8f9fa; font-weight: 600; }
.pagination {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.pagination span { font-size: 14px; color: #666; }
.pagination button { padding: 6px 12px; border: 1px solid #ddd; background: #fff; cursor: pointer; border-radius: 4px; }
.pagination button:hover:not(:disabled) { background: #f0f0f0; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
.thumb-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
.empty-tip { color: #999; font-size: 14px; padding: 24px; text-align: center; }
.modal-mask {
display: none;
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4); z-index: 1000;
align-items: center; justify-content: center;
}
.modal-mask.show { display: flex; }
.modal { background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; max-width: 90%; }
.modal h3 { margin-bottom: 16px; font-size: 16px; }
</style>
</head>
<body>
<div class="nav"><a href="/home">← 返回首页</a></div>
<h1>管理后台</h1>
<div class="tabs">
<div class="tab active" data-tab="users">用户管理</div>
<div class="tab" data-tab="history">查看生成记录</div>
</div>
<!-- 用户管理 -->
<div id="panel-users" class="tab-panel active">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">创建用户</h3>
<p style="margin-bottom:16px;font-size:13px;color:#666;">无注册入口,仅管理员可在此创建用户。层级:超级管理员 → 管理员 → 普通号。</p>
<div class="form-group">
<label>用户名</label>
<input type="text" id="username" placeholder="用户名至少2个字符">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="password" placeholder="密码至少6个字符">
</div>
<div class="form-group" id="formGroupRole">
<label>角色</label>
<select id="createRole">
<option value="normal">普通号</option>
<option value="admin" id="optAdmin">管理员</option>
</select>
</div>
<div class="form-group" id="formGroupCreatedBy" style="display:none;">
<label>所属管理员</label>
<select id="createCreatedBy">
<option value="">请选择管理员</option>
</select>
</div>
<button class="btn" id="btnCreate">创建用户</button>
<p class="msg" id="msgCreate"></p>
</div>
<div class="panel-box">
<h3 style="margin-bottom:16px;font-size:15px;">用户列表</h3>
<div class="form-row" style="margin-bottom:16px;">
<div class="form-group" style="min-width:180px;">
<label>用户名(模糊搜索)</label>
<input type="text" id="searchUsername" placeholder="输入用户名关键字">
</div>
<div class="form-group" id="filterCreatedByGroup" style="min-width:160px;display:none;">
<label>所属管理员</label>
<select id="filterCreatedBy">
<option value="">全部</option>
</select>
</div>
<button class="btn" id="btnSearchUsers">查询</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>角色</th>
<th>所属管理员</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="userListBody"></tbody>
</table>
<div class="pagination" id="userPagination"></div>
</div>
</div>
<!-- 查看生成记录 -->
<div id="panel-history" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
<div class="form-row">
<div class="form-group" style="min-width:140px;">
<label>指定用户</label>
<select id="filterUser">
<option value="">全部用户</option>
</select>
</div>
<div class="form-group" style="min-width:140px;">
<label>开始时间</label>
<input type="datetime-local" id="filterTimeStart">
</div>
<div class="form-group" style="min-width:140px;">
<label>结束时间</label>
<input type="datetime-local" id="filterTimeEnd">
</div>
<button class="btn" id="btnFilterHistory">查询</button>
</div>
</div>
<div class="panel-box">
<h3 style="margin-bottom:16px;font-size:15px;">生成记录</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户</th>
<th>类型</th>
<th>创建时间</th>
<th>结果预览</th>
</tr>
</thead>
<tbody id="historyListBody"></tbody>
</table>
<div class="pagination" id="historyPagination"></div>
</div>
</div>
<!-- 编辑用户弹窗 -->
<div class="modal-mask" id="editUserModal">
<div class="modal">
<h3>编辑用户</h3>
<input type="hidden" id="editUserId">
<div class="form-group">
<label>用户名</label>
<input type="text" id="editUsername" readonly style="background:#f5f5f5;">
</div>
<div class="form-group">
<label>新密码(不修改留空)</label>
<input type="password" id="editPassword" placeholder="留空则不修改密码">
</div>
<div class="form-group" id="editFormGroupRole" style="display:none;">
<label>角色</label>
<select id="editRole">
<option value="normal">普通号</option>
<option value="admin">管理员</option>
</select>
</div>
<div class="form-group" id="editFormGroupCreator" style="display:none;">
<label>所属管理员</label>
<input type="text" id="editCreatorName" readonly style="background:#f5f5f5;">
</div>
<p class="msg" id="msgEdit"></p>
<div style="margin-top:16px;display:flex;gap:8px;">
<button class="btn" id="btnSaveUser">保存</button>
<button class="btn btn-secondary" id="btnCloseEdit">取消</button>
</div>
</div>
</div>
<script>
(function() {
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
function buildJavaUrl(path) { return JAVA_API_BASE + path; }
function getAuthToken() {
try { return localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { return ''; }
}
function authHeaders(extra) {
var headers = extra || {};
var token = getAuthToken();
if (token) headers['Authorization'] = 'Bearer ' + token;
return headers;
}
function jsonHeaders() {
return authHeaders({ 'Content-Type': 'application/json' });
}
function parseJson(r) { return r.json().catch(function() { return {}; }); }
function errMsg(res) {
return (res && (res.message || res.error)) || '请求失败';
}
if (!JAVA_API_BASE) {
alert('Java 服务地址未配置,请联系管理员设置 java_api_base');
}
// Tab 切换
document.querySelectorAll('.tab').forEach(function(t) {
t.onclick = function() {
document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(x) { x.classList.remove('active'); });
t.classList.add('active');
var id = 'panel-' + t.dataset.tab;
document.getElementById(id).classList.add('active');
if (t.dataset.tab === 'users') loadUsers(1);
else if (t.dataset.tab === 'history') loadHistory(1);
};
});
// ========== 用户管理 ==========
var userPage = 1, userPageSize = 15;
var currentUserRole = 'admin';
var adminsList = [];
function roleLabel(role) {
if (role === 'super_admin') return '超级管理员';
if (role === 'admin') return '管理员';
return '普通号';
}
function buildUserListQuery(page) {
var q = 'page=' + (page || 1) + '&page_size=' + userPageSize;
var kw = (document.getElementById('searchUsername').value || '').trim();
if (kw) q += '&username=' + encodeURIComponent(kw);
var cby = document.getElementById('filterCreatedBy').value;
if (cby) q += '&created_by_id=' + encodeURIComponent(cby);
return q;
}
function loadUsers(page) {
userPage = page || 1;
fetch(buildJavaUrl('/api/admin/users?' + buildUserListQuery(userPage)), {
credentials: 'include',
headers: authHeaders()
})
.then(parseJson)
.then(function(res) {
var tbody = document.getElementById('userListBody');
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
return;
}
var data = res.data || {};
currentUserRole = data.current_user_role || 'admin';
adminsList = data.admins || [];
var items = data.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无用户</td></tr>';
} else {
tbody.innerHTML = items.map(function(u) {
return '<tr><td>' + u.id + '</td><td>' + (u.username || '') + '</td><td>' +
roleLabel(u.role || 'normal') + '</td><td>' + (u.creator_username || '-') + '</td><td>' + (u.created_at || '') + '</td><td>' +
'<button class="btn btn-sm" data-edit="' + u.id + '" data-user="' + (JSON.stringify(u).replace(/"/g, '&quot;')) + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-delete="' + u.id + '" data-name="' + (u.username || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</td></tr>';
}).join('');
}
renderPagination('userPagination', data.total, data.page, data.page_size, loadUsers);
bindUserActions();
updateCreateFormByRole();
updateUserFilterByRole();
})
.catch(function() {
document.getElementById('userListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
});
}
function updateUserFilterByRole() {
var grp = document.getElementById('filterCreatedByGroup');
var sel = document.getElementById('filterCreatedBy');
if (currentUserRole === 'super_admin') {
grp.style.display = 'block';
var cur = sel.value;
sel.innerHTML = '<option value="">全部</option>';
adminsList.forEach(function(a) {
var opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.username;
sel.appendChild(opt);
});
sel.value = cur || '';
} else {
grp.style.display = 'none';
}
}
function updateCreateFormByRole() {
var roleSel = document.getElementById('createRole');
var optAdmin = document.getElementById('optAdmin');
var formCreatedBy = document.getElementById('formGroupCreatedBy');
var selCreatedBy = document.getElementById('createCreatedBy');
if (currentUserRole === 'super_admin') {
if (optAdmin) optAdmin.style.display = '';
formCreatedBy.style.display = (roleSel.value === 'normal') ? 'block' : 'none';
selCreatedBy.innerHTML = '<option value="">请选择管理员</option>';
adminsList.forEach(function(a) {
var opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.username;
selCreatedBy.appendChild(opt);
});
} else {
if (optAdmin) optAdmin.style.display = 'none';
roleSel.value = 'normal';
formCreatedBy.style.display = 'none';
}
}
function bindUserActions() {
document.querySelectorAll('[data-edit]').forEach(function(btn) {
btn.onclick = function() {
var raw = (btn.getAttribute('data-user') || '{}').replace(/&quot;/g, '"');
var u;
try { u = JSON.parse(raw); } catch (e) { u = {}; }
document.getElementById('editUserId').value = u.id || '';
document.getElementById('editUsername').value = u.username || '';
document.getElementById('editPassword').value = '';
var editRole = document.getElementById('editRole');
var editFormGroupRole = document.getElementById('editFormGroupRole');
var editFormGroupCreator = document.getElementById('editFormGroupCreator');
var editCreatorName = document.getElementById('editCreatorName');
editFormGroupRole.style.display = (currentUserRole === 'super_admin' && u.role !== 'super_admin') ? 'block' : 'none';
editFormGroupCreator.style.display = (u.role === 'normal' && u.creator_username) ? 'block' : 'none';
editCreatorName.value = u.creator_username || '';
if (u.role !== 'super_admin') { editRole.value = u.role || 'normal'; }
document.getElementById('msgEdit').textContent = '';
document.getElementById('editUserModal').classList.add('show');
};
});
document.querySelectorAll('[data-delete]').forEach(function(btn) {
btn.onclick = function() {
if (!confirm('确定删除用户 "' + (btn.dataset.name || '') + '" 吗?')) return;
fetch(buildJavaUrl('/api/admin/user/' + btn.dataset.delete), {
method: 'DELETE',
credentials: 'include',
headers: authHeaders()
})
.then(parseJson)
.then(function(res) {
if (res.success) { loadUsers(userPage); }
else { alert(errMsg(res)); }
});
};
});
}
document.getElementById('btnSearchUsers').onclick = function() { loadUsers(1); };
document.getElementById('createRole').onchange = function() { updateCreateFormByRole(); };
document.getElementById('btnCreate').onclick = function() {
var username = (document.getElementById('username').value || '').trim();
var password = document.getElementById('password').value || '';
var role = document.getElementById('createRole').value || 'normal';
var createdById = document.getElementById('createCreatedBy').value ? parseInt(document.getElementById('createCreatedBy').value, 10) : null;
var msgEl = document.getElementById('msgCreate');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!username || username.length < 2) {
msgEl.textContent = '用户名至少2个字符';
msgEl.classList.add('err');
return;
}
if (!password || password.length < 6) {
msgEl.textContent = '密码至少6个字符';
msgEl.classList.add('err');
return;
}
var body = { username: username, password: password, role: role };
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.createdById = createdById;
fetch(buildJavaUrl('/api/admin/user'), {
method: 'POST',
credentials: 'include',
headers: jsonHeaders(),
body: JSON.stringify(body)
})
.then(parseJson)
.then(function(res) {
if (res.success) {
msgEl.textContent = res.message || '创建成功';
msgEl.classList.add('ok');
document.getElementById('username').value = '';
document.getElementById('password').value = '';
loadUsers(1);
} else {
msgEl.textContent = errMsg(res);
msgEl.classList.add('err');
}
})
.catch(function() {
msgEl.textContent = '请求失败';
msgEl.classList.add('err');
});
};
document.getElementById('btnSaveUser').onclick = function() {
var uid = document.getElementById('editUserId').value;
var password = document.getElementById('editPassword').value;
var editRoleEl = document.getElementById('editRole');
var msgEl = document.getElementById('msgEdit');
msgEl.textContent = '';
msgEl.className = 'msg';
var body = {};
if (password) body.password = password;
if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null)
body.role = editRoleEl.value || 'normal';
fetch(buildJavaUrl('/api/admin/user/' + uid), {
method: 'PUT',
credentials: 'include',
headers: jsonHeaders(),
body: JSON.stringify(body)
})
.then(parseJson)
.then(function(res) {
if (res.success) {
msgEl.textContent = res.message || '保存成功';
msgEl.classList.add('ok');
document.getElementById('editUserModal').classList.remove('show');
loadUsers(userPage);
} else {
msgEl.textContent = errMsg(res);
msgEl.classList.add('err');
}
});
};
document.getElementById('btnCloseEdit').onclick = function() {
document.getElementById('editUserModal').classList.remove('show');
};
// ========== 生成记录 ==========
var historyPage = 1, historyPageSize = 15;
function toSqlDatetime(val) {
if (!val) return '';
return val.replace('T', ' ');
}
function buildHistoryQuery(page) {
var q = 'page=' + (page || 1) + '&page_size=' + historyPageSize;
var uid = document.getElementById('filterUser').value;
var start = toSqlDatetime(document.getElementById('filterTimeStart').value);
var end = toSqlDatetime(document.getElementById('filterTimeEnd').value);
if (uid) q += '&user_id=' + uid;
if (start) q += '&time_start=' + encodeURIComponent(start);
if (end) q += '&time_end=' + encodeURIComponent(end);
return q;
}
function loadHistory(page) {
historyPage = page || 1;
fetch(buildJavaUrl('/api/admin/history?' + buildHistoryQuery(historyPage)), {
credentials: 'include',
headers: authHeaders()
})
.then(parseJson)
.then(function(res) {
var tbody = document.getElementById('historyListBody');
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
return;
}
var data = res.data || {};
var items = data.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无记录</td></tr>';
} else {
tbody.innerHTML = items.map(function(h) {
var urls = (h.result_urls || []);
var thumbUrls = (h.long_image_url ? [h.long_image_url] : []).concat(urls);
var thumbs = thumbUrls.slice(0, 3).map(function(url) {
return '<img src="' + (url || '').replace(/"/g, '&quot;') + '" class="thumb" alt="">';
}).join('');
return '<tr><td>' + h.id + '</td><td>' + (h.username || '-') + '</td><td>' +
(h.panel_type || '-') + '</td><td>' + (h.created_at || '') + '</td><td>' +
'<div class="thumb-wrap">' + (thumbs || '-') + '</div></td></tr>';
}).join('');
}
renderPagination('historyPagination', data.total, data.page, data.page_size, loadHistory);
})
.catch(function() {
document.getElementById('historyListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
});
}
function loadUserOptions() {
fetch(buildJavaUrl('/api/admin/users?page=1&page_size=50'), {
credentials: 'include',
headers: authHeaders()
})
.then(parseJson)
.then(function(res) {
var sel = document.getElementById('filterUser');
var cur = sel.value;
sel.innerHTML = '<option value="">全部用户</option>';
var data = res.data || {};
(data.items || []).forEach(function(u) {
var opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.username + ' (' + roleLabel(u.role || 'normal') + ')';
sel.appendChild(opt);
});
sel.value = cur || '';
});
}
document.getElementById('btnFilterHistory').onclick = function() { loadHistory(1); };
// ========== 分页 ==========
function renderPagination(elId, total, page, pageSize, onPage) {
var el = document.getElementById(elId);
if (!el) return;
total = total || 0;
page = page || 1;
pageSize = pageSize || 15;
var totalPages = Math.max(1, Math.ceil(total / pageSize));
el.innerHTML = '<span>共 ' + total + ' 条</span>' +
'<button ' + (page <= 1 ? 'disabled' : '') + ' data-p="' + (page - 1) + '">上一页</button>' +
'<span>第 ' + page + ' / ' + totalPages + ' 页</span>' +
'<button ' + (page >= totalPages ? 'disabled' : '') + ' data-p="' + (page + 1) + '">下一页</button>';
el.querySelectorAll('[data-p]').forEach(function(b) {
if (!b.disabled) b.onclick = function() { onPage(parseInt(b.dataset.p, 10)); };
});
}
// 初始化
loadUsers(1);
loadUserOptions();
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1450
app/web_source/brand.html Normal file

File diff suppressed because it is too large Load Diff

387
app/web_source/home.html Normal file
View File

@@ -0,0 +1,387 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>首页 - 数富AI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
background: linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)),
url("/static/bg.jpg") center/cover no-repeat;;
/*background-image: url("/static/bg.jpg");*/
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: rgba(255,255,255,0.5);
}
.header-title { font-size: 18px; font-weight: 600; color: #333; }
.header-right { display: flex; align-items: center; gap: 16px; position: relative; }
.header-right a, .header-right span {
font-size: 14px; color: #555; text-decoration: none;
}
.header-right a:hover { color: #667eea; }
.entrances {
display: flex;
gap: 32px;
flex-wrap: wrap;
justify-content: center;
}
.entrance-btn {
width: 160px;
height: 100px;
background: #c5c1c1;
border: 4px solid #b8d4e3;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #333;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.entrance-btn:hover {
background: #f8fbfd;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.entrance-btn.wb { cursor: pointer; }
.entrance-btn.disabled {
cursor: not-allowed;
opacity: 0.9;
}
.toast {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.75);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.toast.show { opacity: 1; }
.admin-link {
font-size: 13px;
color: #888;
}
.update-section {
margin-top: 48px;
padding: 20px 24px;
background: rgba(255,255,255,0.85);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06);
max-width: 420px;
}
.update-section-title {
font-size: 14px;
color: #333;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.update-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.update-version-text {
font-size: 13px;
color: #555;
}
.btn-check-update {
padding: 8px 14px;
font-size: 13px;
background: #667eea;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-check-update:hover {
background: #5a6fd6;
}
.update-hint {
font-size: 12px;
color: #666;
margin-top: 8px;
min-height: 18px;
}
.btn-download-update {
margin-top: 8px;
padding: 8px 14px;
font-size: 13px;
background: #28a745;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-download-update:hover {
background: #218838;
}
.header-update-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(102,126,234,0.12);
color: #4c5bd4;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
padding: 0;
}
.header-update-btn:hover {
background: rgba(102,126,234,0.2);
}
.header-update-panel {
position: absolute;
top: 44px;
right: 0;
width: 280px;
padding: 14px 16px;
background: rgba(255,255,255,0.98);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
border: 1px solid rgba(0,0,0,0.04);
z-index: 10;
display: none;
}
.header-update-title {
font-size: 13px;
color: #333;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
</style>
</head>
<body data-user-id="{{ user_id or '' }}">
<header class="header">
<span class="header-title">数富AI</span>
<div class="header-right">
{% if is_admin %}
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
{% endif %}
<span>{{ username }}</span>
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新"></button>
<a href="/logout" onclick="return handleLogout(event);">退出</a>
<div class="header-update-panel" id="homeUpdatePanel">
<div class="header-update-title"><span></span> 软件更新</div>
<div class="update-block">
<span class="update-version-text" id="homeVersionText">当前版本: {{ version }}</span>
<button type="button" class="btn-check-update" id="homeBtnCheckUpdate"><span></span> 检测</button>
</div>
<div class="update-hint" id="homeUpdateHint"></div>
<div style="margin-top: 4px;">
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate" style="display: none;">立即更新</button>
</div>
</div>
</div>
</header>
<div class="entrances" id="entrances">
<a class="entrance-btn" href="/brand" data-column-key="brand">亚马逊</a>
<a class="entrance-btn disabled" href="javascript:;" data-msg="暂未开通,敬请期待" data-column-key="wb">wildberries</a>
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
</div>
<div class="toast" id="toast"></div>
<script>
function getAppPermissionCacheKey(uid) {
return 'app_column_permissions:' + String(uid || '');
}
// 权限校验:根据 Java column-permissions 接口按 column_key 显示入口
(function() {
localStorage.setItem("uid",{{ user_id }})
var uid = document.body.getAttribute('data-user-id');
if (!uid) return;
var cacheKey = getAppPermissionCacheKey(uid);
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
var token = '';
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
if (!JAVA_API_BASE || !token) return;
var apiUrl = JAVA_API_BASE + '/api/admin/permission-users/' + uid + '/column-permissions?menuType=app';
fetch(apiUrl, {
credentials: 'include',
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (!res || !res.success || !Array.isArray(res.data)) return;
try {
localStorage.setItem(cacheKey, JSON.stringify(res.data));
} catch (e) {}
var allowedKeys = res.data.map(function(item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
});
})
.catch(function() {});
})();
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
btn.onclick = function(e) {
e.preventDefault();
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
var t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(function() { t.classList.remove('show'); }, 2000);
};
});
function handleLogout(ev) {
if (ev && ev.preventDefault) ev.preventDefault();
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
var token = '';
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
function cleanLocal() {
try {
localStorage.removeItem('maixiang_api_key');
localStorage.removeItem(AUTH_TOKEN_KEY);
var uid = document.body.getAttribute('data-user-id');
if (uid) {
localStorage.removeItem(getAppPermissionCacheKey(uid));
}
} catch (e) { console.log(e); }
}
function gotoPythonLogout() {
cleanLocal();
window.location.href = '/logout';
}
if (!JAVA_API_BASE || !token) {
gotoPythonLogout();
return false;
}
// 先让 Java 端清 cookie/会话,再走 Python /logout 清 Python cookie
var headers = { 'Authorization': 'Bearer ' + token };
try {
fetch(JAVA_API_BASE + '/logout', {
method: 'POST',
credentials: 'include',
headers: headers
}).then(gotoPythonLogout).catch(gotoPythonLogout);
} catch (e) {
gotoPythonLogout();
}
return false;
}
// 软件更新(与 index 设置中逻辑一致)
(function() {
const versionText = document.getElementById('homeVersionText');
const updateHint = document.getElementById('homeUpdateHint');
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
const updatePanel = document.getElementById('homeUpdatePanel');
const toggleBtn = document.getElementById('homeUpdateToggle');
if (!versionText || !updateHint || !btnDownload || !updatePanel || !toggleBtn) return;
let currentVersion = '{{version}}';
versionText.textContent = '当前版本: v' + currentVersion;
// 打开/关闭浮层
toggleBtn.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = updatePanel.style.display === 'block';
updatePanel.style.display = isVisible ? 'none' : 'block';
});
// 点击外部关闭浮层
document.addEventListener('click', function() {
updatePanel.style.display = 'none';
});
updatePanel.addEventListener('click', function(e) {
e.stopPropagation();
});
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
updateHint.textContent = '正在检测更新...';
btnDownload.style.display = 'none';
try {
const resp = await fetch('/api/version', { credentials: 'same-origin' }).catch(function() { return null; });
if (resp && resp.ok) {
const data = await resp.json();
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
versionText.textContent = '当前版本: v' + currentVersion;
if (data.has_update && data.latest_version) {
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? '' + data.desc : '');
btnDownload.style.display = 'inline-block';
btnDownload.textContent = '立即更新';
btnDownload.onclick = async function() {
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
if (!data.file_url) {
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
return;
}
updateHint.textContent = '正在下载并准备更新,程序将自动退出...';
btnDownload.disabled = true;
try {
const updateResp = await fetch('/api/update/do', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_url: data.file_url })
});
const result = await updateResp.json().catch(function() { return {}; });
if (result.success) {
updateHint.textContent = '更新已启动,程序即将退出...';
} else {
updateHint.textContent = result.error || '更新启动失败';
btnDownload.disabled = false;
}
} catch (e) {
updateHint.textContent = '请求更新失败,请重试';
btnDownload.disabled = false;
}
};
} else {
updateHint.textContent = '已是最新版本';
}
} else {
updateHint.textContent = '无法连接更新服务,请稍后重试。';
}
} catch (e) {
updateHint.textContent = '检测更新失败';
}
};
btnDownload.style.display = 'none';
})();
</script>
</body>
</html>

5833
app/web_source/index.html Normal file

File diff suppressed because it is too large Load Diff

263
app/web_source/login.html Normal file
View File

@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 南日AI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
background: #d8e4ec;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: rgba(255,255,255,0.5);
}
.header-title { font-size: 18px; font-weight: 600; color: #333; }
.login-box {
background: #fff;
border: 1px solid #b8d4e3;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
padding: 40px;
width: 100%;
max-width: 380px;
}
.login-title {
font-size: 24px;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
color: #555;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px 14px;
font-size: 14px;
border: 1px solid #b8d4e3;
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
background: #fff;
}
.form-group input:focus {
border-color: #3498db;
}
.error-msg {
color: #e74c3c;
font-size: 13px;
margin-bottom: 12px;
text-align: center;
}
.btn-login {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 500;
color: #fff;
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.btn-login:hover {
opacity: 0.9;
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
</head>
<body>
<header class="header">
<span class="header-title">南日AI</span>
</header>
<div class="login-box">
<h1 class="login-title">登录</h1>
{% if error %}
<p class="error-msg">{{ error }}</p>
{% endif %}
<form id="loginForm" method="POST" action="/login">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" placeholder="请输入用户名" required autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码" required>
</div>
<button type="submit" class="btn-login" id="btnLogin">登录</button>
</form>
</div>
<script>
var JAVA_API_BASE = "{{ java_api_base or '' }}";
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
var DEVICE_ID_KEY = 'aiimage_device_id';
function clearAppPermissionCaches() {
try {
var keysToRemove = [];
for (var i = 0; i < localStorage.length; i++) {
var key = localStorage.key(i);
if (key && key.indexOf('app_column_permissions:') === 0) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(function(key) {
localStorage.removeItem(key);
});
} catch (e) {}
}
function waitForPywebview(timeoutMs) {
return new Promise(function(resolve) {
if (window.pywebview && window.pywebview.api && window.pywebview.api.get_device_id) {
resolve(window.pywebview.api);
return;
}
var done = false;
var finish = function() {
if (done) return;
done = true;
var api = (window.pywebview && window.pywebview.api) || null;
resolve(api);
};
window.addEventListener('pywebviewready', finish, { once: true });
setTimeout(finish, timeoutMs || 1500);
});
}
function fetchDeviceId() {
return waitForPywebview(2000).then(function(api) {
if (!api || typeof api.get_device_id !== 'function') {
return '';
}
try {
var ret = api.get_device_id();
return Promise.resolve(ret).then(function(res) {
if (res && res.success && res.device_id) {
try { localStorage.setItem(DEVICE_ID_KEY, res.device_id); } catch (e) {}
return res.device_id;
}
return '';
}).catch(function() { return ''; });
} catch (e) {
return '';
}
});
}
function buildJavaUrl(path) {
var base = (JAVA_API_BASE || '').replace(/\/+$/, '');
return base + path;
}
(function() {
var params = new URLSearchParams(window.location.search || '');
if (params.get('logout') === '1' || params.get('switch') === '1') {
try { localStorage.removeItem(AUTH_TOKEN_KEY); } catch (e) {}
}
})();
document.getElementById('loginForm').onsubmit = function(e) {
e.preventDefault();
var btn = document.getElementById('btnLogin');
btn.disabled = true;
btn.textContent = '登录中...';
var form = e.target;
var username = (form.username.value || '').trim();
var password = form.password.value || '';
function showError(msg) {
var errEl = document.querySelector('.error-msg');
if (!errEl) {
errEl = document.createElement('p');
errEl.className = 'error-msg';
form.insertBefore(errEl, form.firstChild);
}
errEl.textContent = msg || '登录失败';
btn.disabled = false;
btn.textContent = '登录';
}
fetchDeviceId().then(function(deviceId) {
if (!deviceId) {
showError('未获取到设备ID请在桌面端打开');
return;
}
var payload = { username: username, password: password, deviceId: deviceId };
return fetch(buildJavaUrl('/login'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Device-Id': deviceId,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (!res || !res.success) {
showError((res && res.message) || '登录失败');
return;
}
var data = res.data || {};
if (data.token) {
try { localStorage.setItem(AUTH_TOKEN_KEY, data.token); } catch (e) {}
}
// 把 Java 签发的 JWT 同步到 Python 套壳同源 cookie供后续页面跳转携带
return fetch('/api/auth/sync', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ token: data.token })
})
.then(function(r) { return r.json().catch(function() { return {}; }); })
.then(function() {
clearAppPermissionCaches();
window.location.href = '/home';
})
.catch(function() {
clearAppPermissionCaches();
window.location.href = '/home';
});
})
.catch(function() {
showError('网络异常,请稍后重试');
});
});
};
</script>
</body>
</html>

View File

@@ -26,6 +26,7 @@
<rocketmq-spring.version>2.3.5</rocketmq-spring.version>
<minio.version>8.5.17</minio.version>
<javassist.version>3.28.0-GA</javassist.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencyManagement>
@@ -104,6 +105,23 @@
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@@ -31,7 +31,7 @@ public class SecurityConfig {
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("Content-Disposition"));
configuration.setAllowCredentials(false);
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

View File

@@ -0,0 +1,76 @@
package com.nanri.aiimage.modules.admin.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
import com.nanri.aiimage.modules.admin.service.AdminUserService;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin")
@Tag(name = "管理后台-用户", description = "用户列表、创建、更新、删除")
public class AdminUserController {
private final AdminAuthSupport adminAuthSupport;
private final AdminUserService adminUserService;
@GetMapping("/users")
@Operation(summary = "分页查询用户列表")
public ApiResponse<AdminUserListVo> listUsers(HttpServletRequest request,
@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam(name = "page_size", required = false, defaultValue = "15") Integer pageSize,
@RequestParam(required = false) String username,
@RequestParam(name = "search", required = false) String search,
@RequestParam(name = "created_by_id", required = false) Long createdById,
@RequestParam(name = "admin_id", required = false) Long adminId) {
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
String kw = (username == null || username.isBlank()) ? search : username;
Long filterCreatedBy = createdById != null ? createdById : adminId;
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy);
return ApiResponse.success(vo);
}
@PostMapping("/user")
@Operation(summary = "创建用户")
public ApiResponse<Void> createUser(HttpServletRequest request,
@RequestBody AdminUserCreateRequest body) {
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
adminUserService.createUser(currentUser, body);
return ApiResponse.success("用户创建成功", null);
}
@PutMapping("/user/{uid}")
@Operation(summary = "更新用户")
public ApiResponse<Void> updateUser(HttpServletRequest request,
@PathVariable Long uid,
@RequestBody AdminUserUpdateRequest body) {
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
adminUserService.updateUser(currentUser, uid, body);
return ApiResponse.success("更新成功", null);
}
@DeleteMapping("/user/{uid}")
@Operation(summary = "删除用户")
public ApiResponse<Void> deleteUser(HttpServletRequest request,
@PathVariable Long uid) {
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
adminUserService.deleteUser(currentUser, uid);
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -0,0 +1,11 @@
package com.nanri.aiimage.modules.admin.model.dto;
import lombok.Data;
@Data
public class AdminUserCreateRequest {
private String username;
private String password;
private String role;
private Long createdById;
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.admin.model.dto;
import lombok.Data;
@Data
public class AdminUserUpdateRequest {
private String password;
private String role;
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.admin.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AdminBriefVo {
private Long id;
private String username;
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.admin.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class AdminUserItemVo {
private Long id;
private String username;
@JsonProperty("is_admin")
private boolean admin;
private String role;
@JsonProperty("created_by_id")
private Long createdById;
@JsonProperty("creator_username")
private String creatorUsername;
@JsonProperty("created_at")
private String createdAt;
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.admin.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
public class AdminUserListVo {
private List<AdminUserItemVo> items;
private Long total;
private Integer page;
@JsonProperty("page_size")
private Integer pageSize;
@JsonProperty("current_user_role")
private String currentUserRole;
private List<AdminBriefVo> admins;
}

View File

@@ -0,0 +1,269 @@
package com.nanri.aiimage.modules.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
import com.nanri.aiimage.modules.admin.model.vo.AdminBriefVo;
import com.nanri.aiimage.modules.admin.model.vo.AdminUserItemVo;
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AdminUserService {
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final AdminUserMapper adminUserMapper;
private final WerkzeugPasswordEncoder passwordEncoder;
private final AdminAuthSupport adminAuthSupport;
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
String username, Long createdById) {
String role = adminAuthSupport.currentRole(currentUser);
if (role == null) {
throw new BusinessException(403, "需要管理员权限");
}
int safePage = page == null || page < 1 ? 1 : page;
int safeSize = pageSize == null ? 15 : pageSize;
if (safeSize < 5) safeSize = 5;
if (safeSize > 50) safeSize = 50;
String kw = username == null ? "" : username.trim();
Long filterCreatedBy = "super_admin".equals(role) ? createdById : null;
LambdaQueryWrapper<AdminUserEntity> query = new LambdaQueryWrapper<>();
if ("super_admin".equals(role)) {
if (!kw.isEmpty()) {
query.like(AdminUserEntity::getUsername, kw);
}
if (filterCreatedBy != null) {
query.eq(AdminUserEntity::getCreatedById, filterCreatedBy);
}
} else {
Long adminId = currentUser.getId();
query.and(w -> w
.eq(AdminUserEntity::getId, adminId)
.or(inner -> inner
.eq(AdminUserEntity::getRole, "normal")
.eq(AdminUserEntity::getCreatedById, adminId)));
if (!kw.isEmpty()) {
query.like(AdminUserEntity::getUsername, kw);
}
}
query.orderByAsc(AdminUserEntity::getId);
Long total = adminUserMapper.selectCount(query);
int offset = (safePage - 1) * safeSize;
query.last("LIMIT " + safeSize + " OFFSET " + offset);
List<AdminUserEntity> rows = adminUserMapper.selectList(query);
Map<Long, String> creatorMap = loadCreatorMap(rows);
List<AdminUserItemVo> items = rows.stream().map(r -> toItem(r, creatorMap)).toList();
List<AdminBriefVo> admins = "super_admin".equals(role)
? adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getRole, "admin")
.orderByAsc(AdminUserEntity::getId)).stream()
.map(u -> new AdminBriefVo(u.getId(), u.getUsername()))
.toList()
: List.of();
AdminUserListVo vo = new AdminUserListVo();
vo.setItems(items);
vo.setTotal(total == null ? 0L : total);
vo.setPage(safePage);
vo.setPageSize(safeSize);
vo.setCurrentUserRole(role);
vo.setAdmins(admins);
return vo;
}
public void createUser(AdminUserEntity currentUser, AdminUserCreateRequest request) {
String role = adminAuthSupport.currentRole(currentUser);
if (role == null) {
throw new BusinessException(403, "需要管理员权限");
}
String username = request.getUsername() == null ? "" : request.getUsername().trim();
String password = request.getPassword() == null ? "" : request.getPassword();
String wantRole = request.getRole() == null ? "normal" : request.getRole().trim();
if (wantRole.isEmpty()) wantRole = "normal";
if (!"admin".equals(wantRole) && !"normal".equals(wantRole)) {
wantRole = "normal";
}
if ("admin".equals(role) && "admin".equals(wantRole)) {
throw new BusinessException("仅超级管理员可创建管理员");
}
if (username.isEmpty() || password.isEmpty()) {
throw new BusinessException("用户名和密码不能为空");
}
if (username.length() < 2) {
throw new BusinessException("用户名至少2个字符");
}
if (password.length() < 6) {
throw new BusinessException("密码至少6个字符");
}
Long wantCreatedBy;
if ("admin".equals(wantRole)) {
wantCreatedBy = currentUser.getId();
} else if ("super_admin".equals(role)) {
wantCreatedBy = request.getCreatedById();
if (wantCreatedBy == null) {
AdminUserEntity firstAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getRole, "admin")
.orderByAsc(AdminUserEntity::getId)
.last("LIMIT 1"));
wantCreatedBy = firstAdmin != null ? firstAdmin.getId() : currentUser.getId();
}
} else {
wantCreatedBy = currentUser.getId();
}
int isAdmin = ("super_admin".equals(wantRole) || "admin".equals(wantRole)) ? 1 : 0;
AdminUserEntity entity = new AdminUserEntity();
entity.setUsername(username);
entity.setPasswordHash(passwordEncoder.hash(password));
entity.setIsAdmin(isAdmin);
entity.setRole(wantRole);
entity.setCreatedById(wantCreatedBy);
try {
adminUserMapper.insert(entity);
} catch (DuplicateKeyException e) {
throw new BusinessException("用户名已存在");
}
}
public void updateUser(AdminUserEntity currentUser, Long uid, AdminUserUpdateRequest request) {
String role = adminAuthSupport.currentRole(currentUser);
if (role == null) {
throw new BusinessException(403, "需要管理员权限");
}
String password = request.getPassword();
String wantRole = request.getRole() == null ? null : request.getRole().trim();
if ((password == null || password.isEmpty()) && (wantRole == null || wantRole.isEmpty())) {
throw new BusinessException("请提供要修改的内容");
}
AdminUserEntity target = adminUserMapper.selectById(uid);
if (target == null) {
throw new BusinessException("用户不存在");
}
String targetRole = target.getRole() == null ? "" : target.getRole();
if ("admin".equals(role)) {
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
throw new BusinessException(403, "只能编辑自己创建的普通用户");
}
wantRole = null;
} else {
if ("super_admin".equals(targetRole)) {
throw new BusinessException("不能修改超级管理员");
}
if ("super_admin".equals(wantRole)) {
throw new BusinessException("不能将用户设为超级管理员");
}
if (wantRole != null && !"admin".equals(wantRole) && !"normal".equals(wantRole) && !wantRole.isEmpty()) {
wantRole = null;
}
}
LambdaUpdateWrapper<AdminUserEntity> update = new LambdaUpdateWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getId, uid);
boolean dirty = false;
if (password != null && !password.isEmpty()) {
if (password.length() < 6) {
throw new BusinessException("密码至少6个字符");
}
update.set(AdminUserEntity::getPasswordHash, passwordEncoder.hash(password));
dirty = true;
}
if (wantRole != null && !wantRole.isEmpty()) {
int isAdmin = "admin".equals(wantRole) ? 1 : 0;
update.set(AdminUserEntity::getIsAdmin, isAdmin);
update.set(AdminUserEntity::getRole, wantRole);
dirty = true;
}
if (dirty) {
adminUserMapper.update(null, update);
}
}
public void deleteUser(AdminUserEntity currentUser, Long uid) {
String role = adminAuthSupport.currentRole(currentUser);
if (role == null) {
throw new BusinessException(403, "需要管理员权限");
}
if (currentUser.getId().equals(uid)) {
throw new BusinessException("不能删除当前登录账号");
}
AdminUserEntity target = adminUserMapper.selectById(uid);
if (target == null) {
throw new BusinessException("用户不存在");
}
String targetRole = target.getRole() == null ? "" : target.getRole();
if ("super_admin".equals(targetRole)) {
throw new BusinessException("不能删除超级管理员");
}
if ("admin".equals(role)) {
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
throw new BusinessException(403, "只能删除自己创建的普通用户");
}
}
int affected = adminUserMapper.deleteById(uid);
if (affected == 0) {
throw new BusinessException("用户不存在");
}
}
private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) {
Set<Long> creatorIds = new LinkedHashSet<>();
for (AdminUserEntity r : rows) {
if (r.getCreatedById() != null && r.getCreatedById() > 0) {
creatorIds.add(r.getCreatedById());
}
}
if (creatorIds.isEmpty()) {
return Map.of();
}
List<AdminUserEntity> creators = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.in(AdminUserEntity::getId, new ArrayList<>(creatorIds)));
Map<Long, String> map = new HashMap<>(creators.size() * 2);
for (AdminUserEntity c : creators) {
map.put(c.getId(), c.getUsername() == null ? "" : c.getUsername());
}
return map;
}
private AdminUserItemVo toItem(AdminUserEntity entity, Map<Long, String> creatorMap) {
AdminUserItemVo vo = new AdminUserItemVo();
vo.setId(entity.getId());
vo.setUsername(entity.getUsername());
vo.setAdmin(entity.getIsAdmin() != null && entity.getIsAdmin() == 1);
vo.setRole(entity.getRole() == null || entity.getRole().isEmpty() ? "normal" : entity.getRole());
vo.setCreatedById(entity.getCreatedById());
vo.setCreatorUsername(creatorMap.getOrDefault(entity.getCreatedById(), ""));
LocalDateTime createdAt = entity.getCreatedAt();
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
return vo;
}
}

View File

@@ -0,0 +1,109 @@
package com.nanri.aiimage.modules.admin.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.auth.service.JwtService;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
@Component
@RequiredArgsConstructor
public class AdminAuthSupport {
private final JwtService jwtService;
private final AdminUserMapper adminUserMapper;
private final AuthProperties authProperties;
/** 解析当前请求的用户token 缺失或无效抛 401。 */
public AdminUserEntity requireUser(HttpServletRequest request) {
String token = resolveToken(request);
if (token == null || token.isBlank()) {
throw new BusinessException(401, "未登录");
}
Claims claims = jwtService.parse(token);
if (claims == null) {
throw new BusinessException(401, "登录已过期,请重新登录");
}
Long userId;
try {
userId = Long.parseLong(claims.getSubject());
} catch (NumberFormatException e) {
throw new BusinessException(401, "登录态无效");
}
AdminUserEntity user = adminUserMapper.selectById(userId);
if (user == null) {
throw new BusinessException(401, "用户不存在");
}
return user;
}
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */
public AdminUserEntity requireAdmin(HttpServletRequest request) {
AdminUserEntity user = requireUser(request);
String role = currentRole(user);
if (role == null) {
throw new BusinessException(403, "需要管理员权限");
}
return user;
}
/** 计算当前用户的管理角色super_admin / admin / null。 */
public String currentRole(AdminUserEntity user) {
if (user == null) {
return null;
}
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
if ("super_admin".equals(storedRole)) {
return "super_admin";
}
if ("admin".equals(storedRole)) {
return resolveAdminRole(user);
}
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
if (isAdminFlag) {
return resolveAdminRole(user);
}
return null;
}
/** 数据库里 role='admin' 中 id 最小者视作超级管理员(与 Python 行为一致)。 */
private String resolveAdminRole(AdminUserEntity user) {
AdminUserEntity superAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getRole, "admin")
.orderByAsc(AdminUserEntity::getId)
.last("LIMIT 1"));
if (superAdmin != null && superAdmin.getId().equals(user.getId())) {
return "super_admin";
}
return "admin";
}
private String resolveToken(HttpServletRequest request) {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String t = authHeader.substring(7).trim();
if (!t.isEmpty()) {
return t;
}
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
String cookieName = authProperties.getCookieName();
for (Cookie cookie : cookies) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
}

View File

@@ -362,7 +362,10 @@ public class AppearancePatentCozeClient {
parameters.put("title_list", titles);
parameters.put("url_list", urls);
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, titles, urls));
parameters.put("prompt", prompt == null ? "" : prompt);
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
if (prompt != null && !prompt.isBlank()) {
parameters.put("prompt", prompt);
}
if (apiKey != null && !apiKey.isBlank()) {
parameters.put("api_key", apiKey.trim());
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.auth.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "aiimage.auth")
public class AuthProperties {
private String jwtSecret = "please-change-this-secret-please-rotate-at-least-32-bytes";
private long jwtTtlHours = 168L;
private String cookieName = "aiimage_token";
private boolean cookieSecure = false;
private String cookieSameSite = "Lax";
}

View File

@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.auth.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
import com.nanri.aiimage.modules.auth.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@Tag(name = "登录认证", description = "桌面端登录与会话校验")
public class LoginController {
private static final String DEVICE_ID_HEADER = "X-Device-Id";
private final AuthService authService;
@PostMapping("/login")
@Operation(summary = "用户名密码登录")
public ResponseEntity<ApiResponse<LoginResultVo>> login(@RequestBody LoginRequest request) {
LoginResultVo result = authService.login(request);
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(ApiResponse.success("登录成功", result));
}
@GetMapping("/check_login")
@Operation(summary = "校验登录态")
public ResponseEntity<ApiResponse<LoginResultVo>> checkLogin(
HttpServletRequest httpRequest,
@RequestHeader(value = DEVICE_ID_HEADER, required = false) String deviceIdHeader) {
String token = resolveToken(httpRequest);
LoginResultVo result = authService.checkLogin(token, deviceIdHeader);
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(ApiResponse.success(result));
}
@PostMapping("/logout")
@Operation(summary = "登出")
public ResponseEntity<ApiResponse<Void>> logout() {
ResponseCookie cookie = authService.buildClearCookie();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(ApiResponse.success("已登出", null));
}
private String resolveToken(HttpServletRequest request) {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7).trim();
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
String cookieName = authService.cookieName();
for (Cookie cookie : cookies) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LoginUserMapper extends BaseMapper<LoginUserEntity> {
}

View File

@@ -0,0 +1,11 @@
package com.nanri.aiimage.modules.auth.model.dto;
import lombok.Data;
@Data
public class LoginRequest {
private String username;
private String password;
private String deviceId;
}

View File

@@ -0,0 +1,24 @@
package com.nanri.aiimage.modules.auth.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("users")
public class LoginUserEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
@TableField("password_hash")
private String passwordHash;
private String machine;
@TableField("is_admin")
private Integer isAdmin;
private String role;
@TableField("created_by_id")
private Long createdById;
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.auth.model.vo;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import lombok.Data;
import java.util.List;
@Data
public class LoginResultVo {
private Long userId;
private String username;
private String role;
private Boolean isAdmin;
private String deviceId;
private String token;
private Long expiresIn;
private List<PermissionMenuItemVo> appColumns;
private List<PermissionMenuItemVo> adminColumns;
}

View File

@@ -0,0 +1,158 @@
package com.nanri.aiimage.modules.auth.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {
private final LoginUserMapper loginUserMapper;
private final WerkzeugPasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final PermissionMenuService permissionMenuService;
private final AuthProperties authProperties;
public LoginResultVo login(LoginRequest request) {
String username = trim(request.getUsername());
String password = request.getPassword() == null ? "" : request.getPassword();
String deviceId = trim(request.getDeviceId());
if (username.isEmpty() || password.isEmpty()) {
throw new BusinessException("请输入用户名和密码");
}
if (deviceId.isEmpty()) {
throw new BusinessException("缺少设备ID请在桌面端打开");
}
LoginUserEntity user = loginUserMapper.selectOne(new LambdaQueryWrapper<LoginUserEntity>()
.eq(LoginUserEntity::getUsername, username)
.last("LIMIT 1"));
if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
throw new BusinessException("用户名或密码错误");
}
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
if (stored.isEmpty()) {
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
.eq(LoginUserEntity::getId, user.getId())
.set(LoginUserEntity::getMachine, deviceId));
stored = deviceId;
log.info("[auth] first-login bind userId={} device={}", user.getId(), deviceId);
} else if (!stored.equals(deviceId)) {
if (isAdmin) {
log.warn("[auth] device mismatch but admin bypass userId={} stored={} current={}",
user.getId(), stored, deviceId);
} else {
log.warn("[auth] device mismatch reject userId={} stored={} current={}",
user.getId(), stored, deviceId);
throw new BusinessException("当前设备与首次登录设备不一致,请在原设备上登录");
}
} else {
log.info("[auth] device match userId={} isAdmin={} device={}",
user.getId(), isAdmin, deviceId);
}
return buildResult(user, stored, isAdmin);
}
public LoginResultVo checkLogin(String token, String currentDeviceId) {
if (token == null || token.isBlank()) {
throw new BusinessException(401, "未登录");
}
Claims claims = jwtService.parse(token);
if (claims == null) {
throw new BusinessException(401, "登录已过期,请重新登录");
}
Long userId;
try {
userId = Long.parseLong(claims.getSubject());
} catch (NumberFormatException e) {
throw new BusinessException(401, "登录态无效");
}
LoginUserEntity user = loginUserMapper.selectById(userId);
if (user == null) {
throw new BusinessException(401, "用户不存在");
}
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
String device = trim(currentDeviceId);
if (device.isEmpty()) {
// 没传设备 ID 时,回落到 token 内 deviceId
Object claimDevice = claims.get("deviceId");
device = claimDevice == null ? "" : claimDevice.toString().trim();
}
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
if (isAdmin) {
log.warn("[auth] check_login device mismatch but admin bypass userId={} stored={} current={}",
user.getId(), stored, device);
} else {
log.warn("[auth] check_login device mismatch reject userId={} stored={} current={}",
user.getId(), stored, device);
throw new BusinessException(401, "当前设备与首次登录设备不一致");
}
}
return buildResult(user, stored.isEmpty() ? device : stored, isAdmin);
}
public ResponseCookie buildAuthCookie(String token) {
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(authProperties.getCookieName(), token)
.httpOnly(true)
.path("/")
.maxAge(Duration.ofSeconds(jwtService.ttlSeconds()))
.sameSite(authProperties.getCookieSameSite());
if (authProperties.isCookieSecure()) {
builder.secure(true);
}
return builder.build();
}
public ResponseCookie buildClearCookie() {
return ResponseCookie.from(authProperties.getCookieName(), "")
.httpOnly(true)
.path("/")
.maxAge(Duration.ZERO)
.sameSite(authProperties.getCookieSameSite())
.secure(authProperties.isCookieSecure())
.build();
}
public String cookieName() {
return authProperties.getCookieName();
}
private LoginResultVo buildResult(LoginUserEntity user, String deviceId, boolean isAdmin) {
String token = jwtService.issue(user.getId(), user.getUsername(), deviceId);
LoginResultVo vo = new LoginResultVo();
vo.setUserId(user.getId());
vo.setUsername(user.getUsername());
vo.setRole(user.getRole());
vo.setIsAdmin(isAdmin);
vo.setDeviceId(deviceId);
vo.setToken(token);
vo.setExpiresIn(jwtService.ttlSeconds());
vo.setAppColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_APP));
vo.setAdminColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_ADMIN));
return vo;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,64 @@
package com.nanri.aiimage.modules.auth.service;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
@Service
@RequiredArgsConstructor
@Slf4j
public class JwtService {
private final AuthProperties props;
private SecretKey signingKey() {
byte[] keyBytes = props.getJwtSecret().getBytes(StandardCharsets.UTF_8);
if (keyBytes.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
keyBytes = padded;
}
return Keys.hmacShaKeyFor(keyBytes);
}
public String issue(Long userId, String username, String deviceId) {
Instant now = Instant.now();
Instant exp = now.plus(Duration.ofHours(props.getJwtTtlHours()));
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("username", username)
.claim("deviceId", deviceId)
.issuedAt(Date.from(now))
.expiration(Date.from(exp))
.signWith(signingKey())
.compact();
}
public Claims parse(String token) {
try {
return Jwts.parser()
.verifyWith(signingKey())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException | IllegalArgumentException e) {
log.debug("[auth] jwt parse failed: {}", e.getMessage());
return null;
}
}
public long ttlSeconds() {
return Duration.ofHours(props.getJwtTtlHours()).toSeconds();
}
}

View File

@@ -0,0 +1,96 @@
package com.nanri.aiimage.modules.auth.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.HexFormat;
/**
* 兼容 Werkzeug `generate_password_hash` 的 PBKDF2-HMAC-SHA256 输出格式。
* 形如pbkdf2:sha256:iterations$salt$hexdigestWerkzeug 默认 600000 轮、salt 16 位)。
*/
@Component
@Slf4j
public class WerkzeugPasswordEncoder {
private static final int DK_BITS = 32 * 8;
private static final int DEFAULT_ITERATIONS = 600_000;
private static final int SALT_LENGTH = 16;
private static final String SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final SecureRandom RANDOM = new SecureRandom();
public String hash(String rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("password cannot be null");
}
String salt = generateSalt();
try {
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(),
salt.getBytes(StandardCharsets.UTF_8), DEFAULT_ITERATIONS, DK_BITS);
String hex = HexFormat.of().formatHex(derived);
return "pbkdf2:sha256:" + DEFAULT_ITERATIONS + "$" + salt + "$" + hex;
} catch (Exception e) {
throw new IllegalStateException("生成密码摘要失败: " + e.getMessage(), e);
}
}
public boolean matches(String rawPassword, String storedHash) {
if (rawPassword == null || storedHash == null || storedHash.isBlank()) {
return false;
}
try {
int firstSep = storedHash.indexOf('$');
int secondSep = storedHash.indexOf('$', firstSep + 1);
if (firstSep < 0 || secondSep < 0) {
return false;
}
String method = storedHash.substring(0, firstSep);
String salt = storedHash.substring(firstSep + 1, secondSep);
String expectedHex = storedHash.substring(secondSep + 1);
if (!method.startsWith("pbkdf2:sha256")) {
log.warn("[auth] unsupported password hash scheme: {}", method);
return false;
}
String[] parts = method.split(":");
int iterations = parts.length >= 3 ? Integer.parseInt(parts[2]) : 600_000;
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(), salt.getBytes(StandardCharsets.UTF_8), iterations, DK_BITS);
String actualHex = HexFormat.of().formatHex(derived);
return constantTimeEquals(actualHex, expectedHex);
} catch (Exception e) {
log.warn("[auth] verify password failed: {}", e.getMessage());
return false;
}
}
private static byte[] pbkdf2Sha256(char[] password, byte[] salt, int iterations, int keyBits) throws Exception {
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBits);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} finally {
spec.clearPassword();
}
}
private static boolean constantTimeEquals(String a, String b) {
if (a == null || b == null || a.length() != b.length()) {
return false;
}
int diff = 0;
for (int i = 0; i < a.length(); i++) {
diff |= a.charAt(i) ^ b.charAt(i);
}
return diff == 0;
}
private static String generateSalt() {
StringBuilder sb = new StringBuilder(SALT_LENGTH);
for (int i = 0; i < SALT_LENGTH; i++) {
sb.append(SALT_ALPHABET.charAt(RANDOM.nextInt(SALT_ALPHABET.length())));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,37 @@
package com.nanri.aiimage.modules.imagehistory.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryListVo;
import com.nanri.aiimage.modules.imagehistory.service.ImageHistoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin")
@Tag(name = "管理后台-生成历史", description = "管理员查看所有用户的生成历史")
public class ImageHistoryController {
private final AdminAuthSupport adminAuthSupport;
private final ImageHistoryService imageHistoryService;
@GetMapping("/history")
@Operation(summary = "分页查询所有用户生成历史")
public ApiResponse<ImageHistoryListVo> listHistory(HttpServletRequest request,
@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam(name = "page_size", required = false, defaultValue = "15") Integer pageSize,
@RequestParam(name = "user_id", required = false) Long userId,
@RequestParam(name = "time_start", required = false) String timeStart,
@RequestParam(name = "time_end", required = false) String timeEnd) {
adminAuthSupport.requireAdmin(request);
ImageHistoryListVo vo = imageHistoryService.listHistory(page, pageSize, userId, timeStart, timeEnd);
return ApiResponse.success(vo);
}
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.imagehistory.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.imagehistory.model.entity.ImageHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ImageHistoryMapper extends BaseMapper<ImageHistoryEntity> {
}

View File

@@ -0,0 +1,31 @@
package com.nanri.aiimage.modules.imagehistory.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("image_history")
public class ImageHistoryEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("user_id")
private Long userId;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("panel_type")
private String panelType;
/** JSON 字段,按字符串读出后由 service 解析。 */
@TableField("original_urls")
private String originalUrls;
private String params;
@TableField("result_urls")
private String resultUrls;
@TableField("long_image_url")
private String longImageUrl;
}

View File

@@ -0,0 +1,26 @@
package com.nanri.aiimage.modules.imagehistory.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class ImageHistoryItemVo {
private Long id;
@JsonProperty("user_id")
private Long userId;
private String username;
@JsonProperty("created_at")
private String createdAt;
@JsonProperty("panel_type")
private String panelType;
@JsonProperty("original_urls")
private List<String> originalUrls;
private Map<String, Object> params;
@JsonProperty("result_urls")
private List<String> resultUrls;
@JsonProperty("long_image_url")
private String longImageUrl;
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.imagehistory.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
public class ImageHistoryListVo {
private List<ImageHistoryItemVo> items;
private Long total;
private Integer page;
@JsonProperty("page_size")
private Integer pageSize;
}

View File

@@ -0,0 +1,166 @@
package com.nanri.aiimage.modules.imagehistory.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.imagehistory.mapper.ImageHistoryMapper;
import com.nanri.aiimage.modules.imagehistory.model.entity.ImageHistoryEntity;
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryItemVo;
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryListVo;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
public class ImageHistoryService {
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DateTimeFormatter ISO_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter ISO_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final TypeReference<List<String>> LIST_STRING_REF = new TypeReference<>() {
};
private static final TypeReference<Map<String, Object>> MAP_REF = new TypeReference<>() {
};
private final ImageHistoryMapper imageHistoryMapper;
private final AdminUserMapper adminUserMapper;
private final ObjectMapper objectMapper;
public ImageHistoryListVo listHistory(Integer page, Integer pageSize, Long userId,
String timeStart, String timeEnd) {
int safePage = page == null || page < 1 ? 1 : page;
int safeSize = pageSize == null ? 15 : pageSize;
if (safeSize < 10) safeSize = 10;
if (safeSize > 50) safeSize = 50;
LambdaQueryWrapper<ImageHistoryEntity> query = new LambdaQueryWrapper<>();
if (userId != null && userId > 0) {
query.eq(ImageHistoryEntity::getUserId, userId);
}
LocalDateTime startDt = parseDateTime(timeStart, false);
if (startDt != null) {
query.ge(ImageHistoryEntity::getCreatedAt, startDt);
}
LocalDateTime endDt = parseDateTime(timeEnd, true);
if (endDt != null) {
query.le(ImageHistoryEntity::getCreatedAt, endDt);
}
Long total = imageHistoryMapper.selectCount(query);
query.orderByDesc(ImageHistoryEntity::getCreatedAt);
int offset = (safePage - 1) * safeSize;
query.last("LIMIT " + safeSize + " OFFSET " + offset);
List<ImageHistoryEntity> rows = imageHistoryMapper.selectList(query);
Map<Long, String> usernameMap = loadUsernameMap(rows);
List<ImageHistoryItemVo> items = new ArrayList<>(rows.size());
for (ImageHistoryEntity r : rows) {
items.add(toItem(r, usernameMap));
}
ImageHistoryListVo vo = new ImageHistoryListVo();
vo.setItems(items);
vo.setTotal(total == null ? 0L : total);
vo.setPage(safePage);
vo.setPageSize(safeSize);
return vo;
}
private Map<Long, String> loadUsernameMap(List<ImageHistoryEntity> rows) {
Set<Long> userIds = new LinkedHashSet<>();
for (ImageHistoryEntity r : rows) {
if (r.getUserId() != null && r.getUserId() > 0) {
userIds.add(r.getUserId());
}
}
if (userIds.isEmpty()) {
return Map.of();
}
List<AdminUserEntity> users = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.in(AdminUserEntity::getId, new ArrayList<>(userIds)));
Map<Long, String> map = new HashMap<>(users.size() * 2);
for (AdminUserEntity u : users) {
map.put(u.getId(), u.getUsername() == null ? "" : u.getUsername());
}
return map;
}
private ImageHistoryItemVo toItem(ImageHistoryEntity entity, Map<Long, String> usernameMap) {
ImageHistoryItemVo vo = new ImageHistoryItemVo();
vo.setId(entity.getId());
vo.setUserId(entity.getUserId());
String username = usernameMap.get(entity.getUserId());
vo.setUsername(username == null || username.isEmpty() ? "-" : username);
LocalDateTime createdAt = entity.getCreatedAt();
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
vo.setPanelType(entity.getPanelType() == null ? "" : entity.getPanelType());
vo.setOriginalUrls(parseList(entity.getOriginalUrls()));
vo.setParams(parseMap(entity.getParams()));
vo.setResultUrls(parseList(entity.getResultUrls()));
String longImageUrl = entity.getLongImageUrl();
vo.setLongImageUrl(longImageUrl == null || longImageUrl.trim().isEmpty() ? null : longImageUrl.trim());
return vo;
}
private List<String> parseList(String json) {
if (json == null || json.isBlank()) {
return Collections.emptyList();
}
try {
List<String> v = objectMapper.readValue(json, LIST_STRING_REF);
return v == null ? Collections.emptyList() : v;
} catch (Exception e) {
log.warn("[image-history] 解析 JSON 列表失败: {}", e.getMessage());
return Collections.emptyList();
}
}
private Map<String, Object> parseMap(String json) {
if (json == null || json.isBlank()) {
return Collections.emptyMap();
}
try {
Map<String, Object> v = objectMapper.readValue(json, MAP_REF);
return v == null ? Collections.emptyMap() : v;
} catch (Exception e) {
log.warn("[image-history] 解析 JSON 对象失败: {}", e.getMessage());
return Collections.emptyMap();
}
}
private LocalDateTime parseDateTime(String text, boolean endOfDay) {
if (text == null) {
return null;
}
String t = text.trim();
if (t.isEmpty()) {
return null;
}
try {
if (t.length() <= 10) {
LocalDateTime base = java.time.LocalDate.parse(t, ISO_DATE_FORMATTER).atStartOfDay();
return endOfDay ? base.withHour(23).withMinute(59).withSecond(59) : base;
}
return LocalDateTime.parse(t.replace('T', ' '), ISO_DATETIME_FORMATTER);
} catch (Exception e) {
throw new BusinessException("时间格式不正确: " + text);
}
}
}

View File

@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("users")
public class AdminUserEntity {
@@ -13,7 +15,14 @@ public class AdminUserEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
@TableField("password_hash")
private String passwordHash;
@TableField("is_admin")
private Integer isAdmin;
private String role;
@TableField("created_by_id")
private Long createdById;
@TableField("created_at")
private LocalDateTime createdAt;
private String machine;
}

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.permission.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.time.LocalDateTime;
@@ -9,9 +10,14 @@ public class PermissionMenuItemVo {
private Long id;
private String name;
@JsonProperty("column_key")
private String columnKey;
@JsonProperty("menu_type")
private String menuType;
@JsonProperty("route_path")
private String routePath;
@JsonProperty("sort_order")
private Integer sortOrder;
@JsonProperty("created_at")
private LocalDateTime createdAt;
}

View File

@@ -355,9 +355,14 @@ public class SimilarAsinCozeClient {
List<String> urls = rows.stream().map(this::primaryImageUrl).toList();
List<List<String>> urlLists = rows.stream().map(this::imageUrls).toList();
List<Map<String, Object>> items = buildItemObjects(asins, titles, skus, urls, urlLists);
logCozeItemsDiff(rows, items);
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("items", buildItemObjects(asins, titles, skus, urls, urlLists));
parameters.put("prompt", prompt == null ? "" : prompt);
parameters.put("items", items);
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
if (prompt != null && !prompt.isBlank()) {
parameters.put("prompt", prompt);
}
// legacy flag当线上 coze 工作流回退到老契约时,把环境变量
// AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=true 即可重新塞 api_key。
boolean includeLegacyApiKey = properties.isCozeIncludeLegacyApiKey();
@@ -367,6 +372,51 @@ public class SimilarAsinCozeClient {
return parameters;
}
/**
* 打印每一行 row 在送入 Coze 前后的关键字段,便于排查 Python -> Java -> Coze 的字段是否被改写。
* 对照点asin / url / urls(size+样例) / title / sku以及组装到 Coze 后的 url / target_urls。
*/
private void logCozeItemsDiff(List<SimilarAsinResultRowDto> rows, List<Map<String, Object>> items) {
if (rows == null || items == null) {
return;
}
int size = Math.min(rows.size(), items.size());
log.info("[similar-asin] coze items diff start batchSize={}", size);
for (int i = 0; i < size; i++) {
SimilarAsinResultRowDto row = rows.get(i);
Map<String, Object> item = items.get(i);
if (row == null || item == null) {
continue;
}
List<String> rowUrls = safeUrls(row.getUrls());
String rowUrl = safeText(row.getUrl());
Object outUrl = item.get("url");
Object outTargetUrls = item.get("target_urls");
int outTargetSize = (outTargetUrls instanceof List<?> list) ? list.size() : 0;
boolean urlMatch = String.valueOf(outUrl == null ? "" : outUrl).equals(rowUrl);
boolean targetMatch = (outTargetUrls instanceof List<?> outList)
&& outList.size() == rowUrls.size()
&& outList.equals(rowUrls);
log.info("[similar-asin] coze item idx={} asin={} title={} sku={} rowUrl={} rowUrlsSize={} rowUrlsHead={} rowUrlsTail={} outUrl={} outTargetSize={} outTargetHead={} outTargetTail={} urlMatch={} targetUrlsMatch={}",
i,
safeText(row.getAsin()),
abbreviate(safeText(row.getTitle()), 80),
safeText(row.getSku()),
abbreviate(rowUrl, 200),
rowUrls.size(),
rowUrls.isEmpty() ? "" : abbreviate(rowUrls.get(0), 200),
rowUrls.size() <= 1 ? "" : abbreviate(rowUrls.get(rowUrls.size() - 1), 200),
abbreviate(String.valueOf(outUrl == null ? "" : outUrl), 200),
outTargetSize,
(outTargetUrls instanceof List<?> headList && !headList.isEmpty())
? abbreviate(String.valueOf(headList.get(0)), 200) : "",
(outTargetUrls instanceof List<?> tailList && tailList.size() > 1)
? abbreviate(String.valueOf(tailList.get(tailList.size() - 1)), 200) : "",
urlMatch,
targetMatch);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> maskCozeRequestBody(Map<String, Object> body) {
Map<String, Object> masked = new LinkedHashMap<>(body);

View File

@@ -9,14 +9,11 @@ import lombok.Data;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Data
@Schema(description = "相似ASIN检测单行商品数据")
public class SimilarAsinResultRowDto {
private static final int MAX_IMAGE_URLS = 24;
@Schema(description = "来源文件 key。多文件回传时用于避免行结果串到别的文件。", example = "uploads/20260426/similar_asin_17.xlsx")
private String sourceFileKey;
@@ -47,10 +44,10 @@ public class SimilarAsinResultRowDto {
@Schema(description = "商品价格。来自 Excel 解析或 Python 回传。", example = "12.29")
private String price;
@Schema(description = "兼容旧链路的首个图片 URL。新链路允许 url 传字符串或数组数组会同步写入 urls", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
@Schema(description = "商品主图 URL。Python 端解析的代表图。允许传字符串或数组数组取首个非空),与 urls 互不影响", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
private String url;
@Schema(description = "商品图 URL 列表,最多保留 24 个非空地址。请求中也可以直接把 url 传成数组", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]")
@Schema(description = "同类商品图 URL 列表。Python 端原样回传,后端不再去重/截断/与 url 互写", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]")
private List<String> urls = new ArrayList<>();
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
@@ -125,11 +122,7 @@ public class SimilarAsinResultRowDto {
private String puzzleImg2;
public String getUrl() {
if (url != null && !url.isBlank()) {
return url;
}
List<String> normalized = getUrls();
return normalized.isEmpty() ? "" : normalized.get(0);
return url == null ? "" : url;
}
@JsonSetter("url")
@@ -140,20 +133,24 @@ public class SimilarAsinResultRowDto {
"image", "img", "pic", "picture", "link", "imageLink", "image_link"
})
public void setUrl(Object value) {
List<String> normalized = normalizeUrls(value);
this.urls = normalized;
// 仅写 url 字段;不再回写 urls遵循"url 主图 / urls 同类图"语义。
// 兼容历史输入:若传入数组/集合,仅取第一个非空值作为 url不影响 urls。
if (value == null) {
this.url = "";
return;
}
if (value instanceof String text) {
this.url = text.trim();
return;
}
List<String> normalized = new ArrayList<>();
appendUrls(normalized, value);
this.url = normalized.isEmpty() ? "" : normalized.get(0);
}
public List<String> getUrls() {
List<String> normalized = normalizeUrls(urls);
if (url != null && !url.isBlank() && normalized.stream().noneMatch(url.trim()::equals)) {
List<String> combined = new ArrayList<>(normalized.size() + 1);
combined.add(url.trim());
combined.addAll(normalized);
return limitUrls(combined);
}
return normalized;
// 直接返回 Python 回传的 urls不再把 url 强推到首位、不去重、不截断。
return cleanUrls(urls);
}
@JsonSetter("urls")
@@ -166,19 +163,18 @@ public class SimilarAsinResultRowDto {
"图片链接", "商品图片", "商品主图", "主图", "主图链接"
})
public void setUrls(Object urls) {
List<String> normalized = normalizeUrls(urls);
// 仅写 urls 字段;不再回写 url避免 url/urls 交叉污染。
List<String> normalized = new ArrayList<>();
appendUrls(normalized, urls);
this.urls = normalized;
this.url = normalized.isEmpty() ? "" : normalized.get(0);
}
public boolean hasImageUrl() {
return !getUrls().isEmpty();
}
private static List<String> normalizeUrls(Object value) {
List<String> result = new ArrayList<>();
appendUrls(result, value);
return limitUrls(result);
// url主图和 urls同类商品图任一存在即可作为可送 Coze 的素材。
if (url != null && !url.isBlank()) {
return true;
}
return !cleanUrls(urls).isEmpty();
}
private static void appendUrls(List<String> result, Object value) {
@@ -214,19 +210,21 @@ public class SimilarAsinResultRowDto {
}
}
private static List<String> limitUrls(List<String> values) {
Set<String> deduplicated = new LinkedHashSet<>();
if (values != null) {
for (String value : values) {
if (value == null || value.isBlank()) {
continue;
}
deduplicated.add(value.trim());
if (deduplicated.size() >= MAX_IMAGE_URLS) {
break;
}
private static List<String> cleanUrls(List<String> values) {
// 仅做空值过滤与 trim不去重、不截断原样保留 Python 回传顺序与数量。
if (values == null || values.isEmpty()) {
return new ArrayList<>();
}
List<String> result = new ArrayList<>(values.size());
for (String value : values) {
if (value == null) {
continue;
}
String trimmed = value.trim();
if (!trimmed.isBlank()) {
result.add(trimmed);
}
}
return new ArrayList<>(deduplicated);
return result;
}
}

View File

@@ -1301,11 +1301,56 @@ public class SimilarAsinTaskService {
rows.add(row);
}
}
logPythonInboundRows(rows);
return rows;
}
return List.of();
}
/**
* 打印 Python 端回传给 Java 的每一行 row 关键字段,确认 url主图/ urls同类商品图/ title / sku
* 是否按预期到达。该日志与 SimilarAsinCozeClient 的 coze items diff 日志成对,
* 便于排查"Python 回传了什么、Java 又把什么发到 Coze"。
*/
private void logPythonInboundRows(List<SimilarAsinResultRowDto> rows) {
if (rows == null || rows.isEmpty()) {
return;
}
log.info("[similar-asin] python inbound start size={}", rows.size());
for (int i = 0; i < rows.size(); i++) {
SimilarAsinResultRowDto row = rows.get(i);
if (row == null) {
continue;
}
String url = row.getUrl();
List<String> urls = row.getUrls();
log.info("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} url={} urlsSize={} urlsHead={} urlsTail={}",
i,
normalize(row.getGroupKey()),
normalize(row.getRowToken()),
normalize(row.getId()),
normalize(row.getAsin()),
normalize(row.getCountry()),
abbreviateForLog(row.getTitle(), 80),
normalize(row.getSku()),
abbreviateForLog(url, 200),
urls == null ? 0 : urls.size(),
urls == null || urls.isEmpty() ? "" : abbreviateForLog(urls.get(0), 200),
urls == null || urls.size() <= 1 ? "" : abbreviateForLog(urls.get(urls.size() - 1), 200));
}
}
private String abbreviateForLog(String value, int maxLength) {
if (value == null) {
return "";
}
String trimmed = value.trim();
if (maxLength <= 0 || trimmed.length() <= maxLength) {
return trimmed;
}
return trimmed.substring(0, maxLength) + "...";
}
private int allRowCount(FileTaskEntity task) {
try {
SimilarAsinParsedPayloadDto payload = readParsedPayload(task);
@@ -1634,13 +1679,8 @@ public class SimilarAsinTaskService {
if (parsedRow == null) {
return row;
}
if (normalize(row.getUrl()).isBlank()) {
String parsedUrl = firstNonBlank(parsedRow.getUrl(), readValueByHeader(parsedRow,
"url", "rul", "image", "img", "pic", "picture", "link"));
if (!normalize(parsedUrl).isBlank()) {
row.setUrl(parsedUrl);
}
}
// 不再回填 urlPython 端会同时回传 url主图与 urls同类商品图
// 缺失场景应在 Python 侧定位Java 不再合成新的 url 字段以避免覆盖原始数据。
if (normalize(row.getTitle()).isBlank()) {
row.setTitle(parsedRow.getTitle());
}

View File

@@ -58,11 +58,13 @@ public class SimilarAsinImageEmbedder {
static final int DOWNLOAD_TIMEOUT_SECONDS = 5;
static final int DOWNLOAD_MAX_RETRY = 1;
static final int DOWNLOAD_POOL_SIZE = 8;
public static final float IMAGE_ROW_HEIGHT_POINTS = 150f;
public static final int IMAGE_COL_WIDTH_CHARS = 30;
static final int TARGET_LONG_EDGE_PX = 300;
static final float JPEG_QUALITY = 0.85f;
static final int MAX_THUMB_SIZE_BYTES = 40 * 1024;
// B 方案:单元格放大到 Excel 上限附近255 char ≈ 1785 px / 409.5 pt ≈ 546 px保持 16:9 显示空间。
public static final float IMAGE_ROW_HEIGHT_POINTS = 409f;
public static final int IMAGE_COL_WIDTH_CHARS = 80;
// 源图长边 1280 px 给 Excel 缩放/打印留余量q=0.75 + 300KB 出口硬上限主动控制单图体积。
static final int TARGET_LONG_EDGE_PX = 1280;
static final float JPEG_QUALITY = 0.75f;
static final int MAX_THUMB_SIZE_BYTES = 300 * 1024;
static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024;
static final int MAX_DECODE_PIXELS = 6000 * 6000;
@@ -150,6 +152,9 @@ public class SimilarAsinImageEmbedder {
anchor.setRow2(rowIdx + 1);
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
patriarch.createPicture(anchor, picIdx);
// POI 已经把缩略图字节复制进 picture poolB 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。
// 同 URL 行内重复出现概率较低,且 cache 仍承担同任务跨行去重收益(首次 miss 时已经 put 过)。
taskImageCache.remove(trimmedUrl);
} catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
@@ -440,8 +445,10 @@ public class SimilarAsinImageEmbedder {
}
/**
* resize 出口字节硬上限保护:worst case 6000 行 × 3 列 × 40KB × 2 副本 = 1440MB
* 因此线上限制建议结合 `taskImageCache` 命中率与单任务行数评估,超出预算需缩小 MAX_THUMB_SIZE_BYTES 或限制行数。
* resize 出口字节硬上限保护:B 方案下单图最坏 ~300KBPOI picture pool 在 SXSSFWorkbook.dispose() 前不会 spill 到临时文件,
* 因此 N 行 × 3 列 × 300KB 全量驻留堆。配合 -Xmx2048M≈1500 行 × 3 列 ≈ 1.32GB picture pool已逼近安全水位
* embed() 成功后会立刻 taskImageCache.remove() 释放 A 副本B 副本picture pool仍随 workbook 生命周期驻留。
* 超过 ~1500 行需要降低 MAX_THUMB_SIZE_BYTES 或调小 TARGET_LONG_EDGE_PX必要时再考虑拆任务。
*/
public static class ResizeOversizeException extends ResizeException {
private final String url;

View File

@@ -179,6 +179,12 @@ aiimage:
security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
auth:
jwt-secret: ${AIIMAGE_JWT_SECRET:please-change-this-secret-please-rotate-at-least-32-bytes}
jwt-ttl-hours: ${AIIMAGE_AUTH_JWT_TTL_HOURS:168}
cookie-name: ${AIIMAGE_AUTH_COOKIE_NAME:aiimage_token}
cookie-secure: ${AIIMAGE_AUTH_COOKIE_SECURE:false}
cookie-same-site: ${AIIMAGE_AUTH_COOKIE_SAME_SITE:Lax}
ziniao:
enabled: ${AIIMAGE_ZINIAO_ENABLED:false}
base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router}

View File

@@ -19,7 +19,7 @@
<div class="prompt-card">
<div class="section-title">新增条件要求</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,仅在激活任务时传递这里填写的 prompt" />
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空则不向 Coze 传 prompt 字段;填写后将作为 prompt 透传" />
</div>
<div class="run-row">
@@ -167,12 +167,7 @@ const uploadedFiles = ref<UploadFileVo[]>([])
const parseResult = ref<AppearancePatentParseVo | null>(null)
type TaskSummary = Pick<AppearancePatentParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
const queuedTaskSummary = ref<TaskSummary | null>(null)
const defaultAiPrompt = `请帮我排查以下亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险
请直接给出明确结论(有侵权风险 或 未发现明显侵权风险),并严格按照以下三个维度提供精简的排查理由:
外观维度: 评估产品外形、图案设计是否与欧洲/英国常见外观专利雷同。
专利维度: 评估产品的核心技术或物理结构是否存在侵权可能。
标题维度: 排查标题中是否包含大牌商标、敏感词或版权保护词汇。
结论XX`
// 不再注入默认 prompt用户未填则一律空字符串由后端决定是否给 Coze 透传 prompt 字段
const aiPrompt = ref('')
const parsing = ref(false)
const pushing = ref(false)
@@ -253,9 +248,8 @@ function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
}
function effectiveAiPrompt() {
const extraPrompt = aiPrompt.value.trim()
if (!extraPrompt) return defaultAiPrompt
return `${defaultAiPrompt}\n\n新增条件要求\n${extraPrompt}`
// 用户没在文本框填东西就送空字符串,后端会跳过 prompt 字段。
return aiPrompt.value.trim()
}
function queueAiPrompt() {