From 1cc3d5e37a0ecd5434ac57aaaebd832ebce883d4 Mon Sep 17 00:00:00 2001 From: 24024 <240241002@qq.com> Date: Fri, 16 Jan 2026 22:24:14 +0800 Subject: [PATCH] feat: Implement a comprehensive user authentication system, add video generation capabilities, and set up database migrations and API blueprints. --- .gitignore | 33 ++ __pycache__/app.cpython-312.pyc | Bin 2170 -> 0 bytes __pycache__/config.cpython-312.pyc | Bin 4123 -> 0 bytes __pycache__/extensions.cpython-312.pyc | Bin 667 -> 0 bytes __pycache__/init_rbac.cpython-312.pyc | Bin 2602 -> 0 bytes __pycache__/models.cpython-312.pyc | Bin 9069 -> 0 bytes app.py | 49 ++- blueprints/__pycache__/admin.cpython-312.pyc | Bin 12554 -> 0 bytes blueprints/__pycache__/api.cpython-312.pyc | Bin 20201 -> 0 bytes blueprints/__pycache__/auth.cpython-312.pyc | Bin 13564 -> 0 bytes .../__pycache__/payment.cpython-312.pyc | Bin 9792 -> 0 bytes blueprints/admin.py | 50 ++- blueprints/api.py | 286 ++++++++++++- blueprints/auth.py | 259 +++++++++--- blueprints/payment.py | 39 +- config.py | 2 + create_database.py | 49 +-- extensions.py | 2 + fix_db_manual.py | 23 +- fix_db_manual_points.py | 39 +- logs/system.log | 168 -------- middlewares/__pycache__/auth.cpython-312.pyc | Bin 2780 -> 0 bytes middlewares/auth.py | 4 + migrations/README | 1 + migrations/alembic.ini | 50 +++ migrations/env.py | 113 +++++ migrations/script.py.mako | 24 ++ .../versions/9024b393e1ef_add_some_columns.py | 59 +++ models.py | 22 + requirements.txt | Bin 2226 -> 2118 bytes .../alipay_service.cpython-312.pyc | Bin 4178 -> 0 bytes services/__pycache__/logger.cpython-312.pyc | Bin 3986 -> 0 bytes .../__pycache__/sms_service.cpython-312.pyc | Bin 6011 -> 0 bytes services/captcha_service.py | 51 +++ services/logger.py | 55 ++- static/js/auth.js | 258 +++++++++--- static/js/main.js | 183 ++++---- static/js/video.js | 336 +++++++++++++++ sync_videos_manual.py | 83 ++++ templates/base.html | 124 +++--- templates/dicts.html | 392 +++++++++++------- templates/index.html | 166 +++++--- templates/login.html | 54 ++- templates/logs.html | 337 ++++++++++++--- templates/ocr.html | 83 ++-- templates/video.html | 241 +++++++++++ 46 files changed, 2789 insertions(+), 846 deletions(-) create mode 100644 .gitignore delete mode 100644 __pycache__/app.cpython-312.pyc delete mode 100644 __pycache__/config.cpython-312.pyc delete mode 100644 __pycache__/extensions.cpython-312.pyc delete mode 100644 __pycache__/init_rbac.cpython-312.pyc delete mode 100644 __pycache__/models.cpython-312.pyc delete mode 100644 blueprints/__pycache__/admin.cpython-312.pyc delete mode 100644 blueprints/__pycache__/api.cpython-312.pyc delete mode 100644 blueprints/__pycache__/auth.cpython-312.pyc delete mode 100644 blueprints/__pycache__/payment.cpython-312.pyc delete mode 100644 logs/system.log delete mode 100644 middlewares/__pycache__/auth.cpython-312.pyc create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/9024b393e1ef_add_some_columns.py delete mode 100644 services/__pycache__/alipay_service.cpython-312.pyc delete mode 100644 services/__pycache__/logger.cpython-312.pyc delete mode 100644 services/__pycache__/sms_service.cpython-312.pyc create mode 100644 services/captcha_service.py create mode 100644 static/js/video.js create mode 100644 sync_videos_manual.py create mode 100644 templates/video.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16a8a2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesize + +# Instance specific +instance/ +.env + +# PyCharm +.idea/ + +# VS Code +.vscode/ diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 08608bcfc5d7188863a13683aa5427ece647bb54..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2170 zcmbVNO>7%Q6rT0&+G{)ZI*HTZP#Qz{sgu?LB>tkPmlh(WQVyh-tST(WyGb_PwXNB8 zn^>vjs6|NysFf&cB_b76AyNXUZ~)O>kl@CFlo%CF6SV>y;Dm#bD&@qRu{U<2AS!0% znK$o!@A>Kd&3A!-55c-LJg$E)AoL5pI7_S%m9K%ggjA$*1~NH?GhK>{qj#6Vn}Q-R znm0t#t+-8(;$e5ekW8=QWwdDcOuyo1wA*Mh14@9=9wTVVip*%q2q_`3DH+X5Gdo+9 z7S($eDXlJ~wJz11QNmiA>N~L?A>5{gClA)x4TtJio7BL^!6m-R3OWPpzBy;veVl+#E z$*f+bq@-%<=_>8ZCZ|on!fhzk;1F`dg*sDFTr`k2=Ll`MSJzUFcN)#0Nl3)bHR;*O zLCBUIo}(Z)8$Q z!#WgaqP?JH1z;Wb7c_@J<^AFR)#UZ*;o0!%i;X#^j z-_0iwL;=y@QGP}4UY5FNT3T3Kzgk$o@wvYgcf*?o{f5KKkt3LUn z^hR&t@K1tgZg6(+w$O1)=va8ZC_MJJ8;PE|qq9dZ@I@iKDuiweky}FKa#vB z!@f`yx_7V7bhO9@W>r5QwnUly-ap;MchM$ zJyh6Eg*{~n`2y!V&vmYNJ2nt6_N+>Q<={(2sc%*Coe!J~yeTtItSEKg_kg_YLrrpF z@V>uJZ(4Lre$&%)B+7pq6_0d#X!^_~%=ARw^K_hj`aA^j7z9+9$XGTJvKb5!SGCdH zc&wd>WAr^?sb`IY+rc8sGVwubw6vvX(iY2jHP7-^daXEpQ&^Hou#ub9S<*?Ukx5?~ zRtBV{fn9ras-@utYg$s()8j<2byFj5GoxyT#Zrvvgu-;h5+3K$J6dQ{>IZDj&<^2V zfYA5IdKI=Z&vD#cw0{jn?xLsGP}drIbPYYRhGKV6=nnE%+=vVQCG6$ojeg{5K4TR4 jd%S->{{tVP^)oO#a3S(uc=1?a;78%vGLM7~)_DH_jZxTO diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 3c1358472622286daa4095e616a76f5203e2b5da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4123 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 diff --git a/__pycache__/extensions.cpython-312.pyc b/__pycache__/extensions.cpython-312.pyc deleted file mode 100644 index 143ac3ff39e4889f610c4cc2f64be99aac00eee9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 667 zcmaJrJAdWnnv{qxd;LU5O`9IyCuQYzswL!>t2{% zGRs&esC#k7rwF5(fd9}+|IrMo;qqtmM4#+(t-k75za()WH=jIet-k1=wRcam4^m&0y3>2BqkBDd1>vIWO-pbKqp5k z;vtt+YddXgfue#Y)J|m`V$$e9!0)8}Buk{UR?sBYr0rjMNJxC`-Xv~1#sJ|cKIhzX z?z!i6&+#8+WhMZhyRjBu%m~2Wh|nHg5qZ2HB7J}Z92NjU-~kxIbpb4>^XT%pKA;cc z9y~~Rh@in^fNS_^;4$(fM|@ESfI#X1ZyYJA!$7;C-M|=U5Y1q!-W!QPpyQeuy^0?c zc|pYwgaf>4+RqC?pD6mmp_by_JTSnw9{wM<;>a!u)0lqEVr>CJ?bMCXYKhPo95zzi z1#Q)FdJg9Z&M<(-fDgnl8Ter=o_{n=jo{N(01m^mV!D`KA~iXGK9U+$fc~<7(}E|fUG%o^#v{d8Bn7>-XOHi{> z^I9g@xp=?A;}=Db(Db~K&*sa=ZcoA^rm-O@eF72o&t->w_f7*?L zeF>}p>w$=$U|g#HjXjxm;|=nANQcJM-nBlpt}*@5A#aw$9XRkrJU$AO(Y zb~&bgxjl7zu)e-NiY{Yr@aojSSB14)-_`8Bz9_1wsnIJ_J!hwfzM1a4F*){i?$W85 z^F8?u1g!i{Ywcdi3t(*sbT!Mh)e`z;Ge3b8*g&cIA{FW|4q;ziNh4gKY_i zB>se?5)r`{g1`qo6$RA0RpMxr7ur>-)fbR>fo*PAiB_K=N&=i2KsAbdey+gb!*Qyi zB^(U;BmoT;6%Y7CNi`hv2BJI+aAa-+jSxridA7K*;c#0x+!o*u`$9e`pW6Be z^kj|_YN0%`Mf?p8u?~=-=g=yWTMJu5Sosj#oq-A5; zvL#{JlC*47c1$d?W~|FEH(uvb)-63)#SKrAd2&w5Q0WPe`!1s?XEwN!yr&jfubWLEOe9Z9CGo-3i<7q;0Qany9d6 z?A7rV8{#|Jl>KwXopCb5Oxo3ua5W@dTa(UhiaSNWouSLS-JS04rp~7Rh7?`ZLLdK>xM&dYhBX1Ep6SEuiQM)Pb+#KKhaeQG@ zirzPgvQd{JnWu*^n4G65L-{m46VVm=ZV$C*mHzit#GZ9Vm1NnV7uE{f#88DQH^J)W zL+A@gEEKLG2#YIFD8+oe=Bqtl5okNQ;w<|kcnnno!3kf_$gOZoG{A2c)}MFjY30j5_4Wz8?94~AdVp66YoPo;(-UYO diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc deleted file mode 100644 index 3fca0f2355d2cb347c4f8fba98cffb51c42e2ac0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9069 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 diff --git a/app.py b/app.py index eb9b284..62cfb99 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ -from flask import Flask, render_template +from flask import Flask, render_template, jsonify from config import Config -from extensions import db, redis_client +from extensions import db, redis_client, migrate from blueprints.auth import auth_bp from blueprints.api import api_bp from blueprints.admin import admin_bp @@ -18,6 +18,7 @@ def create_app(): # 初始化扩展 db.init_app(app) redis_client.init_app(app) + migrate.init_app(app, db) # 注册蓝图 app.register_blueprint(auth_bp) @@ -25,6 +26,46 @@ def create_app(): app.register_blueprint(admin_bp) app.register_blueprint(payment_bp) + from flask import g, session + from models import User, SystemLog + + @app.before_request + def load_user(): + """在每个请求前加载用户信息到 g,供日志系统使用""" + user_id = session.get('user_id') + if user_id: + g.user_id = user_id + g.user = db.session.get(User, user_id) + else: + g.user_id = None + g.user = None + + @app.context_processor + def inject_menu(): + """将导航菜单注入所有模板,实现服务端渲染,解决闪烁问题""" + from blueprints.auth import get_user_menu + menu = get_user_menu(g.user) if hasattr(g, 'user') else [] + return dict(nav_menu=menu) + + @app.route('/api/system_logs') + def get_system_logs(): + """获取系统日志数据 (供后台管理界面使用)""" + # 这里可以加入权限检查 + logs = SystemLog.query.order_by(SystemLog.created_at.desc()).limit(100).all() + return jsonify([{ + 'id': log.id, + 'user_id': log.user_id, + 'level': log.level, + 'module': log.module, + 'message': log.message, + 'extra': log.extra, + 'ip': log.ip, + 'path': log.path, + 'method': log.method, + 'user_agent': log.user_agent, + 'created_at': log.created_at.strftime('%Y-%m-%d %H:%M:%S') + } for log in logs]) + @app.route('/') def index(): return render_template('index.html') @@ -37,6 +78,10 @@ def create_app(): def visualizer(): return render_template('kongzhiqi.html') + @app.route('/video') + def video_page(): + return render_template('video.html') + # 自动创建数据库表 with app.app_context(): print("🔧 正在检查并创建数据库表...") diff --git a/blueprints/__pycache__/admin.cpython-312.pyc b/blueprints/__pycache__/admin.cpython-312.pyc deleted file mode 100644 index f8db394bf71e517831ff2faffce9bae0ca608123..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12554 zcmeHNYj7Lab>3YpfW?ad3BEx|ltfa5O$mBXFG{9GOQPPCWKot(KNyG$k)R2J-UV$- zAwBfuVKPpJWRiwptPbfUttnfL)lS=}oyo+`uXOwe@Uj85Lr>zVWm&&Un{C?USI@bN z-NiynJu>y5PVWpZ?&Iv)`#6v9p1U72HCtG&XDMom;whf) zqhfRi9b-C}n5Dx)V;j@Q#;hGyQnvKjV)hPujO*ZHjt)o6+2M@2I$SY#hnw`XeV$lB zM*&SSRD|ZO5if7MW`EbByr;t#@%64(U#|c29Pj9+uQ}gkls0&_fOox1D>dHzP3qk& zvHIUpv?3?ZJvbGZaPqIf$;;o1lh1@x@d}&@?>W076HX;7aPpgQD%RGi#Dr7n3Y>4tuFizh>Ps7f^>0~C5y%bC-t+NNPDVON`ZdWbqejnm^ym!+HPVxBufQN#2Q z-OH&I`aiT1ORcwH30mF8mTW7IH!s1Pm@bCA2YQE@A;yg3B=cP#3u75(c;*TVwPR4T z3^T)Ylvelt4D{24=4GWs`d@}kZgwu0t>T-W?T*L0`y$U? zP^C?LmhAYZflIQZJCf)m0}@ul6At)^mmwRW{=-Ij3dfI*9UX5QYm?knBL^2GQPYZi)2rdlUk7Xno?k4-~imrkl`&mW<`s8YlUz0>%F&Hce@{}Xii z2V3k={xxldTxL(5JkxFvI~!0FvFigdyPwz%Q$wbr$7>sh!X=zeAXk#i1kFr%hjb1g zHljD-QaSJpZ_x#VXD>2mU=+Ny0tkkfPVG%AjLfEr6wqpF(ci4Ah{nr&X4>-rBeIG( z%p`p3=z5FPlK$U|!j+eX7fy)T=-9X z-REzA|MRiG`}D^5Zv9R2)|I(CKLk^#Yw1-+FB6SLt160MO$TuovJe02FX7?OMkj84 zXC_!E0IsBD7JY(j0aYc|K)?l2%z$nIP>y7~2>j6aTf#gqpfbz0i_yLWI1o#;zf-8h z2hmo@wyt0L86Fn2P0A~;A+bbG5iu3e&`prKpo#L zEi72IB%8KZE&3`mzUs8E zI^)}r_HDT4e9wE;o2lQEuHSRx!27GxdrwOBrzGF$jPGpPcUJP98?`P3sz=#*co%DX z{pf2)Gi*tkEtzM_!0b&NNv+wI@;6GZ?J0KqGR&E}>`@$Kh>!F|5)p$UOddwXo&%96%(%?;aROY;eXLSkZt!Swd znw#v^Y=%k?WAp1}wWM1@(4KogZDMC~G*2IwjkoLLf-&hm7M|04cn6L+=v4G_kSOFo ze(Ux>zkU1XZ{42w_N~dJfVw`28R+Qe-}vRN(b+rGKl|KBr08LE0(G|*>2>8)=oRG@ z)JxpqM*cMcgSbs!b_>NOEnyzCJ~iLf8sJyX+3-fER;Kpu7^DxCVv$`Drr`t4f9$Jj{YN z@E;Ec4gtV10N_>;IA;dmXad=Y$rNf};6WR)s>%ivYfAQ3GsCc*n}JNIg3Pd;Uc^~B zYa{FgiYBBw2AP7k$};Y%w7V+fu1>qFCHJ~4;wnj10;>6 z3A|uWygx#86M#6Pul$WiGGPy9sK7+qK?HDLWqQyc00d~0cL|R{_kQ?^FF>Yjy(!S< z0BHZg!2(+55NO*~pj}crb#C(9!up!Gcl>0>L<^$ssbk+iHuv>qJL`9TN#PR~8w!RE z1;d8;nt?_UP0-aAOG~2@ycZcuC=7U^yWb!bEKm`lX#C2&7f3u+)rOK&#sxv#asWfon!&5a$Je-Xn&|%5?%qPJ- z!+EI}B`_~RtI0^A6&+t*f>!i-c?nuEs*snUwK}f}s?cgObDq6o2Q%JI%y`Z@S_`mP zz(ZTxWHxbWAOdkJ2g*n%R%9y?m!MsVl^ldu$kx8_g-D-leJKo~5Si_YL%_`u6+6RS ziReoZUK8U&qBBn7X6n%^EOd(kQ8Tnr#Qw_;&ly}lQ7o6})zD79VxC=3ubEPR02&Ds|zpu<}X-#iw&1^ZA z-f~RZazb*qW!z7u-A_vHb}iiFLoZawflu+F7xJoJsG@QvFdbOPI-~4gHU^aKs|?IE zOgAihIrK`%!Qw=({JSVi-vu!Sw z;|eh)xGqwy25`_uw*s{ju9C8mBcRqS+X~cf18>cKB(%4fuE830#QMLweufx~hcD2* zEu8hd%2|(`>y!x0&#&M5Ez46toDtE&cA~V3i^f5Inj3)e<(%w{MnH+b z=nglC{D=+6dl727@`?s1sqWZL>=fXGQ_!deYjeXa*e{%g&H*HlAv5^nyTUB0;wz_z z*)qu;9Hno1eOJn6H>4ijn_ByrRMGstB~`IMRkZ(8cj+6)N9o+)$&2$`#qWzsC%&F8 zT0heA&13L_iJfCHC?3i!LP+@h*!hWz$*|<9hR)W7qSC4Q$@=7;IaVs#NS^eUP3@Z8 zl{`MTR`NGM%W>0)O7hi|A5N;F6dO{!y+`r(_YA0fb&&KS0xCyy;HoCputC|>>s3qo zzivc8oHdm-yO}UE2RW*s0ajyJ86c_N!}3;LNq8H9r1q?$)WVnsanOcP24Ya*3})*g z3p#V89tWEf&(Hw2%80{G>A;waeaAHEZn25$AK}NQbv4}F=Z#dN#B0e(=QwYQJKY*W@gp9~|L3PGc zoA%UZJe$&eA$;1q0B^REE-(q15JmQf14s%N798h0j|!1YI10E{=dMXe6NP zgW?w76AdeXuD^Z&F@M18wExqf%M3E|cAm?DOelUN-3J6Addo|ydK*B{3Kvm22)Z-! z38G%kA zxlhW~9w7omZz16|$P5l*0q&7r8G9wkzVYhF;ZN+|Mfd8AyC&_f$++v&?)tfM$-VVn zfM>Pjt~NnDIVi_hJn{6T7x2oFyD^O^g2}xTQXou;(XISHl!09|rW}FR0gK@r^>9!I z9hD-$cLN&q-**EVU6hGr>!NH9Xubwa3?|>7y_hk`Z8uQ;15&;WnL%zBxylSzo#v`D zTuqv*nQedj{9EVe_@4r>X?z%9^Po2{_7I6kh_WXOX+-jwkO9l!q7f{EpXU5IVgIih z%kU7u#}6fkX)t`+`4}Qs_%V?BiO#0j zP#M5;CNVD+twFC@?2GBGR-f1Zfp(oAIPQA2(d@Su72}CtHW75C0&(P+B(C zGufm5l~>F-r=9qT*WT&9SA#d!&Ofp@9oU`0gI$dv6%m+ecnw9l8FP4)CG`<=xAP;pomHv&{Q0#r8vsL-eY)XMz@ z2R1v~f@^SL!{Q49S}g^e;13kvu{cPTsNB_&dl8J83FHt<3_otO z?2AQtzAy4}Scr(5!h?w(0nbh{*V&0*<8^k*9Qi)3^TGgrtTzas&!8rgk6wiBSlWph zUgHTZn4QK9V>JREaLEaPAQ=JO0@=o^6afe%!1E_=FN|`qFVZZ$2K_Yr#5X}NmMk<) z|Bl+2rZ#>|Rs4=Rm7z|3Ol|(eRXlMn*>ipGyldYGw`8Lj=j*FqTRp+eZlB#SvRa}a zxk(pKJSWlXzhE4+ec3`WHJClR#6oqcRHxrDTaWaQ>hwkQ4voHHiG}J?y-M$$tD7rF z`mH*>BZqz)^53ykuF@CGmCV_ZezQ)`=8R{{5)0L(0+rr4{^upS<_m^X=xej%hbqWy zcD}~(sPvB`DWQk)aP;H3^UYl!h2Cf657ng_l|MI|yFLoNpWdwThxLK#(jzLp*?8>A z`l#c9sy6Rxdp-P1J Fe*i~z6h{C6 diff --git a/blueprints/__pycache__/api.cpython-312.pyc b/blueprints/__pycache__/api.cpython-312.pyc deleted file mode 100644 index d499527d56d5efa936dcb6178b6ea8eb645be1ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20201 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 diff --git a/blueprints/__pycache__/auth.cpython-312.pyc b/blueprints/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index c1233964c28e59994a0054647d4adf33374fd101..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13564 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 diff --git a/blueprints/__pycache__/payment.cpython-312.pyc b/blueprints/__pycache__/payment.cpython-312.pyc deleted file mode 100644 index ea928e7bd7c0901332369b31fd9304b82f2c3dcb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9792 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 D=jQIs diff --git a/blueprints/admin.py b/blueprints/admin.py index 03892a2..cfdad57 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify from extensions import db from models import User, Role, Permission, SystemDict, SystemNotification, Order from middlewares.auth import permission_required +from services.logger import system_logger admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin') @@ -30,9 +31,11 @@ def save_role(): if not role: return jsonify({"error": "角色不存在"}), 404 role.name = data['name'] role.description = data.get('description') + system_logger.info(f"管理员修改角色: {role.name}") else: role = Role(name=data['name'], description=data.get('description')) db.session.add(role) + system_logger.info(f"管理员创建角色: {role.name}") if 'permissions' in data: perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() @@ -49,8 +52,10 @@ def delete_role(): if role: if role.name == '超级管理员': return jsonify({"error": "不能删除超级管理员角色"}), 400 + role_name = role.name db.session.delete(role) db.session.commit() + system_logger.info(f"管理员删除角色: {role_name}") return jsonify({"message": "角色删除成功"}) return jsonify({"error": "角色不存在"}), 404 @@ -80,15 +85,46 @@ def get_users(): @permission_required('manage_users') def assign_role(): data = request.json - user = User.query.get(data['user_id']) - role = Role.query.get(data['role_id']) + user = db.session.get(User, data['user_id']) + role = db.session.get(Role, data['role_id']) if user and role: user.role = role db.session.commit() + system_logger.info(f"管理员分配用户角色", user_phone=user.phone, role_name=role.name) return jsonify({"message": "角色分配成功"}) return jsonify({"error": "用户或角色不存在"}), 404 # --- 字典管理 --- +@admin_bp.route('/dict_types', methods=['GET']) +@permission_required('manage_dicts') +def get_dict_types(): + # 获取唯一的字典类型及其记录数 + counts = dict(db.session.query(SystemDict.dict_type, db.func.count(SystemDict.id))\ + .group_by(SystemDict.dict_type).all()) + + # 定义类型的友好名称 (标准类型) + standard_types = { + 'ai_model': 'AI 生成模型', + 'aspect_ratio': '画面比例配置', + 'ai_image_size': '输出尺寸设定', + 'prompt_tpl': '生图提示词模板', + 'video_model': '视频生成模型', + 'video_prompt': '视频提示词模板', + } + + # 合并数据库中存在的其他类型 + all_types = {**standard_types} + for t in counts.keys(): + if t not in all_types: + all_types[t] = t # 未知类型直接使用 Key 作为名称 + + return jsonify({ + "types": [{ + "type": t, + "name": name, + "count": counts.get(t, 0) + } for t, name in all_types.items()] + }) @admin_bp.route('/dicts', methods=['GET']) @permission_required('manage_dicts') def get_dicts(): @@ -118,9 +154,11 @@ def save_dict(): if dict_id: d = SystemDict.query.get(dict_id) if not d: return jsonify({"error": "记录不存在"}), 404 + action = "修改" else: d = SystemDict() db.session.add(d) + action = "创建" d.dict_type = data['dict_type'] d.label = data['label'] @@ -130,6 +168,7 @@ def save_dict(): d.sort_order = data.get('sort_order', 0) db.session.commit() + system_logger.info(f"管理员{action}系统配置: {d.label}") return jsonify({"message": "保存成功"}) @admin_bp.route('/dicts/delete', methods=['POST']) @@ -138,8 +177,10 @@ def delete_dict(): data = request.json d = SystemDict.query.get(data.get('id')) if d: + label = d.label db.session.delete(d) db.session.commit() + system_logger.info(f"管理员删除系统配置: {label}") return jsonify({"message": "删除成功"}) return jsonify({"error": "记录不存在"}), 404 @@ -167,15 +208,18 @@ def save_notification(): if notif_id: n = SystemNotification.query.get(notif_id) if not n: return jsonify({"error": "通知不存在"}), 404 + action = "修改" else: n = SystemNotification() db.session.add(n) + action = "发布" n.title = data['title'] n.content = data['content'] n.is_active = data.get('is_active', True) db.session.commit() + system_logger.info(f"管理员{action}通知: {n.title}") return jsonify({"message": "通知保存成功"}) @admin_bp.route('/notifications/delete', methods=['POST']) @@ -184,8 +228,10 @@ def delete_notification(): data = request.json n = SystemNotification.query.get(data.get('id')) if n: + title = n.title db.session.delete(n) db.session.commit() + system_logger.info(f"管理员删除通知: {title}") return jsonify({"message": "通知删除成功"}) return jsonify({"error": "通知不存在"}), 404 diff --git a/blueprints/api.py b/blueprints/api.py index d5d5c71..250ec96 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -85,7 +85,7 @@ def sync_images_background(app, record_id, raw_urls): # 更新数据库记录为持久化数据结构 try: - record = GenerationRecord.query.get(record_id) + record = db.session.get(GenerationRecord, record_id) if record: record.image_urls = json.dumps(processed_data) db.session.commit() @@ -102,11 +102,12 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api resp = requests.post(target_api, json=payload, headers=headers, timeout=1000) if resp.status_code != 200: - # 错误处理:退还积分 - user = User.query.get(user_id) + user = db.session.get(User, user_id) if user and "sk-" in api_key: user.points += cost db.session.commit() + + system_logger.error(f"生图任务失败: {resp.text}", user_id=user_id, task_id=task_id) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text})) return @@ -130,25 +131,176 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api ).start() # 存入 Redis 标记完成 + system_logger.info(f"生图任务完成", user_id=user_id, task_id=task_id, model=payload.get('model')) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls})) except Exception as e: # 异常处理:退还积分 - user = User.query.get(user_id) + user = db.session.get(User, user_id) if user and "sk-" in api_key: user.points += cost db.session.commit() + + system_logger.error(f"生图任务异常: {str(e)}", user_id=user_id, task_id=task_id) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": str(e)})) +def sync_video_background(app, record_id, raw_url, internal_task_id=None): + """后台同步视频至 MinIO,带重试机制""" + with app.app_context(): + success = False + final_url = raw_url + for attempt in range(3): + try: + # 增加了流式下载,处理大视频文件 + with requests.get(raw_url, stream=True, timeout=120) as r: + r.raise_for_status() + content_type = r.headers.get('content-type', 'video/mp4') + ext = ".mp4" + if "text/html" in content_type: # 有些 API 返回的是跳转页面 + continue + + base_filename = f"video-{uuid.uuid4().hex}" + full_filename = f"{base_filename}{ext}" + + video_io = io.BytesIO() + for chunk in r.iter_content(chunk_size=8192): + video_io.write(chunk) + video_io.seek(0) + + # 上传至 MinIO + s3_client.upload_fileobj( + video_io, + Config.MINIO["bucket"], + full_filename, + ExtraArgs={"ContentType": content_type} + ) + + final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}" + success = True + break + except Exception as e: + system_logger.error(f"同步视频失败 (第{attempt+1}次): {str(e)}") + time.sleep(5) + + if success: + try: + record = db.session.get(GenerationRecord, record_id) + if record: + # 更新记录为 MinIO 的 URL + record.image_urls = json.dumps([{"url": final_url, "type": "video"}]) + db.session.commit() + + # 关键修复:同步更新 Redis 中的缓存,这样前端轮询也能拿到最新的 MinIO 地址 + if internal_task_id: + cached_data = redis_client.get(f"task:{internal_task_id}") + if cached_data: + if isinstance(cached_data, bytes): + cached_data = cached_data.decode('utf-8') + task_info = json.loads(cached_data) + task_info['video_url'] = final_url + redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps(task_info)) + + system_logger.info(f"视频同步 MinIO 成功", video_url=final_url) + except Exception as dbe: + system_logger.error(f"更新视频记录失败: {str(dbe)}") + +def process_video_generation(app, user_id, internal_task_id, payload, api_key, cost): + """异步提交并查询视频任务状态""" + with app.app_context(): + try: + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + # 1. 提交任务 + submit_resp = requests.post(Config.VIDEO_GEN_API, json=payload, headers=headers, timeout=60) + if submit_resp.status_code != 200: + raise Exception(f"视频任务提交失败: {submit_resp.text}") + + submit_result = submit_resp.json() + remote_task_id = submit_result.get('task_id') + if not remote_task_id: + raise Exception(f"未获取到远程任务 ID: {submit_result}") + + # 2. 轮询状态 + max_retries = 90 # 提升到 15 分钟 + video_url = None + for i in range(max_retries): + time.sleep(10) + poll_url = Config.VIDEO_POLL_API.format(task_id=remote_task_id) + poll_resp = requests.get(poll_url, headers=headers, timeout=30) + if poll_resp.status_code != 200: + continue + + poll_result = poll_resp.json() + status = poll_result.get('status', '').upper() + + if status == 'SUCCESS': + # 提取视频输出地址 + if 'data' in poll_result and isinstance(poll_result['data'], dict): + video_url = poll_result['data'].get('output') + if not video_url: + if 'data' in poll_result and isinstance(poll_result['data'], list) and poll_result['data']: + video_url = poll_result['data'][0].get('url') + elif 'video' in poll_result: + video_url = poll_result['video'].get('url') if isinstance(poll_result['video'], dict) else poll_result['video'] + elif 'url' in poll_result: + video_url = poll_result['url'] + break + elif status in ['FAILED', 'ERROR']: + raise Exception(f"视频生成失败: {poll_result.get('fail_reason') or poll_result.get('message') or '未知错误'}") + + if not video_url: + raise Exception("超时未获取到视频地址") + + # 3. 持久化记录 + new_record = GenerationRecord( + user_id=user_id, + prompt=payload.get('prompt'), + model=payload.get('model'), + image_urls=json.dumps([{"url": video_url, "type": "video"}]) + ) + db.session.add(new_record) + db.session.commit() + + # 后台线程异步同步到 MinIO + threading.Thread( + target=sync_video_background, + args=(app, new_record.id, video_url, internal_task_id) + ).start() + + # 4. 存入 Redis + redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "complete", "video_url": video_url, "record_id": new_record.id})) + system_logger.info(f"视频生成任务完成", user_id=user_id, task_id=internal_task_id) + + except Exception as e: + system_logger.error(f"视频生成执行异常: {str(e)}", user_id=user_id, task_id=internal_task_id) + # 尝试退费 + try: + user = db.session.get(User, user_id) + if user: + user.points += cost + db.session.commit() + except Exception as re: + system_logger.error(f"退费失败: {str(re)}") + + # 确保 Redis 状态一定被更新,防止前端死循环 + redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "error", "message": str(e)})) + @api_bp.route('/api/task_status/') @login_required def get_task_status(task_id): """查询异步任务状态""" - data = redis_client.get(f"task:{task_id}") - if not data: - # 如果 Redis 里没有,可能是刚提交,也可能是过期了 - return jsonify({"status": "pending"}) - return jsonify(json.loads(data)) + try: + data = redis_client.get(f"task:{task_id}") + if not data: + return jsonify({"status": "pending"}) + + # 兼容处理 bytes 和 str + if isinstance(data, bytes): + data = data.decode('utf-8') + + return jsonify(json.loads(data)) + except Exception as e: + system_logger.error(f"查询任务状态异常: {str(e)}") + return jsonify({"status": "error", "message": "状态查询失败"}) @api_bp.route('/api/config') def get_config(): @@ -160,7 +312,9 @@ def get_config(): "models": [], "ratios": [], "prompts": [], - "sizes": [] + "sizes": [], + "video_models": [], + "video_prompts": [] } for d in dicts: @@ -174,6 +328,11 @@ def get_config(): config["prompts"].append(item) elif d.dict_type == 'ai_image_size': config["sizes"].append(item) + elif d.dict_type == 'video_model': + item["cost"] = d.cost + config["video_models"].append(item) + elif d.dict_type == 'video_prompt': + config["video_prompts"].append(item) return jsonify(config) except Exception as e: @@ -193,6 +352,8 @@ def upload(): ExtraArgs={"ContentType": f.content_type} ) img_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}") + + system_logger.info(f"用户上传文件: {len(files)} 个", user_id=session.get('user_id')) return jsonify({"urls": img_urls}) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -202,7 +363,7 @@ def upload(): def generate(): try: user_id = session.get('user_id') - user = User.query.get(user_id) + user = db.session.get(User, user_id) data = request.json if request.is_json else request.form mode = data.get('mode', 'trial') @@ -242,6 +403,7 @@ def generate(): if user.points < cost: return jsonify({"error": f"可用积分不足"}), 400 user.points -= cost + user.has_used_points = True # 标记已使用过积分 db.session.commit() prompt = data.get('prompt') @@ -298,6 +460,9 @@ def generate(): task_id = str(uuid.uuid4()) app = current_app._get_current_object() + log_msg = "用户发起验光单解读" if prompt == "解读验光单" else "用户发起生图任务" + system_logger.info(log_msg, model=model_value, mode=mode) + threading.Thread( target=process_image_generation, args=(app, user_id, task_id, payload, api_key, target_api, cost) @@ -310,6 +475,61 @@ def generate(): except Exception as e: return jsonify({"error": str(e)}), 500 +@api_bp.route('/api/video/generate', methods=['POST']) +@login_required +def video_generate(): + try: + user_id = session.get('user_id') + user = db.session.get(User, user_id) + + data = request.json + # 视频生成统一使用积分模式,隐藏 Key 模式 + if user.points <= 0: + return jsonify({"error": "可用积分不足,请先充值"}), 400 + + model_value = data.get('model', 'veo3.1') + + # 确定积分消耗 (优先从字典获取) + model_dict = SystemDict.query.filter_by(dict_type='video_model', value=model_value).first() + cost = model_dict.cost if model_dict else (15 if "pro" in model_value.lower() or "3.1" in model_value else 10) + + if user.points < cost: + return jsonify({"error": f"积分不足,生成该视频需要 {cost} 积分"}), 400 + + # 扣除积分 + user.points -= cost + user.has_used_points = True + db.session.commit() + + # 构建符合 API 文档的 Payload + payload = { + "model": model_value, + "prompt": data.get('prompt'), + "enhance_prompt": data.get('enhance_prompt', False), + "images": data.get('images', []), + "aspect_ratio": data.get('aspect_ratio', '9:16') + } + + # 使用系统内置的 Key + api_key = Config.TRIAL_KEY # 默认使用试用/中转 Key + + task_id = str(uuid.uuid4()) + app = current_app._get_current_object() + + system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost) + + threading.Thread( + target=process_video_generation, + args=(app, user_id, task_id, payload, api_key, cost) + ).start() + + return jsonify({ + "task_id": task_id, + "message": "视频生成任务已提交,系统正在导演中..." + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + @api_bp.route('/api/notifications/latest', methods=['GET']) @login_required def get_latest_notification(): @@ -341,8 +561,8 @@ def mark_notif_read(): if not notif_id: return jsonify({"error": "缺少通知 ID"}), 400 - notif = SystemNotification.query.get(notif_id) - user = User.query.get(user_id) + notif = db.session.get(SystemNotification, notif_id) + user = db.session.get(User, user_id) if notif and user: if user not in notif.read_by_users: @@ -382,14 +602,17 @@ def get_history(): # 旧数据:直接返回原图作为缩略图 formatted_urls.append({"url": u, "thumb": u}) else: - # 新数据:包含 url 和 thumb + # 如果是视频类型,提供默认预览图 (此处使用一个公共视频占位图或空) + if u.get('type') == 'video' and not u.get('thumb'): + u['thumb'] = "https://img.icons8.com/flat-round/64/000000/play--v1.png" formatted_urls.append(u) history_list.append({ "id": r.id, + "prompt": r.prompt, "model": r.model, "urls": formatted_urls, - "time": (r.created_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M') + "created_at": (r.created_at + timedelta(hours=8)).strftime('%b %d, %H:%M') }) return jsonify({ @@ -399,3 +622,36 @@ def get_history(): }) except Exception as e: return jsonify({"error": str(e)}), 500 + +@api_bp.route('/api/download_proxy', methods=['GET']) +@login_required +def download_proxy(): + """代理下载远程文件,强制浏览器弹出下载""" + url = request.args.get('url') + filename = request.args.get('filename', f"video-{int(time.time())}.mp4") + + if not url: + return jsonify({"error": "缺少 URL 参数"}), 400 + + try: + # 流式获取远程文件 + req = requests.get(url, stream=True, timeout=60) + req.raise_for_status() + + headers = {} + if req.headers.get('Content-Type'): + headers['Content-Type'] = req.headers['Content-Type'] + else: + headers['Content-Type'] = 'application/octet-stream' + + headers['Content-Disposition'] = f'attachment; filename="{filename}"' + + def generate(): + for chunk in req.iter_content(chunk_size=4096): + yield chunk + + return current_app.response_class(generate(), headers=headers) + + except Exception as e: + system_logger.error(f"代理下载失败: {str(e)}") + return jsonify({"error": "下载失败"}), 500 diff --git a/blueprints/auth.py b/blueprints/auth.py index 99c45cb..74d68c6 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -1,7 +1,9 @@ from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for +import json from extensions import db from models import User from services.sms_service import SMSService +from services.captcha_service import CaptchaService from services.logger import system_logger from middlewares.auth import admin_required @@ -52,7 +54,7 @@ def buy_page(): from models import Order, User user_id = session['user_id'] - user = User.query.get(user_id) + user = db.session.get(User, user_id) # 获取用户个人充值记录 personal_orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).limit(10).all() @@ -79,18 +81,75 @@ def buy_page(): success=success, order=order) +@auth_bp.route('/api/auth/captcha') +def get_captcha(): + """获取图形验证码并存入 Redis""" + phone = request.args.get('phone') + if not phone: + return jsonify({"error": "缺少参数"}), 400 + + text, img_bytes = CaptchaService.generate_captcha() + + from extensions import redis_client + # 存入 Redis,有效期 5 分钟 + redis_client.setex(f"captcha:{phone}", 300, text.lower()) + + from flask import Response + return Response(img_bytes, mimetype='image/png') + @auth_bp.route('/api/auth/send_code', methods=['POST']) def send_code(): data = request.json phone = data.get('phone') + captcha = data.get('captcha') + ip = request.remote_addr + if not phone: return jsonify({"error": "请输入手机号"}), 400 + if not captcha: + return jsonify({"error": "请输入图形验证码", "show_captcha": True}), 403 + + from extensions import redis_client - system_logger.info(f"用户请求发送验证码", phone=phone) + # 1. 验证图形验证码 + saved_captcha = redis_client.get(f"captcha:{phone}") + if not saved_captcha or captcha.lower() != saved_captcha.decode('utf-8'): + return jsonify({"error": "图形验证码错误或已过期", "refresh_captcha": True}), 403 + + # 验证后立即删除,防止被脚本重复利用来刷短信 + redis_client.delete(f"captcha:{phone}") + + # 2. 频率限制:单手机号 60秒 一次 (后端兜底) + if redis_client.get(f"sms_lock:{phone}"): + return jsonify({"error": "发送过于频繁,请稍后再试"}), 429 + + # 3. 每日限制:单手机号每天最多 10 条 + day_count_key = f"sms_day_count:{phone}" + day_count = int(redis_client.get(day_count_key) or 0) + if day_count >= 10: + return jsonify({"error": "该手机号今日获取验证码次数已达上限"}), 429 + + # 4. 每日限制:单 IP 每天最多 20 条 (防止换号刷) + ip_count_key = f"sms_ip_count:{ip}" + ip_count = int(redis_client.get(ip_count_key) or 0) + if ip_count >= 20: + return jsonify({"error": "您的设备今日发送请求过多,请明天再试"}), 429 + + system_logger.info(f"用户请求发送验证码", phone=phone, ip=ip) success, msg = SMSService.send_code(phone) if success: + # 设置各种限制标记 + from datetime import datetime + now = datetime.now() + seconds_until_midnight = ((23 - now.hour) * 3600) + ((59 - now.minute) * 60) + (60 - now.second) + + redis_client.setex(f"sms_lock:{phone}", 60, "1") + redis_client.setex(day_count_key, seconds_until_midnight, day_count + 1) + redis_client.setex(ip_count_key, seconds_until_midnight, ip_count + 1) + system_logger.info(f"验证码发送成功", phone=phone) return jsonify({"message": "验证码已发送"}) + system_logger.warning(f"验证码发送失败: {msg}", phone=phone) return jsonify({"error": f"发送失败: {msg}"}), 500 @@ -128,19 +187,78 @@ def login(): data = request.json phone = data.get('phone') password = data.get('password') + code = data.get('code') # 可能是高频报错后强制要求的验证码 + + if not phone or not password: + return jsonify({"error": "请输入手机号和密码"}), 400 + + from extensions import redis_client + fail_key = f"login_fail_count:{phone}" + fail_count = int(redis_client.get(fail_key) or 0) + + # 如果失败次数过多,强制要求图型验证码 + if fail_count >= 3: + if not code: + system_logger.warning(f"触发强制安全验证", phone=phone) + return jsonify({ + "error": "由于密码错误次数过多,请输入图形验证码", + "require_captcha": True + }), 403 + + # 验证图形验证码 + saved_captcha = redis_client.get(f"captcha:{phone}") + if not saved_captcha or code.lower() != saved_captcha.decode('utf-8'): + return jsonify({"error": "验证码错误或已过期"}), 400 + + # 验证成功后删除,防止重复使用 + redis_client.delete(f"captcha:{phone}") system_logger.info(f"用户登录尝试", phone=phone) user = User.query.filter_by(phone=phone).first() if user and user.check_password(password): - session.permanent = True # 开启持久化会话 (受 Config.PERMANENT_SESSION_LIFETIME 控制) + # 登录成功,清除失败计数 + redis_client.delete(fail_key) + + session.permanent = True session['user_id'] = user.id system_logger.info(f"用户登录成功", phone=phone, user_id=user.id) return jsonify({"message": "登录成功", "phone": phone}) - system_logger.warning(f"登录失败: 手机号或密码错误", phone=phone) + # 登录失败,增加计数 (有效期 1 小时) + redis_client.setex(fail_key, 3600, fail_count + 1) + + system_logger.warning(f"登录失败: 手机号或密码错误 [次数: {fail_count+1}]", phone=phone) return jsonify({"error": "手机号或密码错误"}), 401 +@auth_bp.route('/api/auth/reset_password', methods=['POST']) +def reset_password(): + """通过短信重置密码""" + data = request.json + phone = data.get('phone') + code = data.get('code') + new_password = data.get('password') + + if not phone or not code or not new_password: + return jsonify({"error": "请填写完整信息"}), 400 + + if not SMSService.verify_code(phone, code): + return jsonify({"error": "验证码错误或已过期"}), 400 + + user = User.query.filter_by(phone=phone).first() + if not user: + return jsonify({"error": "该手机号尚未注册"}), 404 + + user.set_password(new_password) + db.session.commit() + + # 重置成功后清理失败计数 + from extensions import redis_client + redis_client.delete(f"login_fail_count:{phone}") + + system_logger.info(f"用户通过短信重置密码成功", phone=phone, user_id=user.id) + return jsonify({"message": "密码重置成功,请使用新密码登录"}) + @auth_bp.route('/api/auth/logout', methods=['POST']) def logout(): session.pop('user_id', None) @@ -151,12 +269,22 @@ def me(): user_id = session.get('user_id') if not user_id: return jsonify({"logged_in": False}) - user = User.query.get(user_id) + user = db.session.get(User, user_id) + if not user: + session.pop('user_id', None) + return jsonify({"logged_in": False}) + + # 脱敏手机号: 13812345678 -> 138****5678 + phone = user.phone + masked_phone = phone[:3] + "****" + phone[-4:] if len(phone) >= 11 else phone + return jsonify({ "logged_in": True, - "phone": user.phone, - "api_key": user.api_key, # 返回已保存的 API Key - "points": user.points # 返回剩余试用积分 + "phone": masked_phone, # 默认返回脱敏的供前端显示 + "full_phone": phone, # 某些场景可能需要完整号 + "api_key": user.api_key, + "points": user.points, + "hide_custom_key": (not user.api_key) or user.has_used_points # Key为空或使用过积分,则隐藏自定义Key入口 }) @auth_bp.route('/api/auth/change_password', methods=['POST']) @@ -172,7 +300,7 @@ def change_password(): if not old_password or not new_password: return jsonify({"error": "请填写完整信息"}), 400 - user = User.query.get(user_id) + user = db.session.get(User, user_id) if not user.check_password(old_password): return jsonify({"error": "原密码错误"}), 400 @@ -193,20 +321,14 @@ def add_points(): # return jsonify({"error": "请先登录"}), 401 # ... (原有逻辑) -@auth_bp.route('/api/auth/menu', methods=['GET']) -def get_menu(): - """获取动态导航菜单""" - user_id = session.get('user_id') - if not user_id: - return jsonify({"menu": []}) - - user = User.query.get(user_id) +def get_user_menu(user): + """根据用户权限生成菜单列表""" if not user: - return jsonify({"menu": []}) - - # 菜单定义库:名称, 图标, 链接, 所需权限 + return [] + all_menus = [ {"name": "创作工作台", "icon": "wand-2", "url": "/", "perm": None}, + {"name": "AI 视频创作", "icon": "video", "url": "/video", "perm": None}, {"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None}, {"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None}, {"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"}, @@ -215,46 +337,75 @@ def get_menu(): {"name": "系统日志审计", "icon": "terminal", "url": "/logs", "perm": "view_logs"}, ] - # 根据权限过滤 accessible_menu = [] for item in all_menus: if item["perm"] is None or user.has_permission(item["perm"]): accessible_menu.append(item) - - return jsonify({"menu": accessible_menu}) + return accessible_menu -@auth_bp.route('/api/auth/logs', methods=['GET']) -def get_logs(): - """获取系统日志(支持搜索和筛选)""" +@auth_bp.route('/api/auth/menu', methods=['GET']) +def get_menu(): + """获取动态导航菜单""" user_id = session.get('user_id') if not user_id: - return jsonify({"error": "请先登录"}), 401 - - from extensions import redis_client - import json - - # 筛选参数 - level_filter = request.args.get('level') - search_query = request.args.get('search', '').lower() - - # 从 Redis 获取日志 (从 ZSET 读取,按分数降序排列,即最新在前) - logs = redis_client.zrevrange('system_logs_zset', 0, -1) - log_list = [] - - for log in logs: - item = json.loads(log.decode('utf-8')) + return jsonify({"menu": []}) - # 级别过滤 - if level_filter and item['level'] != level_filter: - continue - - # 关键词搜索 (搜索内容、手机号或其它 Extra 字段) - if search_query: - message_match = search_query in item['message'].lower() - extra_match = any(search_query in str(v).lower() for v in item.get('extra', {}).values()) - if not (message_match or extra_match): - continue - - log_list.append(item) + user = db.session.get(User, user_id) + return jsonify({"menu": get_user_menu(user)}) + +@auth_bp.route('/api/auth/logs', methods=['GET']) +@admin_required +def get_logs(): + """获取系统日志(支持搜索、筛选与分页)""" + from models import SystemLog, User - return jsonify({"logs": log_list}) + # 分页与筛选参数 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + level_filter = request.args.get('level') + search_query = request.args.get('search', '').strip() + + query = db.session.query(SystemLog).outerjoin(User) + + # 级别过滤 + if level_filter: + query = query.filter(SystemLog.level == level_filter) + + # 关键词搜索 (支持消息、手机号、IP) + if search_query: + search_filter = db.or_( + SystemLog.message.ilike(f"%{search_query}%"), + SystemLog.ip.ilike(f"%{search_query}%"), + User.phone.ilike(f"%{search_query}%"), + SystemLog.module.ilike(f"%{search_query}%") + ) + query = query.filter(search_filter) + + # 执行分页查询 + pagination = query.order_by(SystemLog.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + logs_data = [] + for log in pagination.items: + logs_data.append({ + "id": log.id, + "time": log.created_at.strftime('%Y-%m-%d %H:%M:%S'), + "level": log.level, + "message": log.message, + "module": log.module, + "user_id": log.user_id, + "user_phone": log.user.phone if log.user else "系统/游客", + "ip": log.ip, + "path": log.path, + "method": log.method, + "extra": json.loads(log.extra) if log.extra else {} + }) + + return jsonify({ + "logs": logs_data, + "total": pagination.total, + "page": page, + "per_page": per_page, + "total_pages": pagination.pages + }) diff --git a/blueprints/payment.py b/blueprints/payment.py index d7c294d..606aff2 100644 --- a/blueprints/payment.py +++ b/blueprints/payment.py @@ -2,11 +2,9 @@ from flask import Blueprint, request, redirect, url_for, session, jsonify, rende from extensions import db from models import Order, User from services.alipay_service import AlipayService +from services.logger import system_logger import uuid from datetime import datetime -import logging - -logger = logging.getLogger(__name__) payment_bp = Blueprint('payment', __name__, url_prefix='/payment') @@ -44,8 +42,10 @@ def create_payment(): ) db.session.add(order) db.session.commit() + system_logger.info(f"用户创建充值订单", order_id=out_trade_no, amount=package['amount'], points=package['points']) except Exception as e: db.session.rollback() + system_logger.error(f"订单创建失败: {str(e)}") return f"订单创建失败: {str(e)}", 500 # 获取支付链接 @@ -58,38 +58,31 @@ def create_payment(): ) return redirect(pay_url) except Exception as e: + system_logger.error(f"支付链接生成失败: {str(e)}") return f"支付链接生成失败: {str(e)}", 500 @payment_bp.route('/return') def payment_return(): """支付成功后的同步跳转页面""" try: - logger.info(f"收到支付宝同步回调,参数: {dict(request.args)}") - data = request.args.to_dict() signature = data.get("sign") if not signature: - logger.error("同步回调缺少签名参数") return "参数错误:缺少签名", 400 alipay_service = AlipayService() - # 直接传递原始字典,由 verify_notify 处理 success = alipay_service.verify_notify(data, signature) - out_trade_no = data.get('out_trade_no') - order = Order.query.filter_by(out_trade_no=out_trade_no).first() if success: - logger.info(f"同步回调验证成功,订单号: {out_trade_no}") - # 重定向到充值页面,并带上成功参数 return redirect(url_for('auth.buy_page', success='true', out_trade_no=out_trade_no)) else: - logger.error(f"同步回调验证失败,订单号: {out_trade_no}") + system_logger.warning(f"支付同步回调验证失败", order_id=out_trade_no) return "支付验证失败", 400 except Exception as e: - logger.error(f"处理同步回调时发生异常: {str(e)}", exc_info=True) + system_logger.error(f"处理同步回调异常: {str(e)}") return f"处理支付回调失败: {str(e)}", 500 @payment_bp.route('/history', methods=['GET']) @@ -130,13 +123,10 @@ def api_payment_history(): def payment_notify(): """支付宝异步通知""" try: - logger.info(f"收到支付宝异步通知,参数: {request.form.to_dict()}") - data = request.form.to_dict() - signature = data.get("sign") # 不要pop,保留原始数据 + signature = data.get("sign") if not signature: - logger.error("异步通知缺少签名参数") return "fail" alipay_service = AlipayService() @@ -146,34 +136,27 @@ def payment_notify(): out_trade_no = data.get('out_trade_no') trade_no = data.get('trade_no') - logger.info(f"异步通知验证成功,订单号: {out_trade_no}, 支付宝交易号: {trade_no}") - order = Order.query.filter_by(out_trade_no=out_trade_no).first() if order and order.status == 'PENDING': order.status = 'PAID' order.trade_no = trade_no order.paid_at = datetime.utcnow() - # 给用户加积分 - user = User.query.get(order.user_id) + user = db.session.get(User, order.user_id) if user: user.points += order.points - logger.info(f"用户 {user.id} 充值 {order.points} 积分") + system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id) db.session.commit() - logger.info(f"订单 {out_trade_no} 处理成功") return "success" elif order: - logger.warning(f"订单 {out_trade_no} 状态为 {order.status},跳过处理") - return "success" # 已处理过的订单也返回success + return "success" else: - logger.error(f"未找到订单: {out_trade_no}") return "fail" else: - logger.error(f"异步通知验证失败或交易状态异常: {data.get('trade_status')}") return "fail" except Exception as e: - logger.error(f"处理异步通知时发生异常: {str(e)}", exc_info=True) + system_logger.error(f"处理异步通知异常: {str(e)}") db.session.rollback() return "fail" diff --git a/config.py b/config.py index 2edef04..46f1c42 100644 --- a/config.py +++ b/config.py @@ -28,6 +28,8 @@ class Config: # AI API 配置 AI_API = "https://ai.t8star.cn/v1/images/generations" CHAT_API = "https://ai.comfly.chat/v1/chat/completions" + VIDEO_GEN_API = "https://ai.comfly.chat/v2/videos/generations" + VIDEO_POLL_API = "https://ai.comfly.chat/v2/videos/generations/{task_id}" # 试用模式配置 TRIAL_API = "https://ai.comfly.chat/v1/images/generations" diff --git a/create_database.py b/create_database.py index 471ac54..1b3aab5 100644 --- a/create_database.py +++ b/create_database.py @@ -5,10 +5,10 @@ 用于在 PostgreSQL 服务器上创建 ai_vision 数据库 """ -import psycopg2 -from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from sqlalchemy import create_engine, text +from sqlalchemy.engine import url -# 数据库连接信息 +# 数据库连接信息 (从 config 或直接指定) DB_HOST = "331002.xyz" DB_PORT = 2022 DB_USER = "user_xREpkJ" @@ -20,30 +20,23 @@ def create_database(): try: # 连接到默认的 postgres 数据库 print(f"🔗 正在连接到 PostgreSQL 服务器 {DB_HOST}:{DB_PORT}...") - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - user=DB_USER, - password=DB_PASSWORD, - database="postgres" # 先连接到默认数据库 - ) - conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) - cursor = conn.cursor() - # 检查数据库是否存在 - cursor.execute(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'") - exists = cursor.fetchone() + # 构造连接 URL (连接到 postgres 数据库以执行 CREATE DATABASE) + postgres_url = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/postgres" + engine = create_engine(postgres_url, isolation_level="AUTOCOMMIT") - if exists: - print(f"✅ 数据库 {DB_NAME} 已经存在") - else: - # 创建数据库 - print(f"🔧 正在创建数据库 {DB_NAME}...") - cursor.execute(f'CREATE DATABASE {DB_NAME}') - print(f"✅ 数据库 {DB_NAME} 创建成功!") - - cursor.close() - conn.close() + with engine.connect() as conn: + # 检查数据库是否存在 + result = conn.execute(text(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'")) + exists = result.fetchone() + + if exists: + print(f"✅ 数据库 {DB_NAME} 已经存在") + else: + # 创建数据库 + print(f"🔧 正在创建数据库 {DB_NAME}...") + conn.execute(text(f'CREATE DATABASE {DB_NAME}')) + print(f"✅ 数据库 {DB_NAME} 创建成功!") print(f"\n📊 数据库信息:") print(f" 主机: {DB_HOST}:{DB_PORT}") @@ -51,12 +44,6 @@ def create_database(): print(f" 用户: {DB_USER}") print(f"\n💡 下一步:运行 python init_db.py 创建数据表") - except psycopg2.Error as e: - print(f"❌ 数据库操作失败: {e}") - print(f"\n可能的原因:") - print(f" 1. 用户 {DB_USER} 没有创建数据库的权限") - print(f" 2. 网络连接问题") - print(f" 3. 数据库服务器配置限制") except Exception as e: print(f"❌ 发生错误: {e}") diff --git a/extensions.py b/extensions.py index 03197e1..b2bd783 100644 --- a/extensions.py +++ b/extensions.py @@ -1,10 +1,12 @@ from flask_sqlalchemy import SQLAlchemy from flask_redis import FlaskRedis +from flask_migrate import Migrate import boto3 from config import Config db = SQLAlchemy() redis_client = FlaskRedis() +migrate = Migrate() # MinIO Client s3_client = boto3.client( diff --git a/fix_db_manual.py b/fix_db_manual.py index 94ef28a..458ebd8 100644 --- a/fix_db_manual.py +++ b/fix_db_manual.py @@ -1,23 +1,20 @@ -import psycopg2 +from sqlalchemy import create_engine, text from config import Config def migrate(): # 从 URI 解析连接参数 - # postgresql://user:pass@host:port/dbname uri = Config.SQLALCHEMY_DATABASE_URI - print(f"正在手动连接数据库进行迁移...") + print(f"正在手动连接数据库进行迁移 (SQLAlchemy)... ") + + engine = create_engine(uri) try: - conn = psycopg2.connect(uri) - cur = conn.cursor() - - # 添加 api_key 字段 - cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);") - - conn.commit() - cur.close() - conn.close() - print("✅ 数据库字段 users.api_key 手动添加成功") + with engine.connect() as conn: + # 添加 api_key 字段 + print("🔧 正在检查并添加 users.api_key 字段...") + conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);")) + conn.commit() + print("✅ 数据库字段 users.api_key 处理成功") except Exception as e: print(f"❌ 迁移失败: {e}") diff --git a/fix_db_manual_points.py b/fix_db_manual_points.py index d034758..34bd0b6 100644 --- a/fix_db_manual_points.py +++ b/fix_db_manual_points.py @@ -1,31 +1,28 @@ -import psycopg2 +from sqlalchemy import create_engine, text from config import Config def fix_db(): # 从 SQLALCHEMY_DATABASE_URI 提取连接信息 - # 格式: postgresql://user:pass@host:port/db uri = Config.SQLALCHEMY_DATABASE_URI - print(f"🔗 正在尝试连接数据库...") + print(f"🔗 正在尝试连接数据库 (SQLAlchemy)... ") + + engine = create_engine(uri) try: - conn = psycopg2.connect(uri) - cur = conn.cursor() - - # 检查并添加 points 字段 - cur.execute(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN - ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2; - END IF; - END $$; - """) - - conn.commit() - cur.close() - conn.close() - print("✅ 数据库字段 points 处理完成 (默认值 2)") - + with engine.connect() as conn: + # 检查并添加 points 字段 + print("🔧 正在检查并添加 users.points 字段...") + conn.execute(text(""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN + ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2; + END IF; + END $$; + """)) + conn.commit() + print("✅ 数据库字段 points 处理完成 (默认值 2)") + except Exception as e: print(f"❌ 数据库修复失败: {e}") diff --git a/logs/system.log b/logs/system.log deleted file mode 100644 index 8de7ffb..0000000 --- a/logs/system.log +++ /dev/null @@ -1,168 +0,0 @@ -[2026-01-11 17:43:34] INFO - 用户请求发送验证码 -[2026-01-11 17:43:36] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 912B43E3-5393-53A4-92B7-669BA1DF61A3 Response: {'RequestId': '912B43E3-5393-53A4-92B7-669BA1DF61A3', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dysmsapi&requestId=912B43E3-5393-53A4-92B7-669BA1DF61A3', 'HostId': 'dysmsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404} -[2026-01-11 17:50:36] INFO - 用户请求发送验证码 -[2026-01-11 17:50:37] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 762395A5-97D5-569F-9A50-8D2AD36009C1 Response: {'RequestId': '762395A5-97D5-569F-9A50-8D2AD36009C1', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dypnsapi&requestId=762395A5-97D5-569F-9A50-8D2AD36009C1', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404} -[2026-01-11 17:52:03] INFO - 用户注册请求 -[2026-01-11 17:52:03] WARNING - 注册失败: 验证码错误 -[2026-01-11 17:58:53] INFO - 用户请求发送验证码 -[2026-01-11 17:58:53] INFO - 验证码发送成功 -[2026-01-11 17:59:51] INFO - 用户请求发送验证码 -[2026-01-11 17:59:51] WARNING - 验证码发送失败: 请2秒后再试 -[2026-01-11 17:59:56] INFO - 用户请求发送验证码 -[2026-01-11 17:59:57] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4 Response: {'RequestId': 'E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400} -[2026-01-11 18:02:00] INFO - 用户请求发送验证码 -[2026-01-11 18:02:01] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8 Response: {'RequestId': 'BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400} -[2026-01-11 18:04:11] INFO - 用户请求发送验证码 -[2026-01-11 18:04:12] WARNING - 验证码发送失败: 请检查模板内容与模板参数是否匹配 -[2026-01-11 18:05:41] INFO - 用户请求发送验证码 -[2026-01-11 18:05:43] WARNING - 验证码发送失败: 非法参数 -[2026-01-11 18:06:41] INFO - 用户请求发送验证码 -[2026-01-11 18:06:42] WARNING - 验证码发送失败: check frequency failed -[2026-01-11 18:07:53] INFO - 用户请求发送验证码 -[2026-01-11 18:07:53] INFO - 验证码发送成功 -[2026-01-11 18:12:40] INFO - 用户请求发送验证码 -[2026-01-11 18:12:42] WARNING - 验证码发送失败: 非法参数 -[2026-01-11 18:13:42] INFO - 用户请求发送验证码 -[2026-01-11 18:13:44] INFO - 验证码发送成功 -[2026-01-11 18:14:18] INFO - 用户注册请求 -[2026-01-11 18:14:19] WARNING - 注册失败: 验证码错误 -[2026-01-11 18:16:05] INFO - 用户注册请求 -[2026-01-11 18:16:07] WARNING - 注册失败: 验证码错误 -[2026-01-11 18:17:34] INFO - 用户注册请求 -[2026-01-11 18:17:35] WARNING - 注册失败: 验证码错误 -[2026-01-11 18:20:57] INFO - 用户请求发送验证码 -[2026-01-11 18:20:59] INFO - 验证码发送成功 -[2026-01-11 18:21:11] INFO - 用户注册请求 -[2026-01-11 18:21:12] INFO - 用户注册成功 -[2026-01-11 18:21:14] INFO - 用户登录尝试 -[2026-01-11 18:21:14] INFO - 用户登录成功 -[2026-01-11 18:33:40] INFO - 用户登录尝试 -[2026-01-11 18:33:40] WARNING - 登录失败: 手机号或密码错误 -[2026-01-11 18:33:47] INFO - 用户登录尝试 -[2026-01-11 18:33:47] INFO - 用户登录成功 -[2026-01-11 18:34:19] INFO - 用户登录尝试 -[2026-01-11 18:34:19] INFO - 用户登录成功 -[2026-01-11 19:05:37] INFO - 用户登录尝试 -[2026-01-11 19:05:37] INFO - 用户登录成功 -[2026-01-11 19:14:10] INFO - 用户登录尝试 -[2026-01-11 19:14:10] INFO - 用户登录成功 -[2026-01-11 21:51:06] INFO - 用户登录尝试 -[2026-01-11 21:51:06] INFO - 用户登录成功 -[2026-01-11 21:59:14] INFO - 试用模式生成 -[2026-01-11 21:59:14] INFO - 用户发起图片生成 -[2026-01-11 22:09:52] INFO - 积分预扣除 (试用模式) -[2026-01-11 22:10:10] INFO - 用户生成图片成功 -[2026-01-11 23:41:00] INFO - 用户登录尝试 -[2026-01-11 23:41:23] INFO - 用户登录尝试 -[2026-01-11 23:41:23] INFO - 用户登录成功 -[2026-01-11 23:43:21] INFO - 用户修改密码成功 -[2026-01-11 23:43:54] INFO - 用户登录尝试 -[2026-01-11 23:43:54] INFO - 用户登录成功 -[2026-01-11 23:44:01] INFO - 用户登录尝试 -[2026-01-11 23:44:01] WARNING - 登录失败: 手机号或密码错误 -[2026-01-11 23:44:07] INFO - 用户登录尝试 -[2026-01-11 23:44:07] WARNING - 登录失败: 手机号或密码错误 -[2026-01-11 23:44:10] INFO - 用户登录尝试 -[2026-01-11 23:44:10] INFO - 用户登录成功 -[2026-01-12 22:00:04] INFO - 用户登录尝试 -[2026-01-12 22:00:04] INFO - 用户登录成功 -[2026-01-12 22:00:55] INFO - 用户充值积分成功 -[2026-01-12 22:32:04] INFO - 用户登录尝试 -[2026-01-12 22:32:04] INFO - 用户登录成功 -[2026-01-12 22:35:37] INFO - 用户登录尝试 -[2026-01-12 22:35:37] INFO - 用户登录成功 -[2026-01-12 22:38:54] INFO - 用户生成图片成功 -[2026-01-12 22:49:54] INFO - 用户登录尝试 -[2026-01-12 22:49:54] INFO - 用户登录成功 -[2026-01-12 22:49:59] INFO - 用户登录尝试 -[2026-01-12 22:49:59] INFO - 用户登录成功 -[2026-01-12 22:53:16] INFO - 用户生成图片成功 -[2026-01-12 23:05:38] INFO - 用户登录尝试 -[2026-01-12 23:05:38] INFO - 用户登录成功 -[2026-01-12 23:09:28] INFO - 用户登录尝试 -[2026-01-12 23:09:28] INFO - 用户登录成功 -[2026-01-12 23:18:01] INFO - 用户生成图片成功 -[2026-01-13 22:27:07] INFO - 用户生成图片成功 -[2026-01-13 22:44:18] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:44:21] WARNING - API 报错,积分已退还 -[2026-01-13 22:46:45] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:47:04] INFO - 用户生成文本成功 -[2026-01-13 22:50:18] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:50:36] INFO - 用户生成文本成功 -[2026-01-13 22:57:55] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:58:07] INFO - 用户生成文本成功 -[2026-01-13 23:02:42] INFO - 积分预扣除 (普通试用) -[2026-01-13 23:02:55] INFO - 用户生成文本成功 -[2026-01-13 23:08:26] INFO - 积分预扣除 (普通试用) -[2026-01-13 23:08:43] INFO - 用户生成文本成功 -[2026-01-13 23:09:40] INFO - 用户登录尝试 -[2026-01-13 23:09:40] INFO - 用户登录成功 -[2026-01-13 23:11:23] INFO - 积分预扣除 (普通试用) -[2026-01-13 23:11:41] INFO - 用户生成文本成功 -[2026-01-13 23:13:29] INFO - 用户登录尝试 -[2026-01-13 23:13:30] INFO - 用户登录成功 -[2026-01-13 23:14:33] INFO - 用户生成图片成功 -[2026-01-13 23:17:57] INFO - 用户生成图片成功 -[2026-01-13 23:18:03] INFO - 用户生成图片成功 -[2026-01-13 23:18:15] INFO - 用户生成图片成功 -[2026-01-13 23:20:10] INFO - 用户生成图片成功 -[2026-01-13 23:20:11] INFO - 用户生成图片成功 -[2026-01-13 23:20:11] INFO - 用户生成图片成功 -[2026-01-13 23:20:14] INFO - 用户生成图片成功 -[2026-01-13 23:21:19] INFO - 用户生成图片成功 -[2026-01-13 23:21:21] INFO - 用户生成图片成功 -[2026-01-13 23:21:21] INFO - 用户生成图片成功 -[2026-01-13 23:21:22] INFO - 用户生成图片成功 -[2026-01-13 23:24:14] INFO - 用户生成图片成功 -[2026-01-13 23:24:15] INFO - 用户生成图片成功 -[2026-01-13 23:24:16] INFO - 用户生成图片成功 -[2026-01-13 23:24:17] INFO - 用户生成图片成功 -[2026-01-13 23:30:04] INFO - 用户生成图片成功 -[2026-01-13 23:30:04] INFO - 用户生成图片成功 -[2026-01-13 23:30:06] INFO - 用户生成图片成功 -[2026-01-13 23:48:04] INFO - 用户生成图片成功 -[2026-01-13 23:48:05] INFO - 用户生成图片成功 -[2026-01-13 23:48:07] INFO - 用户生成图片成功 -[2026-01-13 23:52:15] INFO - 用户生成图片成功 -[2026-01-13 23:52:15] INFO - 用户生成图片成功 -[2026-01-13 23:52:22] INFO - 用户生成图片成功 -[2026-01-13 23:59:01] INFO - 用户登录尝试 -[2026-01-13 23:59:02] INFO - 用户登录成功 -[2026-01-14 16:45:23] INFO - 用户登录尝试 -[2026-01-14 16:45:23] INFO - 用户登录成功 -[2026-01-14 20:12:31] INFO - 用户登录尝试 -[2026-01-14 20:12:31] INFO - 用户登录成功 -[2026-01-14 20:17:10] INFO - 用户登录尝试 -[2026-01-14 20:17:10] INFO - 用户登录成功 -[2026-01-15 20:17:15] INFO - 用户登录尝试 -[2026-01-15 20:17:15] WARNING - 登录失败: 手机号或密码错误 -[2026-01-15 20:17:17] INFO - 用户登录尝试 -[2026-01-15 20:17:17] WARNING - 登录失败: 手机号或密码错误 -[2026-01-15 20:17:21] INFO - 用户登录尝试 -[2026-01-15 20:17:22] WARNING - 登录失败: 手机号或密码错误 -[2026-01-15 20:17:32] INFO - 用户登录尝试 -[2026-01-15 20:17:32] INFO - 用户登录成功 -[2026-01-15 20:22:42] INFO - 用户生成图片成功 -[2026-01-15 20:31:31] INFO - 用户生成图片成功 -[2026-01-15 20:45:22] INFO - 用户生成图片成功 -[2026-01-15 20:49:12] INFO - 用户生成图片成功 -[2026-01-15 20:50:53] INFO - 用户生成图片成功 -[2026-01-15 20:52:35] INFO - 用户生成图片成功 -[2026-01-15 20:52:35] INFO - 用户生成图片成功 -[2026-01-15 20:52:37] INFO - 用户生成图片成功 -[2026-01-15 20:52:37] INFO - 用户生成图片成功 -[2026-01-15 20:56:42] INFO - 用户生成图片成功 -[2026-01-15 20:57:31] INFO - 用户生成图片成功 -[2026-01-15 20:59:14] INFO - 用户生成图片成功 -[2026-01-15 21:00:00] INFO - 用户生成图片成功 -[2026-01-15 21:01:05] INFO - 用户生成图片成功 -[2026-01-15 21:01:05] INFO - 用户生成图片成功 -[2026-01-15 21:01:05] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:08:09] INFO - 积分预扣除 (普通试用) -[2026-01-15 21:09:55] INFO - 用户生成图片成功 (Base64模式) -[2026-01-15 21:13:38] INFO - 积分预扣除 (普通试用) -[2026-01-15 21:13:59] INFO - 用户生成图片成功 (上传:Base64 / 接收:URL) diff --git a/middlewares/__pycache__/auth.cpython-312.pyc b/middlewares/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index 60baaeb0da834b0a84070443d24b05dfd9616121..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2780 zcmds3Z%k8H6o2paz5bzvRvuEN0tFQQ0@Ca!wLHq$Pg+}F-Y&%vG1V?bBp9qjnk_4UdQvr=r z6QG@RfN?Sbty3G&Id$ro#;*?;oCd7ZrwC^e$6#sXv{;(@bq@U;EPRnvm_Fq06&;MC z6M0b-LP1666GK74+pp*m&k2b4NQy3u{4Q?@DLTX-3G<@lAQk#0kw+df?;muU{F80K z%A43*X>oLjxRlppgqP&VQ~FEr2X4p72-!(EsPJ0+GIQqJ%y%PmU%oSY_2lfQgL9va zWzHl$>b$(b@gb`5lbyw4gnZD3uMn&JROmReVD~r`p~Oy!E)4o$r4`L*Sn&bBa#Udj zWRZ-8aZbb09CLPYjHA6&Cm|IV#wBw>rd0BTU?{~pYCqWTIng}5f5%L209z;jq zfsX)(+a+)dOryJ3X#CTJ4a{EsCNmhz2Q7{Re34QZ9wB^Cv?s*z3ubp%>Td9dx`d#s z*WJbA^QMFRQOTi44E|rybny~`O)GS-Tk2MdM9GaLu}_e?5yS`SK&4P4@Ru$4KLO|_ydvu|aYd+W&3U&GUg8_l#{5+n=iw9K?di#}9j`xI+TjDvFHyrdx zIMa^R=RAX)!gO_|Hn=JNL= zLy=qNx(RdL`MybWW7=Akth&KYRPRbzci+^=*1fW2?=)*W+a_;%dc5uXeb@I*ZrGDz z+htSxl-V-8NjBGv;=gNTeN7fyArK_9gF+iXufjrvQ#x!%mAF$%mOGB@_#*^Vtib|2 zWQt->4IIc)I;J+OA+#m)pv_wdQ;vSZA7VZx&QY9U83h?0NkK)N@v>=E znxU7`OmT+_YgB?afq=mfL3cgv(eel^QqWu7e2t5p)f*_pdQJ#DK^zILN?V&k5at^MY6vTeUywm)sH zzMzx0?)*{rv*`!Zr2WN|wL`XaOtaQRbG$jx8gD(nCB@dqo}Xf^qtdPFritpNv7M9E z&#b=wiEF~OSLG-7$ek}wY?#J)HarW`N`wr>hjE{_V1lhBOlxJj z>|uh1Dy0(aCaffIfnB^2=0y#DaFeMFd)Et+(B^>fw>E1 z#mxn9r7q+qf_u~!m;-4SC4M(HP8zF5-$)r7$H=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/9024b393e1ef_add_some_columns.py b/migrations/versions/9024b393e1ef_add_some_columns.py new file mode 100644 index 0000000..e9ac2d3 --- /dev/null +++ b/migrations/versions/9024b393e1ef_add_some_columns.py @@ -0,0 +1,59 @@ +"""add some columns + +Revision ID: 9024b393e1ef +Revises: +Create Date: 2026-01-16 20:58:52.178001 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9024b393e1ef' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ai_models') + op.drop_table('aspect_ratios') + op.drop_table('prompt_templates') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('has_used_points', sa.Boolean(), nullable=True)) + batch_op.drop_column('role') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('role', sa.VARCHAR(length=20), server_default=sa.text("'user'::character varying"), autoincrement=False, nullable=True)) + batch_op.drop_column('has_used_points') + + op.create_table('prompt_templates', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('label', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('prompt_templates_pkey')) + ) + op.create_table('aspect_ratios', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('label', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('value', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('aspect_ratios_pkey')) + ) + op.create_table('ai_models', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('value', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('ai_models_pkey')), + sa.UniqueConstraint('value', name=op.f('ai_models_value_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + # ### end Alembic commands ### diff --git a/models.py b/models.py index 05993fc..c04f1d8 100644 --- a/models.py +++ b/models.py @@ -30,6 +30,7 @@ class User(db.Model): password_hash = db.Column(db.String(255), nullable=False) api_key = db.Column(db.String(255)) # 存储用户的 API Key points = db.Column(db.Integer, default=2) # 账户积分,默认赠送2次试用 + has_used_points = db.Column(db.Boolean, default=False) # 是否使用过积分 # 关联角色 ID role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) created_at = db.Column(db.DateTime, default=datetime.utcnow) @@ -117,3 +118,24 @@ class Order(db.Model): paid_at = db.Column(db.DateTime) user = db.relationship('User', backref=db.backref('orders', lazy='dynamic', order_by='Order.created_at.desc()')) + +class SystemLog(db.Model): + """系统精细化日志记录""" + __tablename__ = 'system_logs' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # 可能没有登录用户 + level = db.Column(db.String(20), nullable=False) # INFO, WARNING, ERROR, DEBUG + module = db.Column(db.String(50)) # 模块名 + message = db.Column(db.Text, nullable=False) # 日志内容 + extra = db.Column(db.Text) # 额外信息的 JSON 字符串 + + # 请求上下文信息 + ip = db.Column(db.String(50)) + path = db.Column(db.String(255)) + method = db.Column(db.String(10)) + user_agent = db.Column(db.String(255)) + + created_at = db.Column(db.DateTime, default=datetime.now) + + user = db.relationship('User', backref=db.backref('logs', lazy='dynamic', order_by='SystemLog.created_at.desc()')) diff --git a/requirements.txt b/requirements.txt index c73c4f05a11b4324a66a2f143778f90ed504e2d2..8b2ee9d5f60e76655bef84b38ee3bae781c07428 100644 GIT binary patch delta 247 zcmdlacuaul|G&u_SY#%DVKK6FW5{7hWGH6HX3%BuWyoYmXD9;lOBhlaY=O{(L65-z zh|L*z8Mqjb)do$z$s#>@8@tBjIyR%pChQKAW0(~t$FV6*zQU%!3DRo})Mq?dk+F30 z2}Y&KUzoHQ%O{s|C^BYE-pDFDxs6q8vINi#Hb7lJ*lZ@xVV9a5!l5uZk5yvwB4&-r zPngXnpJJAnya8z9IaZ~~B|w{MnAIlVV>g@Z1LTWvI80>HnV8@(S%Fz*vKp(}WHC06 g$u{5C8xG delta 366 zcmX|-y-UMT6vb~|J}RQ1C{n7HG+>BDr8XhBSTYHUU3AbzAr_<1N=y8JF49SGa0q92 z5|NJn4bn-Rq?5a=IG5^u5#(~$4zD-yJSl#LsOU=zA+>ngbe!slLTd1w(>hO? zR>)_q;y|yWpP50|$f9Fh;Z@ne!0=!S6YrTU?94X;QN)v;#~1G^iiwV9<6d&I-9} zJ-xcJDXUB}C;GoFQ=Ht+CiyaYx{VLb!VNd-ilVZGJ24MmvkFzti8<$z$-DfH_&+33 yX8!R$`3DXaD|stnnj@|i3u9`K3yr`kE z%R1~#Y;~2^Zt#2UXp4)BcBWmaU;9V5Gfw+Qlfq2Wx9p7F5(0lMBhIvA|LM8!B_SX) zJL}$=oOkZI=bn4+J->VI$=~wwvRchgB(=CO}%58QifF z64U5%w%Y>0<=Xv%z`I4(=JmGO6fD1KWg09%^(%@8|g{CC<% zFjT}0)loxr%upLO)DE?cHjNn?L)2;Q95{y-TuB^S`AUX>fh(E0u0gcar37i_x?o#M z18D}dX&FKw%>ZX!$`K%!6XLuKbGKB(DI0r{mGr*?qUrbVCeC~?b?)}${lUb@<;2Ku zrb2gWK`CnM-FDZ-=xljWG;alr^gL6A@atW*HVWK-7dE+=Hv-tvV=c|ieN z^lbvthbHq4@#V&F?LckZQhs&o`K|HF>R9E*XywNED(mmqYi!(DiWO=GYM$k(_1ama z)@TJY%(Z8S1mAik#{v_^LCAy@1gSQh`W~l(RCgwirbi)kgqX=@!TDTCev?4oB8F2o zj?+trTu^Z;H^Gs|nILl!-l`yd5nU!B{ItIsd`X_WIu$yrgn#B&kEY(gQL#FC?vK;` z1IdR2$>HXctT z9PA3EDG=1#1sQOV{x#FS`5x>-GHCEAIxFwQb6UnJjAe#-k3}gqP3x^gMy)v0%r_dm{F`Kt%8iMz>bBhw?-D_*G5Dy9Z+f7y3w{@s<={Ic)z zbl<6&%U571aqk?sqxb^y7xo8fJMr_Y{xa}LSvKWJ_>;t$0Z8kK2PYDvqYV}N;Lk@6 zf6NYb)nSiSCowLM!@&y@%?pA@kSMzc$}ZCD@hTaWIl>E2$0;u`K0HZ%)F!y`qfTK9 zlBW5Xo%f1Pk6W<9uvN*a62;j>n-U0JMJ2Jq%6}AA4pF!BKgypdGKbp++8~ivb*Y{qWH_h-cZ$rR-Op%LP#n%3 z$c>b)8`rOYQfLb8{_kXd$y21#m?m_~&$OOwjaW86C>(w3zHu}sVrqQc6lxvUy){u- z^1oRcl%D%4;iOKOnhMdS?d3rK{Du+eXN8((4f$DVWiv(og`$CPv#N33?Y8ysEGubQ zw#UQyU6|Lg>`(kQmog&YOCp0rsKeY=AUBcx(#Y?kfJ;l6;EL3TQ$as^s$%GE#8bV7 z);=}r=-Ro`D%zyX!Rl763=O#kl<31Qo9}JqkBG|Slhhcg7HIa3Cn diff --git a/services/__pycache__/logger.cpython-312.pyc b/services/__pycache__/logger.cpython-312.pyc deleted file mode 100644 index 7268fff8728a0c41ac111e24a0ed6e6a6b7ead17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3986 zcmd59ZEO?Cb#}dLkL`_sBG3iU_dti5)U z2A!%_>aFBAZ{ECl^WMz+@*m2|d5`evdJB3Y=@-!FrJz=xH zFlS*^F*Q?9YJk7gZonZ>oZbE?gj2P)^>Viw88^(=}D8<+}c-0`6eOJ)&| z<04u_9N8Q){SMKT94sDiu+kW>Ea47j?r<=bcV0_%se&rrb>AuIIl*PQ|4P5oEmb+E zl}5)aCipD>KRRV+NxFln9u?m$`s0KPEUI(XN@Lblx>eBT%Q&`w$%c|V2g?>cfkQ8N z|Lu11C2i6u?CZ0<7j&u{DOu4oK5F}DJH&#H&^&q~!eJS4)EsQ<47)6xRK~m0rbaFQ zc_lhLI`XbD3Za9wRMb!*zB1wWLoEu8+Hps}8w|B1vnSn8l_4MX3r}_ z=h{NawvZYOo$LyIvnzB4V$Z{(b}(s%1C|GLh=EMY8`I2u&{%Fp(};yBkeFmk=e1is}xVUVO^njl94Ii;CTQB@~9 z2$n###GoY&LduYb@;SpQj~XdzY#?|C9a)s=5QOh}quMnPGmKb58=xAwphq=2kk`9? zByC|?)>FDE%bDtp`Jmk)mw_1iX8@Dv!Oo_Yoz1zO&A0rEI}c3>ANW_XG~=K4ui(ZU zZoGE(`oPtJC4AtS8`Zw@Knl$CO!v&3nm%={X1;Y%>R7GbvszoXTHW}}E7tm^q%{e7 zt5jl+q z%Ee{eu?|L}zGxvGunA)6%1(rPA-HFe_e)Y?G026i5NDpc<=c=dxh$V`jdK)=r4EJj znlK^4o^8@&QKsAUI^yDXk#=VJQFWQ@sqAmNw3roD;cl_aW!*aGBti&QC=MBrIpNuG z#x?~SQl=;Cxx;P*J`SbvK7i+79Cw{VV_euf`Z6p${_(9Rlb0S%-3#`!f);%A+h70n zcR&Bz$5(@|9}GVF+wT7la)U2ES&(X^D1VTAo5YTB0^?62+>>$G;5zO63fT z4pt|%6dfg+41uldI{L5~5mtBl!d}8uvs@`-3_{W*gSKcdgVa=#BZR$)C1fD3ASS7i zvP5+>IYO<94AGKANyW7DX&N<%7RF>Z@RAk=!VDZ>pbdcK1!ZKY&?HSd*u2~v(utXt zRV7VjC1y}o##rRBkL+VC0eNJU4#}n=+a*5JusPH=Ewa<-2LL9~no!}be6XW_w)INu zN9|MZeqP&fUAihQggO^%-&(EPwc4;3;7=Ro_kQ6O>jG1~YXMZ(c)jdu+0V+SdLKx& zD^gQVYFgNJ@K=X^)Ah@)PxXcO-d}osVBveQh1Z6bBz?_|cDLMmC%3C>>a@)s&Pm~g z18;w-e71X8`qn?zc+Ok-C#m8&WhM8A-JWhA`n`|u4!EMlN)HR{T3K~o|2}|OmiuAc z`o#-wvyr}~Vr&~SSqPO4E{DQ=A7Q00>>{krT7sT}q<0w5+phehu^Y79!($4GQOZtr z-hp(&BvZNR^4adM0huz-okYcqR-41Mb2UqN4^UQcLk>60otW>r_1-c*L_)y&f4u<9 zbM|O|UvFQJa|s?WHU-7Wz6bIavuBgx%)(67Cc_ImjxydYw%gYBEaCkn+k5B7?zxxo zn_mIj#pC_`r~4htMU4=HyvXh&%sH`ly`_m-^gnLm4La2dbx zRj~Tl@$T;w-&<6*^P?|(?PLFLwawoPbiy9owkOO%#$@%MwL`LO`D8h1sH4zL$d)9_ zKNwZ)MoE^*ezt{0m>ocbITJa;06XY*UYecgh?h|rVEKXu0bE8;`Fio-dgl(Y@@We% zzVQ_EVkZNK*BP)?vA2XP2y4?x4Fk-3tqRtrfLFH{`W>2Ytb9-#paejRNbQVHUS&IK z8DPCEG;KOzHZj}mc1b4e{~YTUhoK-5Y9D8nQM_QFBK4QeG*36rv`)AFgv^dz8JoR$<>EZK zId)_0=EWNq?~N}uA6cw9`q|+{-!~>btH?je|HyyIKh-m*E~9;a7rxFN{sJ*@j7ODC L9|bOz*~?8!~oDc7%2@a`bR_R{=Rv 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 { - isRegisterMode = !isRegisterMode; - document.getElementById('authTitle').innerText = isRegisterMode ? "加入视界 AI" : "欢迎回来"; - document.getElementById('authSub').innerText = isRegisterMode ? "注册并开启创作" : "请登录以开启 AI 创作之旅"; - document.getElementById('authSubmitBtn').querySelector('span').innerText = isRegisterMode ? "立即注册" : "立即登录"; - document.getElementById('authSwitchBtn').innerText = isRegisterMode ? "已有账号?返回登录" : "没有账号?立即注册"; - document.getElementById('smsGroup').classList.toggle('hidden', !isRegisterMode); -}; +const getEl = (id) => document.getElementById(id); -document.getElementById('sendSmsBtn').onclick = async () => { - const phone = document.getElementById('authPhone').value; - const btn = document.getElementById('sendSmsBtn'); - - if(!phone) return showToast('请输入手机号', 'warning'); - - btn.disabled = true; - const originalText = btn.innerText; - - const r = await fetch('/api/auth/send_code', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ phone }) - }); - const d = await r.json(); - - if(d.error) { - showToast(d.error, 'error'); - btn.disabled = false; - } else { - showToast(d.message, 'success'); - let countdown = 60; - const timer = setInterval(() => { - btn.innerText = `${countdown}秒后重试`; - countdown--; - if(countdown < 0) { - clearInterval(timer); - btn.innerText = originalText; - btn.disabled = false; - } - }, 1000); +const updateUI = () => { + const title = getEl('authTitle'); + const sub = getEl('authSub'); + const submitBtnSpan = getEl('authSubmitBtn')?.querySelector('span'); + const switchBtn = getEl('authSwitchBtn'); + const forgotBtn = getEl('forgotPwdBtn'); + const smsGroup = getEl('smsGroup'); + const captchaGroup = getEl('captchaGroup'); + + if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return; + + if (authMode === 1) { // Register + title.innerText = "加入视界 AI"; + sub.innerText = "注册并开启创作"; + submitBtnSpan.innerText = "立即注册"; + switchBtn.innerText = "已有账号?返回登录"; + forgotBtn.classList.add('hidden'); + smsGroup.classList.remove('hidden'); + captchaGroup.classList.remove('hidden'); // 注册模式默认显示图形验证码防止刷短信 + } else if (authMode === 2) { // Reset Password + title.innerText = "重置密码"; + sub.innerText = "验证短信以设置新密码"; + submitBtnSpan.innerText = "确认重置"; + switchBtn.innerText = "我想起来了,返回登录"; + forgotBtn.classList.add('hidden'); + smsGroup.classList.remove('hidden'); + captchaGroup.classList.remove('hidden'); // 重置模式默认显示 + } else { // Login + title.innerText = "欢迎回来"; + sub.innerText = "请登录以开启 AI 创作之旅"; + submitBtnSpan.innerText = "立即登录"; + switchBtn.innerText = "没有账号?立即注册"; + forgotBtn.classList.remove('hidden'); + smsGroup.classList.add('hidden'); + captchaGroup.classList.add('hidden'); // 登录默认隐藏,除非高频失败 } }; -document.getElementById('authSubmitBtn').onclick = async () => { - const phone = document.getElementById('authPhone').value; - const password = document.getElementById('authPass').value; - const code = document.getElementById('authCode').value; +const refreshCaptcha = () => { + const phone = getEl('authPhone')?.value; + const captchaImg = getEl('captchaImg'); + if (!phone || !captchaImg) return; + captchaImg.src = `/api/auth/captcha?phone=${phone}&t=${Date.now()}`; +}; - const url = isRegisterMode ? '/api/auth/register' : '/api/auth/login'; - const body = isRegisterMode ? { phone, password, code } : { phone, password }; +const handleAuth = async () => { + const phone = getEl('authPhone')?.value; + const password = getEl('authPass')?.value; - const r = await fetch(url, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(body) - }); - const d = await r.json(); - if(d.error) { - showToast(d.error, 'error'); - } else { - showToast(d.message, 'success'); - if(isRegisterMode) { - isRegisterMode = true; - document.getElementById('authSwitchBtn').click(); - } else { - // 获取来源页面路径 - const urlParams = new URLSearchParams(window.location.search); - const nextUrl = urlParams.get('next') || '/'; - window.location.href = nextUrl; + if (!phone || !password) { + return showToast('请输入手机号和密码', 'warning'); + } + + let url = ''; + let body = { phone, password }; + + if (authMode === 1) { + url = '/api/auth/register'; + body.code = getEl('authCode')?.value; + } else if (authMode === 0) { + url = '/api/auth/login'; + if (!getEl('captchaGroup')?.classList.contains('hidden')) { + body.code = getEl('authCaptcha')?.value; } + } else if (authMode === 2) { + url = '/api/auth/reset_password'; + body.code = getEl('authCode')?.value; + if (!body.code) return showToast('请输入短信验证码', 'warning'); } -}; \ No newline at end of file + + try { + const btn = getEl('authSubmitBtn'); + if (btn) btn.disabled = true; + + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const d = await r.json(); + + if (r.status === 403 && d.require_captcha) { + showToast(d.error, 'warning'); + getEl('captchaGroup')?.classList.remove('hidden'); + refreshCaptcha(); + if (btn) btn.disabled = false; + return; + } + + if (d.error) { + showToast(d.error, 'error'); + if (getEl('captchaGroup') && !getEl('captchaGroup').classList.contains('hidden')) { + refreshCaptcha(); + } + if (btn) btn.disabled = false; + } else { + showToast(d.message, 'success'); + if (authMode === 1 || authMode === 2) { + authMode = 0; + updateUI(); + if (btn) btn.disabled = false; + } else { + const urlParams = new URLSearchParams(window.location.search); + const nextUrl = urlParams.get('next') || '/'; + window.location.href = nextUrl; + } + } + } catch (e) { + showToast('网络连接失败', 'error'); + const btn = getEl('authSubmitBtn'); + if (btn) btn.disabled = false; + } +}; + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + updateUI(); + + getEl('authSwitchBtn').onclick = () => { + authMode = (authMode === 0) ? 1 : 0; + updateUI(); + if (authMode === 1 || authMode === 2) refreshCaptcha(); + }; + + getEl('forgotPwdBtn').onclick = () => { + authMode = 2; + updateUI(); + refreshCaptcha(); + }; + + getEl('captchaImg').onclick = refreshCaptcha; + + getEl('authSubmitBtn').onclick = handleAuth; + + // 回车登录支持 + ['authPhone', 'authPass', 'authCode', 'authCaptcha'].forEach(id => { + getEl(id)?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') handleAuth(); + }); + }); + + getEl('sendSmsBtn').onclick = async () => { + const phone = getEl('authPhone')?.value; + const captcha = getEl('authCaptcha')?.value; + const btn = getEl('sendSmsBtn'); + + if (!phone) return showToast('请输入手机号', 'warning'); + if (!captcha) { + getEl('captchaGroup')?.classList.remove('hidden'); + refreshCaptcha(); + return showToast('请先输入图形验证码以发送短信', 'warning'); + } + + btn.disabled = true; + const originalText = btn.innerText; + + try { + const r = await fetch('/api/auth/send_code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, captcha }) + }); + const d = await r.json(); + + if (d.error) { + showToast(d.error, 'error'); + btn.disabled = false; + if (d.show_captcha || d.refresh_captcha) { + getEl('captchaGroup')?.classList.remove('hidden'); + refreshCaptcha(); + } + } else { + showToast(d.message, 'success'); + // 发送成功后也要刷新图形验证码,防止被再次利用 + refreshCaptcha(); + let countdown = 60; + const timer = setInterval(() => { + btn.innerText = `${countdown}秒后重试`; + countdown--; + if (countdown < 0) { + clearInterval(timer); + btn.innerText = originalText; + btn.disabled = false; + } + }, 1000); + } + } catch (e) { + showToast('短信发送失败', 'error'); + btn.disabled = false; + } + }; +}); diff --git a/static/js/main.js b/static/js/main.js index 0a038fd..be58315 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -7,38 +7,59 @@ async function checkAuth() { const entry = document.getElementById('loginEntryBtn'); const loginHint = document.getElementById('loginHint'); const submitBtn = document.getElementById('submitBtn'); - - if(d.logged_in) { - if(profile) profile.classList.remove('hidden'); - if(entry) entry.classList.add('hidden'); - if(loginHint) loginHint.classList.add('hidden'); + + if (d.logged_in) { + if (profile) profile.classList.remove('hidden'); + if (entry) entry.classList.add('hidden'); + if (loginHint) loginHint.classList.add('hidden'); submitBtn.disabled = false; submitBtn.classList.remove('opacity-50', 'cursor-not-allowed'); const phoneDisp = document.getElementById('userPhoneDisplay'); - if(phoneDisp) phoneDisp.innerText = d.phone; + if (phoneDisp) phoneDisp.innerText = d.phone; // 处理积分显示 const pointsBadge = document.getElementById('pointsBadge'); const pointsDisplay = document.getElementById('pointsDisplay'); - if(pointsBadge && pointsDisplay) { + if (pointsBadge && pointsDisplay) { pointsBadge.classList.remove('hidden'); pointsDisplay.innerText = d.points; } const headerPoints = document.getElementById('headerPoints'); - if(headerPoints) headerPoints.innerText = d.points; + if (headerPoints) headerPoints.innerText = d.points; + + // 处理自定义 Key 显示逻辑 + // 处理自定义 Key 显示逻辑 + const keyBtn = document.getElementById('modeKeyBtn'); + const modeButtonsContainer = keyBtn ? keyBtn.parentElement : null; + + if (keyBtn && modeButtonsContainer) { + if (d.hide_custom_key) { + // 如果后端说要隐藏(用过积分生成),则隐藏整个按钮组 + modeButtonsContainer.classList.add('hidden'); + // 修改标题为“账户状态” + const authTitle = document.querySelector('#authSection h3'); + if (authTitle) authTitle.innerText = '账户状态'; + } else { + // 否则显示按钮组,让用户可以选择 + modeButtonsContainer.classList.remove('hidden'); + + // 如果有 Key,默认帮忙切过去,方便老用户 + if (d.api_key) { + switchMode('key'); + const keyInput = document.getElementById('apiKey'); + if (keyInput && !keyInput.value) keyInput.value = d.api_key; + return; + } + } + } // 如果用户已经有绑定的 Key,且当前没手动输入,则默认切到 Key 模式 - if(d.api_key) { - switchMode('key'); - const keyInput = document.getElementById('apiKey'); - if(keyInput && !keyInput.value) keyInput.value = d.api_key; - } else { - switchMode('trial'); - } + // 强制使用积分模式 + switchMode('trial'); } else { - if(profile) profile.classList.add('hidden'); - if(entry) entry.classList.remove('hidden'); - if(loginHint) loginHint.classList.remove('hidden'); + if (profile) profile.classList.add('hidden'); + if (entry) entry.classList.remove('hidden'); + if (loginHint) loginHint.classList.remove('hidden'); submitBtn.disabled = true; submitBtn.classList.add('opacity-50', 'cursor-not-allowed'); } @@ -65,20 +86,20 @@ function switchMode(mode) { const keyInputGroup = document.getElementById('keyInputGroup'); const premiumToggle = document.getElementById('premiumToggle'); - if(mode === 'trial') { + if (mode === 'trial') { trialBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); trialBtn.classList.remove('border-slate-200', 'text-slate-400'); keyBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); keyBtn.classList.add('border-slate-200', 'text-slate-400'); keyInputGroup.classList.add('hidden'); - if(premiumToggle) premiumToggle.classList.remove('hidden'); + if (premiumToggle) premiumToggle.classList.remove('hidden'); } else { keyBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); keyBtn.classList.remove('border-slate-200', 'text-slate-400'); trialBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); trialBtn.classList.add('border-slate-200', 'text-slate-400'); keyInputGroup.classList.remove('hidden'); - if(premiumToggle) premiumToggle.classList.add('hidden'); + if (premiumToggle) premiumToggle.classList.add('hidden'); } updateCostPreview(); // 切换模式时同步计费预览 } @@ -105,7 +126,7 @@ async function downloadImage(url) { async function loadHistory(isLoadMore = false) { if (isHistoryLoading || (!hasMoreHistory && isLoadMore)) return; - + isHistoryLoading = true; if (!isLoadMore) { currentHistoryPage = 1; @@ -118,9 +139,9 @@ async function loadHistory(isLoadMore = false) { try { const r = await fetch(`/api/history?page=${currentHistoryPage}&per_page=10`); const d = await r.json(); - + const list = document.getElementById('historyList'); - + if (d.history && d.history.length > 0) { const html = d.history.map(item => `
@@ -143,7 +164,7 @@ async function loadHistory(isLoadMore = false) { } else { list.innerHTML = html; } - + hasMoreHistory = d.has_next; currentHistoryPage++; } else if (!isLoadMore) { @@ -166,14 +187,14 @@ async function loadHistory(isLoadMore = false) { async function init() { checkAuth(); - + // 模式切换监听 const modeTrialBtn = document.getElementById('modeTrialBtn'); const modeKeyBtn = document.getElementById('modeKeyBtn'); const isPremiumCheckbox = document.getElementById('isPremium'); - if(modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial'); - if(modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key'); - if(isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview(); + if (modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial'); + if (modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key'); + if (isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview(); // 历史记录控制 const historyDrawer = document.getElementById('historyDrawer'); @@ -184,16 +205,16 @@ async function init() { // 3D 构图辅助控制 const openVisualizerBtn = document.getElementById('openVisualizerBtn'); const closeVisualizerBtn = document.getElementById('closeVisualizerBtn'); - if(openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal; - if(closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal; + if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal; + if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal; - if(showHistoryBtn) { + if (showHistoryBtn) { showHistoryBtn.onclick = () => { historyDrawer.classList.remove('translate-x-full'); loadHistory(false); }; } - if(closeHistoryBtn) { + if (closeHistoryBtn) { closeHistoryBtn.onclick = () => { historyDrawer.classList.add('translate-x-full'); }; @@ -211,11 +232,11 @@ async function init() { // 全部下载按钮逻辑 const downloadAllBtn = document.getElementById('downloadAllBtn'); - if(downloadAllBtn) { + if (downloadAllBtn) { downloadAllBtn.onclick = async () => { - if(currentGeneratedUrls.length === 0) return; + if (currentGeneratedUrls.length === 0) return; showToast(`正在准备下载 ${currentGeneratedUrls.length} 张作品...`, 'info'); - for(const url of currentGeneratedUrls) { + for (const url of currentGeneratedUrls) { await downloadImage(url); // 稍微延迟一下,防止浏览器拦截 await new Promise(r => setTimeout(r, 300)); @@ -225,16 +246,16 @@ async function init() { // 重新生成按钮逻辑 const regenBtn = document.getElementById('regenBtn'); - if(regenBtn) { + if (regenBtn) { regenBtn.onclick = () => { const submitBtn = document.getElementById('submitBtn'); - if(submitBtn) submitBtn.click(); + if (submitBtn) submitBtn.click(); }; } - + // 检查是否有来自 URL 的错误提示 const urlParams = new URLSearchParams(window.location.search); - if(urlParams.has('error')) { + if (urlParams.has('error')) { showToast(urlParams.get('error'), 'error'); // 清理 URL 参数以防刷新时重复提示 window.history.replaceState({}, document.title, window.location.pathname); @@ -246,13 +267,13 @@ async function init() { fillSelect('modelSelect', d.models); fillSelect('ratioSelect', d.ratios); fillSelect('sizeSelect', d.sizes); - fillSelect('promptTpl', [{label:'✨ 自定义创作', value:'manual'}, ...d.prompts]); + fillSelect('promptTpl', [{ label: '✨ 自定义创作', value: 'manual' }, ...d.prompts]); updateCostPreview(); // 初始化时显示默认模型的积分 - } catch(e) { console.error(e); } + } catch (e) { console.error(e); } // 初始化拖拽排序 const prev = document.getElementById('imagePreview'); - if(prev) { + if (prev) { new Sortable(prev, { animation: 150, ghostClass: 'opacity-50', @@ -271,9 +292,9 @@ async function init() { function fillSelect(id, list) { const el = document.getElementById(id); - if(!el) return; + if (!el) return; // 如果是模型选择,增加积分显示 - if(id === 'modelSelect') { + if (id === 'modelSelect') { el.innerHTML = list.map(i => ``).join(''); } else { el.innerHTML = list.map(i => ``).join(''); @@ -286,10 +307,10 @@ function updateCostPreview() { const costPreview = document.getElementById('costPreview'); const isPremium = document.getElementById('isPremium')?.checked || false; const selectedOption = modelSelect.options[modelSelect.selectedIndex]; - + if (currentMode === 'trial' && selectedOption) { let cost = parseInt(selectedOption.getAttribute('data-cost') || 0); - if(isPremium) cost *= 2; // 优质模式 2 倍积分 + if (isPremium) cost *= 2; // 优质模式 2 倍积分 costPreview.innerText = `本次生成将消耗 ${cost} 积分`; costPreview.classList.remove('hidden'); } else { @@ -300,9 +321,9 @@ function updateCostPreview() { // 渲染参考图预览 function renderImagePreviews() { const prev = document.getElementById('imagePreview'); - if(!prev) return; + if (!prev) return; prev.innerHTML = ''; - + uploadedFiles.forEach((file, index) => { // 同步创建容器,确保编号顺序永远正确 const d = document.createElement('div'); @@ -318,7 +339,7 @@ function renderImagePreviews() { `; prev.appendChild(d); - + // 异步加载图片内容 const reader = new FileReader(); reader.onload = (ev) => { @@ -415,7 +436,7 @@ document.getElementById('modelSelect').onchange = (e) => { document.getElementById('promptTpl').onchange = (e) => { const area = document.getElementById('manualPrompt'); - if(e.target.value !== 'manual') { + if (e.target.value !== 'manual') { area.value = e.target.value; area.classList.add('hidden'); } else { @@ -433,20 +454,20 @@ document.getElementById('submitBtn').onclick = async () => { // 检查登录状态并获取积分 const authCheck = await fetch('/api/auth/me'); const authData = await authCheck.json(); - if(!authData.logged_in) { + if (!authData.logged_in) { showToast('请先登录后再生成作品', 'warning'); return; } // 根据模式验证 - if(currentMode === 'key') { - if(!apiKey) return showToast('请输入您的 API 密钥', 'warning'); + if (currentMode === 'key') { + if (!apiKey) return showToast('请输入您的 API 密钥', 'warning'); } else { - if(authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning'); + if (authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning'); } - + // 允许文生图(不强制要求图片),但至少得有提示词或图片 - if(!prompt && uploadedFiles.length === 0) { + if (!prompt && uploadedFiles.length === 0) { return showToast('请至少输入提示词或上传参考图', 'warning'); } @@ -454,7 +475,7 @@ document.getElementById('submitBtn').onclick = async () => { btn.disabled = true; const btnText = btn.querySelector('span'); btnText.innerText = uploadedFiles.length > 0 ? "正在同步参考图..." : "正在开启 AI 引擎..."; - + document.getElementById('statusInfo').classList.remove('hidden'); document.getElementById('placeholder').classList.add('hidden'); document.getElementById('finalWrapper').classList.remove('hidden'); @@ -464,7 +485,7 @@ document.getElementById('submitBtn').onclick = async () => { try { let image_data = []; - + // 1. 将图片转换为 Base64 if (uploadedFiles.length > 0) { btnText.innerText = "正在准备图片数据..."; @@ -504,7 +525,7 @@ document.getElementById('submitBtn').onclick = async () => { }) }); const res = await r.json(); - if(res.error) throw new Error(res.error); + if (res.error) throw new Error(res.error); // 如果直接返回了 data (比如聊天模型),直接显示 if (res.data) { @@ -530,7 +551,7 @@ document.getElementById('submitBtn').onclick = async () => { displayResult(slot, { url: imgUrl }); finishedCount++; btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`; - if(currentMode === 'trial') checkAuth(); + if (currentMode === 'trial') checkAuth(); return; // 任务正常结束 } else if (statusRes.status === 'error') { throw new Error(statusRes.message || "生成失败"); @@ -543,7 +564,7 @@ document.getElementById('submitBtn').onclick = async () => { } catch (e) { slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6'; - if(e.message.includes('401') || e.message.includes('请先登录')) { + if (e.message.includes('401') || e.message.includes('请先登录')) { slot.innerHTML = `
登录已过期,请重新登录
`; } else { slot.innerHTML = `
生成异常: ${e.message}
`; @@ -591,7 +612,7 @@ init(); // 修改密码弹窗控制 function openPwdModal() { const modal = document.getElementById('pwdModal'); - if(!modal) return; + if (!modal) return; modal.classList.remove('hidden'); setTimeout(() => { modal.classList.add('opacity-100'); @@ -601,7 +622,7 @@ function openPwdModal() { function closePwdModal() { const modal = document.getElementById('pwdModal'); - if(!modal) return; + if (!modal) return; modal.classList.remove('opacity-100'); modal.querySelector('div').classList.add('scale-95'); setTimeout(() => { @@ -611,7 +632,7 @@ function closePwdModal() { } document.addEventListener('click', (e) => { - if(e.target.closest('#openPwdModalBtn')) { + if (e.target.closest('#openPwdModalBtn')) { openPwdModal(); } }); @@ -620,21 +641,21 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => { e.preventDefault(); const old_password = document.getElementById('oldPwd').value; const new_password = document.getElementById('newPwd').value; - + try { const r = await fetch('/api/auth/change_password', { method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({old_password, new_password}) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ old_password, new_password }) }); const d = await r.json(); - if(r.ok) { + if (r.ok) { showToast('密码修改成功,请记牢新密码', 'success'); closePwdModal(); } else { showToast(d.error || '修改失败', 'error'); } - } catch(err) { + } catch (err) { showToast('网络连接失败', 'error'); } }); @@ -642,18 +663,18 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => { // 拍摄角度设置器弹窗控制 function openVisualizerModal() { const modal = document.getElementById('visualizerModal'); - if(!modal) return; - + if (!modal) return; + // 检查是否已上传参考图 if (uploadedFiles.length === 0) { return showToast('请先上传一张参考图作为基准', 'warning'); } - + modal.classList.remove('hidden'); setTimeout(() => { modal.classList.add('opacity-100'); modal.querySelector('div').classList.remove('scale-95'); - + // 将主页面的图片同步到设置器 iframe const reader = new FileReader(); reader.onload = (e) => { @@ -666,7 +687,7 @@ function openVisualizerModal() { function closeVisualizerModal() { const modal = document.getElementById('visualizerModal'); - if(!modal) return; + if (!modal) return; modal.classList.remove('opacity-100'); modal.querySelector('div').classList.add('scale-95'); setTimeout(() => { @@ -678,12 +699,12 @@ function closeVisualizerModal() { window.addEventListener('message', (e) => { if (e.data.type === 'apply_prompt') { isSetterActive = true; - + const area = document.getElementById('manualPrompt'); const promptTpl = document.getElementById('promptTpl'); const modelSelect = document.getElementById('modelSelect'); const sizeGroup = document.getElementById('sizeGroup'); - + // 1. 强制切换并锁定版本 2.0 (nano-banana-2) if (modelSelect) { modelSelect.value = 'nano-banana-2'; @@ -691,26 +712,26 @@ window.addEventListener('message', (e) => { sizeGroup.classList.remove('hidden'); updateCostPreview(); } - + // 2. 隐藏模板选择器 if (promptTpl) { promptTpl.classList.add('hidden'); promptTpl.value = 'manual'; } - + // 3. 强制图片数量为 1 if (uploadedFiles.length > 1) { uploadedFiles = uploadedFiles.slice(0, 1); renderImagePreviews(); } - + // 4. 替换提示词 if (area) { area.value = e.data.prompt; area.classList.remove('hidden'); showToast('已同步拍摄角度并切换至 2.0 引擎', 'success'); } - + closeVisualizerModal(); } }); diff --git a/static/js/video.js b/static/js/video.js new file mode 100644 index 0000000..6b42487 --- /dev/null +++ b/static/js/video.js @@ -0,0 +1,336 @@ +document.addEventListener('DOMContentLoaded', () => { + lucide.createIcons(); + + const submitBtn = document.getElementById('submitBtn'); + const pointsDisplay = document.getElementById('pointsDisplay'); + const headerPoints = document.getElementById('headerPoints'); + const resultVideo = document.getElementById('resultVideo'); + const finalWrapper = document.getElementById('finalWrapper'); + const placeholder = document.getElementById('placeholder'); + const statusInfo = document.getElementById('statusInfo'); + const promptInput = document.getElementById('promptInput'); + const fileInput = document.getElementById('fileInput'); + const imagePreview = document.getElementById('imagePreview'); + const modelSelect = document.getElementById('modelSelect'); + const ratioSelect = document.getElementById('ratioSelect'); + const promptTemplates = document.getElementById('promptTemplates'); + const enhancePrompt = document.getElementById('enhancePrompt'); + + // 历史记录相关 + const historyList = document.getElementById('historyList'); + const historyCount = document.getElementById('historyCount'); + const historyEmpty = document.getElementById('historyEmpty'); + const loadMoreBtn = document.getElementById('loadMoreBtn'); + + let uploadedImageUrl = null; + let historyPage = 1; + let isLoadingHistory = false; + + // 初始化配置 + async function initConfig() { + try { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error('API 响应失败'); + const d = await r.json(); + + if (d.video_models && d.video_models.length > 0) { + modelSelect.innerHTML = d.video_models.map(m => + `` + ).join(''); + } + + if (d.video_prompts && d.video_prompts.length > 0) { + promptTemplates.innerHTML = d.video_prompts.map(p => + `` + ).join(''); + } + } catch (e) { + console.error('加载系统配置失败:', e); + } + } + + // 载入历史记录 + async function loadHistory(page = 1, append = false) { + if (isLoadingHistory) return; + isLoadingHistory = true; + try { + const r = await fetch(`/api/history?page=${page}&per_page=10`); + const d = await r.json(); + + // 过滤出有视频的记录 + const videoRecords = d.history.filter(item => { + const urls = item.urls || []; + return urls.some(u => u.type === 'video' || (typeof u === 'string' && u.endsWith('.mp4'))); + }); + + if (videoRecords.length > 0) { + const html = videoRecords.map(item => { + const videoObj = item.urls.find(u => u.type === 'video') || { url: item.urls[0] }; + const videoUrl = typeof videoObj === 'string' ? videoObj : videoObj.url; + + return ` +
+
+
+ +
+ +
+
+

${item.prompt || '无描述'}

+
+ ${item.created_at} +
+ +
+
+
+
+ `; + }).join(''); + + if (append) { + historyList.insertAdjacentHTML('beforeend', html); + } else { + historyList.innerHTML = html; + } + + historyEmpty.classList.add('hidden'); + historyCount.innerText = videoRecords.length + (append ? parseInt(historyCount.innerText) : 0); + + if (d.history.length >= 10) { + loadMoreBtn.classList.remove('hidden'); + } else { + loadMoreBtn.classList.add('hidden'); + } + } else if (!append) { + historyEmpty.classList.remove('hidden'); + historyList.innerHTML = ''; + } + + lucide.createIcons(); + } catch (e) { + console.error('加载历史失败:', e); + } finally { + isLoadingHistory = false; + } + } + + window.playHistoryVideo = (url) => { + showVideo(url); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + window.downloadUrl = (url) => { + // 使用后端代理强制下载,绕过跨域限制 + showToast('开始下载...', 'info'); + const filename = `vision-video-${Date.now()}.mp4`; + const proxyUrl = `/api/download_proxy?url=${encodeURIComponent(url)}&filename=${filename}`; + + // 创建隐藏的 iframe 触发下载,相比 a 标签兼容性更好 + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = proxyUrl; + document.body.appendChild(iframe); + + // 1分钟后清理 iframe + setTimeout(() => document.body.removeChild(iframe), 60000); + }; + + loadMoreBtn.onclick = () => { + historyPage++; + loadHistory(historyPage, true); + }; + + initConfig(); + loadHistory(); + + window.applyTemplate = (text) => { + promptInput.value = text; + showToast('已应用提示词模板', 'success'); + }; + + // 上传图片逻辑 + fileInput.onchange = async (e) => { + const files = e.target.files; + if (files.length === 0) return; + + const formData = new FormData(); + formData.append('images', files[0]); + + try { + submitBtn.disabled = true; + const r = await fetch('/api/upload', { method: 'POST', body: formData }); + const d = await r.json(); + if (d.urls && d.urls.length > 0) { + uploadedImageUrl = d.urls[0]; + imagePreview.innerHTML = ` +
+ + +
+ `; + lucide.createIcons(); + } + } catch (err) { + showToast('图片上传失败', 'error'); + } finally { + submitBtn.disabled = false; + } + }; + + window.removeImage = () => { + uploadedImageUrl = null; + imagePreview.innerHTML = ''; + fileInput.value = ''; + }; + + // 提交生成任务 + submitBtn.onclick = async () => { + const prompt = promptInput.value.trim(); + if (!prompt) return showToast('请输入视频描述', 'warning'); + + const payload = { + prompt, + model: modelSelect.value, + enhance_prompt: enhancePrompt ? enhancePrompt.checked : false, + aspect_ratio: ratioSelect ? ratioSelect.value : '9:16', + images: uploadedImageUrl ? [uploadedImageUrl] : [] + }; + + try { + setLoading(true); + const r = await fetch('/api/video/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const d = await r.json(); + + if (d.error) { + showToast(d.error, 'error'); + setLoading(false); + } else if (d.task_id) { + showToast(d.message || '任务已提交', 'info'); + pollTaskStatus(d.task_id); + } else { + showToast('未知异常,请重试', 'error'); + setLoading(false); + } + } catch (err) { + console.error('提交生成异常:', err); + showToast('任务提交失败', 'error'); + setLoading(false); + } + }; + + async function pollTaskStatus(taskId) { + let attempts = 0; + const maxAttempts = 180; // 提升到 15 分钟 + + const check = async () => { + try { + const r = await fetch(`/api/task_status/${taskId}?t=${Date.now()}`); + if (!r.ok) { + setTimeout(check, 5000); + return; + } + const d = await r.json(); + + if (d.status === 'complete') { + setLoading(false); + showVideo(d.video_url); + refreshUserPoints(); + // 刷新历史列表 + loadHistory(1, false); + } else if (d.status === 'error') { + showToast(d.message || '生成失败', 'error'); + setLoading(false); + refreshUserPoints(); + } else { + attempts++; + if (attempts >= maxAttempts) { + showToast('生成超时,请稍后在历史记录中查看', 'warning'); + setLoading(false); + return; + } + setTimeout(check, 5000); + } + } catch (e) { + setTimeout(check, 5000); + } + }; + check(); + } + + function showVideo(url) { + if (!url) return; + try { + placeholder.classList.add('hidden'); + finalWrapper.classList.remove('hidden'); + resultVideo.src = url; + resultVideo.load(); + const playPromise = resultVideo.play(); + if (playPromise !== undefined) { + playPromise.catch(e => console.warn('自动播放被拦截')); + } + } catch (err) { + console.error('展示视频失败:', err); + } + } + + const closePreviewBtn = document.getElementById('closePreviewBtn'); + if (closePreviewBtn) { + closePreviewBtn.onclick = () => { + finalWrapper.classList.add('hidden'); + placeholder.classList.remove('hidden'); + resultVideo.pause(); + resultVideo.src = ""; + }; + } + + function setLoading(isLoading) { + submitBtn.disabled = isLoading; + if (isLoading) { + statusInfo.classList.remove('hidden'); + submitBtn.innerHTML = '导演创作中...'; + // 如果正在生成,确保回到预览背景状态(如果当前正在播放旧视频) + if (!finalWrapper.classList.contains('hidden')) { + finalWrapper.classList.add('hidden'); + placeholder.classList.remove('hidden'); + } + } else { + statusInfo.classList.add('hidden'); + submitBtn.innerHTML = '开始生成视频'; + } + if (window.lucide) lucide.createIcons(); + } + + async function refreshUserPoints() { + try { + const r = await fetch('/api/auth/me'); + const d = await r.json(); + if (d.points !== undefined) { + if (pointsDisplay) pointsDisplay.innerText = d.points; + if (headerPoints) headerPoints.innerText = d.points; + } + } catch (e) { } + } + + document.getElementById('downloadBtn').onclick = () => { + const url = resultVideo.src; + if (!url) return; + downloadUrl(url); + }; + + if (regenBtn) { + regenBtn.onclick = () => { + submitBtn.click(); + }; + } +}); + diff --git a/sync_videos_manual.py b/sync_videos_manual.py new file mode 100644 index 0000000..9fa4d9a --- /dev/null +++ b/sync_videos_manual.py @@ -0,0 +1,83 @@ +import json +import io +import requests +import uuid +import time +from urllib.parse import quote +from app import create_app +from extensions import db, s3_client +from config import Config +from models import GenerationRecord + +app = create_app() + +def sync_old_videos(): + with app.app_context(): + print("🔍 开始扫描未同步的视频记录...") + + # 获取所有包含 'video' 字样的记录 (简单过滤) + records = GenerationRecord.query.filter(GenerationRecord.image_urls.like('%video%')).all() + + count = 0 + success_count = 0 + + for r in records: + try: + data = json.loads(r.image_urls) + updated = False + new_data = [] + + for item in data: + # 检查是否是视频且 URL 不是 MinIO 的地址 + if isinstance(item, dict) and item.get('type') == 'video': + url = item.get('url') + if url and Config.MINIO['public_url'] not in url: + print(f"⏳ 正在同步记录 {r.id}: {url[:50]}...") + + # 尝试下载并转存 + try: + with requests.get(url, stream=True, timeout=60) as req: + if req.status_code == 200: + content_type = req.headers.get('content-type', 'video/mp4') + ext = ".mp4" + + base_filename = f"video-{uuid.uuid4().hex}" + full_filename = f"{base_filename}{ext}" + + video_io = io.BytesIO() + for chunk in req.iter_content(chunk_size=8192): + video_io.write(chunk) + video_io.seek(0) + + # 上传至 MinIO + s3_client.upload_fileobj( + video_io, + Config.MINIO["bucket"], + full_filename, + ExtraArgs={"ContentType": content_type} + ) + + final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}" + item['url'] = final_url + updated = True + print(f"✅ 同步成功: {final_url}") + else: + print(f"❌ 下载失败 (Status {req.status_code}),可能链接已过期") + except Exception as e: + print(f"❌ 同步异常: {e}") + + new_data.append(item) + + if updated: + r.image_urls = json.dumps(new_data) + db.session.commit() + success_count += 1 + count += 1 + + except Exception as e: + print(f"处理记录 {r.id} 出错: {e}") + + print(f"\n🎉 扫描完成! 成功同步了 {success_count} 个视频。") + +if __name__ == "__main__": + sync_old_videos() diff --git a/templates/base.html b/templates/base.html index f03c2cc..28ae2c9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,43 +1,89 @@ + {% block title %}AI 视界{% endblock %} - + + {% block head %}{% endblock %} +
- +
- -