4@Ux;YHSB)klEm>+`Znc6>%0}u3(9EHF{fE#leqjV#M
z9W71yC5+y{0nU8mwXZw72C4gQq6rgrVdiY(CFZ=tGnB(&)=4yRq_hhH1F~DC>%?yi
zJ$S(`5jlEi1^Q3m|0HB~Z5C1Soe-aQLXiGWh>@M6$uCACudmPZ_U3)R+F{+2Xi^1iBwFBz
z8VLm>P#p$aKQi5Gh%PIykEfEJl<1Fp)|G4F@Fj^cqh_QlqnHJgq6;E3-+IJ3YGL9C
z)o65quo&-i@HL=@DUM4o6>TA*eF|+WC5AC+i?S#R0D*a&8Nx+s#;e$wS_5t$lmiAm
zqJbMp{Bq`^pZ{8$qXHpIwI=(_?cxw@JrQMiv7tGQbJBu8FM-p05PdoOa@WyrNvV9J
z3MU`yun;Bn4fxm>5lN6TG?07VMsG|vv
zD@!G>6RbUv2
zZ>Q2^Db^h9k;A>sr8{W^E{bNf1N~z0zpFp+cNX339F2cdeTLNdfy=5Gni}HMf}8GEsfN5bG6H?MY@|V*ULA&z?`6V!U_v+N=$xY6a9IJeo#pTQ)oWT8!iB
zrfYD(Q{UZE^%6c4?MSIsVcS)!pVG~Ev466z$I)Ho$L*FI73#o%EaJ~Z`BrTK*4@0s
zf88l;9K_VL%d9FUTJfPacnp7q*Hyxp=+ImG%`VjeQGR{9eUFg?Nl#3va^pef7an6F)0hyWw-Ytb
zYgGt(o^(`!GWo>zT%I-$Soab|2=g~B2g`KFKzX%%R4HR`V!Npm6lf)}PPO{z1V2iR
z{d6jYo4ZCL&GP1Ab8v?#0zegipMp;a{VP$_vvB&4;D>ttzQstyb`UL6A|EU0CSYIo
z;BBC;v;Zfdt#Kl5pv!#$d?0rxcLbm|&O=C`jdU1LAQaM1M4(|miojs7XYfKf&L4h=Fk@ip2|h44{y-!}ihNFH!c!1(o4`BkcSAbYB5Ix<
z<}ZeQB5h>Cs5ZIcPrPaf5n$3QZbtO&0J&Nh#(~<=Un^GVc4CwgpuBZ2@;Tp1cFJwO
z2`ob`(v!t&%0k=!ymWvyR`4oh>T6L=Lpg8aG%Zi!nG7Iid80?^UoX3cS4p$#$&iyg
zN@D*nW1^(<++o}E`ox(i5U;I#E^NY0u2but`dFS5>Rk78R=(R-UUa=~7qPpXr9tGL
z@^c?ufnAX#mIrrfcU}p$h7+<&`1@nV2hrt=1=sz@J*G-GT_EL*N=CoT{*>m;-jE)#
z5G3J5K~FTDGI+oZT8smm?v&oNiWOu}w5&5@Mi6aTFJ&Y{`$W3ZX$>jZ=ngGAWu)$p
zL!h+uB{Cov52+2Fyye?^TMM`X4(2m+18O9TeO60@JE>0yRF
zfCPYo!r~iJpAT?e4l3wO>J~y0^le;T@N1ejIhhwJVXgc;h$9LTflP@F&w+rrhwE@G!p#STxWK
z9Ao7H30X^08379t839}3kx%oqgqT^3#g~)PU!Cz64vo*}4~;wCUs3aib-H9J!t)_m
z{>94h&i+^`5IWJNwwc-MR&}+0vgj8r=f+nR)58Fry!ifBF^X`v$oGrb88jOR^t^m&
zf<9T7d>dPY;ja*o8W7(biFjlUiskM$c0O0^A&pV?sq|%G^`8iUzQqiG(;Og+Puimc
z4N3Qy7kc_?b~!8r4Y73S!2^?Cw+}Y$Gog=(lcYV}Ce7wo{KT!yS&%%m-8Bx?L@+j~*!WsQItI+n%7CLR!G<1;V;p|EMdl_t31C9oLIHU6#ZPg<0
z5>j)2tHg?|_u)tZ6azI6RNrOxW`a^nsW#M!lX>PuWUj=JZTRgK*6(BF)U~4Yf0#IM
zn!h(~N_Pl*(r5vEeIeWX
z_;=%iQ@>Mfe9AosM5f=(DExoktUr<+O|le@rHoA&d(sdY0;_^j+xs;1<8YQ%<+&Sa
zXn)Yeu%ihd<+gbrQ_p+HrM2!xp3leU#q9iMaHeG;@POQ+MKc?d4cFyvFgO19O;w2>27pF^7i70!Bpyj713yV+Ui<*
zarVSEtLpg_1lk%Qe;d%${M){#hd#aN9w?{1@`J5`+lV&79u@4I*z1%~eH)hH+2yBBIQHNtq32>yr~NXT
zd|KnlTC6gvAjjaAbUsV;S&9XzZZ=ebyFHOjgt_jEf~W6br_1JUDhVbzvYQ=WBD(9{
z(^gbzLAQpUT(N-n;ZR@uEV^-#Y2~Hzy9sGO-W~wY0MZ
zTh&*mQ5-f8ke4@>f|`%BFj2w1X9*Yr!9B
zwgTvhI{jR4DStv%G==7`yrhAY!c@OJzx`q7{w4+aF}_-@Gl+AjWK=Q^1<
zJ6qVA{b9z|tG=7DY^Yx@q~C-GF1j6{raYjyiHKmihv@=xyp#6g)kQJX;&$LWJH(UF
ziUJF`SC8WNoE|4$KP3}%!0;{ku}KeGB&0{=0)HxjA#a>LJaXWCA;ZR$G8Sx#V~D)d
z&qYwM$DCGRWx=q_FAqU7
zHBN;*JCs1Q7jYD*#*;oIV@t3?LhM4qb(>8R12Xc$VUkK&Q-J{jmb3{$4UrsyVR#p9
zifEo9<#=f|3JoZH-2GeTj(!ESN<|H3_CApnels`=f~-G`ddSx|OlwqHzcqGKHVN
zRhPxwqf%GU+!|jTPi+azK6&@LP&|axgF-EUeJV+zr#4KU6nuL3bnRJ{LCK+}Qzf-s
z-}o!N$2v~>rS}w6QoEP$YtoOUyxBN&A;zhR7hDp@8O(m%aDJ~5@_`gsOYsej
zxrFvlaT@IkJ=YcB0dJ4gIY3&D%`MtdBhJva(!M4_NHufsWs*Bp@Xe$wPWYJ1ekOJm
zxyqiKVTVj1k{d}T2Wz+wI3MRqmKXQA`%Y4@Pt@X1#AAQaqw_7W7o>s~VL5L2y4lPi
zjFRNEA9b|?+$RA%K9IF@b>4
z{wer}IODG^_nC%_9f~++huX1^`WapgTLTI)?rvp_5C{?*VU*E@yW8by6f3jrWx2mm
zfuxGXyl||?NseN;a=znHrG(V{48gRU>B0$?=NeSl$FD=ft`rd
zsES&(v<$&2W1EHwIfCts2AD~=V=1&4#f!4p^R(tEEv-#Lb`4_k;N~{C&x1W-Gk9Ub
zoT%o)k@ma`kSc+8Bt*T@ou+emI0Wmk{LfP|K3oc8L7+lI6QmmCzx9;2Q)`(wBIY^<
zl#w-7zQHsL5oRja%Vfey<<)DpFPJAjvHFgl9J<*pxw&~e4=9UC366SR_1Z`F{wz6VW3PR}V_3DLjDYWh%E)oXV
zm2G?8Y8GucU1BFF#>p*ADJa|gI0kR|8iB=o+0*Em_^R6IORvxs6sI}Tm$Ek_RR_nb
z??uq@5mhv9*Dqb}3NzBT0Z(K&CVnf1v{;#KicW07?Z%RHL6^$O57U7uas2WLRgi?H
zzQgMZa5{ZcE8dyT`^D?-kV31K#Lf4*FUj-v6~l-BmF3~&;6j)8aU?~z9~S5BIXdh0
z?j-pO;g6WTsl~~k?mtiW*t6aqdr=VD`lqLY5IzNo+M{GaG-^QP9JMuNZV~zLh6;v-
z&`yt__&Mg6x&OE~KF+qAKoZ5*#%aZB8#M@e@Le=Tzzu$;%8~#~yOWILZXp@fjW@Bw
zvM10|j|XUEK1AMpi)@XY3Y~O?+>`b8tfi8c1t={`RPXFFiZ+WtPgKLrv9onAp{#og
zrMKb7BANL|n%HN7*@(1JVa4S178Xna4`URgdx|#Q4~$hW~&BXAuSYV}|~Szivd}l`Kf0q+R*8FQCztc0H7w@r-;r_fkaE_?LXJuH<_?8j-vIcBR)0Qy}Z*;xaD96j!S-R8G
zAKIw*K{e4Vc+wmBJ>e6Io+iGEb5mMHi)wiblcw(Bt&wkkh&9~
zIk19oREiOtlh(1L$A@cbbfR+ufxWT816H(i5sG0G(VUjnzArX?H}0C9AyR(>U1DYF
zJ#L5%4gk*OP^CjhcE!EBSd5^`*k&q(o##sL*^I41rB#KIb!vTKL6F_{5Hg{J6Ek0d
z8?;9c$XZ6dCY{3vp9ZsK5Q|G|>44TN)E=TkE-r2voBaimSFZiX5rFoitS}KAS1JwKD
zmqLtO;dJ;tS5~a9vkrEr4ph=9MW}b>86!YoMT#bBAaM!}zJ4cT
z$>-^6kd1WU)j^?}FgDm+C%Vg4LGU<>gExW7EcSb3k-4KKW2VF~q|xfux@tdKRDNh%
zXhH6)t4k*(&Zs(1jO^K0r)2iJj+Tms8QeCuGo`;fW}Z>|n?eNP?NLB!u0}D5SKi_4+6LcjU_1K(5NE(p)LY%#V{_`xpj@ETU
z^WCgHMgjiIx_$R#x!9Q4I{)#Hp`&GoBcAj{Pya2T%S}UNex9A^I=d1J^qU}%oGU&C
zZCo5wymjs}vJt<;x7$8fz-ZS5DYL@B6>$d;miWYqgEjTWONv7LwbIX?^kYlKMOV_e
z_5AN1u}h7|$0b|`+?&NtB^!_9!S%;;lAPwMFHhRQS%QJZj_rrZA8-4cdmw%6i|Tun
znSUIURUR*nIt7OBnQpV2z1{F{Sd1sV036G;5sI=yJe(TlDJRg9da4+?nP+L}60LM^
zq{|iIUJ5%AGpU~Hv3v}26lo^wUb0z)5yh@5#kMd;$I06!uaBv9YhzH=xYbiPDHpeO
zV86)U^z38ttEU%#?hf1=U`g1x>>2SY(1fNJTK;r|zB7rI6!2uNm~*5XxI|LhCN$i6
zj?(Q{$+6r1BEfCp_Ylb}*bL)xc`{xgX>`E+L<`U7P^?vu*y!CSS-#R)9jA
zD}k>Hv=UsWT4~D<@WCPi+2_0J-dWd^Tans*vU>u^ebN+d`*LzN;0tg%TiEuR6k~!s(@&h27UM=}
z_5yd4^q(UgE4d;I>(}63Q_li*E7w#@T?*&TGpQC(L~Ep;s#rjiUGYCk&xh3>pr3r%
z8duw1)l8ACuj1(k%eO~&UTo8A8T!e-#gbv;?&ns)q9z|pHp=A!(89ieyuv%^4WGYO
zwsP$+ok06!5R_3yzxXgO=~A%L9^|n&k=Sy;z|CHJ;`b<7Jky@SgLs(>vRD}`)6}3`
z9PAWWvj`Vy+P@R1+WusCU+w7q6mSF1alFB#)>Ox0^UC*GMH{z`so3WQNuw>76(=(i
zpZ{>J8E4&lUia-_Ve)>(3TO2mzgu4WN
z+zSiikxnexH>8dlRy81b{~|iJY>M{1sS6!GH8c43`=FU%iTLgv@(_9ftrsSOmehNq
z-qeq94GY%Ol~38{J51YEpDii`mLE-fse4-EeikQT^nA(6xEID8eC5MaMo)hF;!?0t
zmN3$JcEA2|^>%#WuyEp;=F{<%={Burs#iN33!7NAn5^2Y`I>lPpx8d)g%VD~6}or9S>ZJd9~k0P*fVLOtQP3DQX?x4e#kA02hyjbxl36I%0^^axqT9jgkl
z)w$sDat!(0&Em=gm>uBd17l+%xEEm!JsC1Bo9S=D*y*5d6=ys!kj}F4sYY7&Kz#M$
zY`H&^z`n<>he~2$JP+*1;zb5JTfE=&TKn=u>D|X1IBC(#hqE#hBSxyVH0618+H}<;
zQJZ4~`W0vOnMQBaIo1@jclv9vR$0Ndf1|dH`fW@k{X^BxQ$mn7C`7@2dWDL1_Tq
z%?05$r};yHD|&8JDdz;f+SZ4Y2eisf;8aITZmaJLl9pzS&qCcHrB8k~C%xRIqkA0j
z*wVd&2U&k|UH23ek%RT34(7GVXj`Y2rkl(s(j<2&5Jiv4mkG~!7te1y!%ok~E~
z(F7vwK4Z8`A^D1f45$WyxTDk!SqZzK)+Gf-t8vVUWsD$fgYqhaXc0D>VEOvi!4tQP
zJgJp;ha~)7{Fua5!%(ONQK3_b0f!@J_1qanX&K!7)^=WroNYwJQYD3wxuXzAHSDEo
z!G|WYthm+rD{%lg3)x-!jilghC<1q|6;rL2y_Aw{8~=t#a2ur%1H|>Qxu!AUCtu*f
zv*A~7JNP`Dn$@63l14r6*7`>ePa_+vo6iT&p$2%+qou@=F_$hoGum4oWR1hQ|j8P7u4F
zKPZca1x;-1RY1)hjk$%FIp&IaL2
zbN(%C=Mquat|swMwKOq(keXKVL7AJ)cK|y4<3?bplZSWIq
z`zathXUt!>*qB$k$i0}^sJ6%yJtxRUj_$y$11FZfRwXZ+8)NBL8#(vQORDBTa{Wuw
z-M-x$C^dboGpE$3V}6j7HQ;jZ0UQh#3ZE6Mw`DBVB;^~|XL3Dudnj2psjykRS(VRTFa0bFkxbaocY`ARsVeaOKM3A-(=ka6VIz4bT19mw=~2I*sqt
zxabZ0x>tAEXf4cBSz>&gn&qYB1{q-Io$8)x7#hx|+h8Y9quwTWeg^r{u#?9`R$(}-
zTozAA963NT6LK6z)U0eC+3hvwrYDxno_E1-`F5k*qKJvis;W;Qs*b2RmX0uI@_yC_
zf|DS`pjU{Kun!pIm_aQoIoz_#^8$!G_yoSW&HdD~Hj*~j?xzWOA>X7lwIbF2oXJ^5
zgrW4+x|;A(pesoU1hyan(F~-=Jb@RO7vReXRu_2jg|7yqQG_ij!b0o($ij%T;tET_``1T
zs&p|U>M$!yFHjIc2JmiN)^6pHe5){8VgiU66x?8}@%Pw`pACx_g{476
zpA}-uG-2wQb?5}MuVT2Q;&d=bxRvOScimJGB7r^kEFijOz55bsrnEoO|TO-J&Zedws!!KO=kNt%(Dqqa<+k^92Jy9!kBd8i&AgE?OIb|i>KB~zW
zXzo7u&n63%8-?n^@a4DcDylh_S3I*mWF2*k?t)6IBrr@yZdyl;
zwh#6eGh5u8N|$d#Xb}#u2V@4ew_jz~J|2*!KP=o%^imutCI^is99QMFj&O@@)pYGC
z>@cyRDGip`7k?Ii03z3(DlaDd1PDXAV7v16>%bEpT-v|zb{?535Ac3
zE#vrvj3NuV+{_#7elQVTH7Dx)X3>&RprD^C5hfD?1&Ly?yn~cPOJw;yiMqiA?-Og6
z_0nA}d`gYvB9w5C&ai!Kgy_RYsQNZYH(f!P;22H(qpV+1E@YcOg80es26>CMDLdprvuyPo9EBQ`omS=
z&DKPM2M{Nfu~0|oyPiBl?DbHLs7N!sn5ZI~ywSS6
zK`*i8i|l!osPJn$up#G7DiAZK{+{$xI
zby&{WZ8uJ+Tu+KbN7vIoNUV*!@Po0s;2QWF5_&rN3pwXDdxMPSuvj4xY!MKWBVe<{
zNPc}Gu+fg-P!8-JkwYzgLzy}5T&@Y_1-+$JBlcVLO?#7wf>8;tawSpLn$Bcy}
zO=|1D>Ve%AH8;(p5IV6ayKx}EM+&C9g4V%>3S>zHX!SejQjBFUap4C!lQAe*S=cY}
z>6corF5`+4h1`spAR}-=jxbsyb8EKJ#y$$LFW8nXa{`Wu=?Pe!?1>O
z!-sz}JcXd$iO;qTFO}#4bq!z@&eOPJDh5WWyVBnd+f%Qg3D>aq1MK(#;_?jFQKeEo
zK!D@+OH@!s#Xv7_X%^lPe6!rU>I!U=$0ZhpW(}nGW)Q@I4y#SSeEe9uh@(U+ZHA+}
zl|em@R<-v%%#TUu=jTc={A2@X(TG=mbsy0rgJ9hou(>zP=MT7zp(!?)-Zu`c}{1aJzXMgI6!gM)>@{Qy}T}$51%KwHrrO(
zlVzSO<#OL%#h*v?8rA4;GEUqsf@V8BOYezwndUxjhj_XhSh;T8)6cbS^*m0&3RUG%
zGY}&grMpzBW>UZ0w{{$%(c62rcvdRW-=6Q@iQ|S14j*dKUU#IjemD*)_<-WRD`=Q8
zsMh$UV^MeYVeweplkezKP|`%FfhO&_y@dxb1#{tWN6^^uOje*l7xY3K*?*IqJ*`Q2
zp)L+%wN3U=F7Uu_w>AK{XakxE`T{zkgzYBOhE0cSNy(Rf-#b-#rd^SQ;rqk6k}k1b
zku!zbyh;!gwQlQ~hhw+d>+lN*w1w&I
zeuxOLLWi_d^1Gc^N%gPCQXF(GO}pR+IJ!vZVN$`WhhI2e((k1(jO@S@nzX4Gdu!hy
z{&^6y9C8RO`5q9d^?rnp{O{v?TNfKc6UTQ)Q&mgZG6=qoq#&}6&`;s#t>q_Aj7#Ev5F3r|`QP@xszQQYQv-tw`PF!-x
zQkEa9BEd<-G*p^XC}Y2u7=9(sU@~!xHMMxlk0hSc+|n>yHVhZza+oM&NsK8~6w@~|
zGPw)w+HaU<)?Zwz#@iWN2X*t{oc<2NDz=}Ot%0~p^hjvVg3^<&a`kBgxLGkDNx6m3
z$dD2eUUDV%NEoZqBmntDLh)G{seDUGyQub2qeK4M>0IB$9#YMLkSEVGA8QW?+3PT%
zbF;Uc&vAK$Kq>_13LVZ#g1SL4sNJHALy?X2ZWClw5XEzf?+K7lEa9Um
zePTEFhkBP~p6d%X&904Z?h`ojb8(W~c%QTy0Sf|RTI%jaL*4AO$0sEZ#!kUd$nYTU
zyNreey9?F~!-=s=R4jK6})
zJIiE(f+pdYkoN@7i}otcFTcxvmeY9{SGU8F1c&oUs5_``MJFcj+w#q*T`tUd3Y+#Y
zT&z(92ste4wqt6}WaFbk7|5(VXHH@qjWDd~)L#E8`@vb2at$N!oD?&vXppIl5<>43
zDl_?aPF2?U%Nk|PDG2;q7F1=Qj79N4WLBGp6ty;uuSf$$=F4Iiv!aP)^h?|-W8m?T
zr3kF0*?DoM16H-i3+J=tDjtefDhx(jW4YI_V0+g&@S%;^(7NU(%FDRD211>AQw{!(
z{_7dvmOGkd<4$rb?P%`lWMgnm~!$
zX8=%mx)@)Go5B^$xzU(Q;H~hHs}YiwFD9oR|l&
z{N?=POIZZ@gqRY9`YKFF{be4(f7q)~ARr|xHSy5}?lzD6LH*EBZT1ez6w(TLdA)s|
ze4WH6gaYyd2C8Y{^7|y$>YIHd_P#&+fTB9{dEUBriw)Mi(ja7prE!`T)3=-3AS5RQ
z7oryPkz~__CIV+Lsk~1=ZG4hV05ACwP4QR^$mswmVbL|aZcZW8>;`i~>P
z<^@5kivxsuTM*rvT0})g87~~g5v82i-vo15_b=^
zXJ3e_SJ`I-5=VBn41OSX51m{mFZ^?4!V3yIU{Kbucu<`l*=?ERD&ST&Z9@x;eE|Qj
z)&Af8sqd;5NYBy4`uCI#U}9o|*Z1GH-=B(KKG#2G=zd9-$E{0(G9m_EfNv6wd9$y6
zB%ZOh6FJQI{qE&AEXCEHSf3fbeDhmIxNx_+I~0D8w#Pf+tQbB^@92;kTWP1daP$1~sBM`V2PG
zVC|8);Si5#ykQlc&7mK&psBG%OW5ERRuzJ$veni#n@S@1!Y`olSI-P7=|`5m`>&%OA15!ey1GK)i&M`iObvdcVqj8xY&F1#c_?1LKWVGQkef5f&Y+Cf~Q9wg4UtAx65icki|;
z=bW^MRKG4Dsm+6B{w?Ixlm=I@F++9F(YbPT&Avb_7|d;Uh@2EStv$MC!g$TZiWL3&KAY
zayPS3q1}6a#JBgb`d|4GTEg$j_}v|&r~KL8#7XCuEl{7(YTLtr8gwt|9Xw=}h{>g_
zBC3CAVvThOiunvyHh}TSfx6K_;b#maDQS`XDfH~^`DO6hyoSxB#@}~kIUT+hSgK31
zs7`11)cY=OqZTZ$N-|+WXoAhxnyI4$dz=lQA!i=lsUc#T-#*F_T1$GQvd+=a0t2*~D0HbGraHom83qme2h0ZJM2yA$JQw|E?pM`Vj|r=3M}
z$mGRLzWc)+(^AV97kz?K5I*pVqt)@f*|rhCJ+2K!6EmAIJGW71W90wp(~^DM(Ws-d
z;*uf8DkYG=V>41F#U`|R_Z_#i{fhFCVWO|?MfAcG4>!blx*fTJ$q6do=PH^b<}lZj
z4AGDj{KYevtyz!AR`LPCkGv6s;3yY#gC`iHajLKG(}s50&U>T58_bjWw&`s}4`#+ujJLl?m`0qJFf5AE5
zwa8!a|6~mP-){O>ddi>W*G<2@2lM?aS>@La|94V;DYQS!AM?cg53Tk4V1HL|zkq+v
z6xBb0|3lsV-qP>x*uPrp$Ntw4{@%v#@ZU|jzu-f3|APO+u=^eV`zGWsye!SX@c-D7
z{Eq)UR`M_W!F&GC|A?IY9sYa1&|h%kcMAW6|0jFsPw@W>d#3LX?jPWPhfV!+o&O#<
h^;fUa{{;ROMkOx={>yfF_n0FAA-*rCN7i58{tsjp3ts>L
literal 0
HcmV?d00001
diff --git a/跟教务系统同步学生异动信息/api_service/test_jw_cx_api.py b/跟教务系统同步学生异动信息/api_service/test_jw_cx_api.py
new file mode 100755
index 00000000..39e7fb74
--- /dev/null
+++ b/跟教务系统同步学生异动信息/api_service/test_jw_cx_api.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+教务同步只读 API 联调脚本(Python 3.7+,仅标准库)
+
+调用两个业务接口并打印成功结果:
+ - GET /api/v1/students 学生基本信息(第 1 页)
+ - GET /api/v1/student-changes 学生异动(按年)
+
+用法:
+ python test_jw_cx_api.py --prod
+ python test_jw_cx_api.py --base-url http://web.hzdyjsxy.cn:18081/jw-cx-api --year 2026
+
+环境变量:JW_CX_BASE_URL、JW_CX_API_KEY
+"""
+from __future__ import print_function
+
+import argparse
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+
+HEADER_API_KEY = "X-Jw-Cx-Key"
+DEFAULT_BASE_URL = "http://127.0.0.1:5050"
+PROD_BASE_URL = "http://web.hzdyjsxy.cn:18081"
+DEFAULT_API_KEY = "JWCX001"
+
+
+def _get_json(url, api_key, timeout=120):
+ req = urllib.request.Request(url, method="GET")
+ req.add_header(HEADER_API_KEY, api_key)
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ body = resp.read().decode("utf-8")
+ return resp.status, json.loads(body)
+
+
+def _join_url(base, path):
+ return base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def fetch_students(base_url, api_key, page=1, page_size=10):
+ qs = urllib.parse.urlencode({"page": page, "page_size": page_size})
+ url = _join_url(base_url, "/api/v1/students?" + qs)
+ status, data = _get_json(url, api_key)
+ if status != 200 or data.get("code") != 200:
+ raise RuntimeError("学生基本信息接口失败: status=%s body=%s" % (status, data))
+ return data
+
+
+def fetch_student_changes(base_url, api_key, year):
+ qs = urllib.parse.urlencode({"year": year})
+ url = _join_url(base_url, "/api/v1/student-changes?" + qs)
+ status, data = _get_json(url, api_key)
+ if status != 200 or data.get("code") != 200:
+ raise RuntimeError("学生异动接口失败: status=%s body=%s" % (status, data))
+ return data
+
+
+def main():
+ parser = argparse.ArgumentParser(description="教务同步 API:获取学生信息与学生异动")
+ parser.add_argument("--base-url", default=None, help="API 根地址")
+ parser.add_argument("--prod", action="store_true", help="使用 %s" % PROD_BASE_URL)
+ parser.add_argument(
+ "--api-key",
+ default=os.environ.get("JW_CX_API_KEY", DEFAULT_API_KEY),
+ help="请求头 %s" % HEADER_API_KEY,
+ )
+ parser.add_argument(
+ "--year",
+ type=int,
+ default=None,
+ help="异动筛选年份,默认当前自然年",
+ )
+ parser.add_argument(
+ "--page-size",
+ type=int,
+ default=10,
+ help="学生基本信息每页条数,默认 10",
+ )
+ args = parser.parse_args()
+
+ if args.base_url:
+ base_url = args.base_url
+ elif args.prod:
+ base_url = PROD_BASE_URL
+ else:
+ base_url = os.environ.get("JW_CX_BASE_URL", DEFAULT_BASE_URL)
+
+ year = args.year if args.year is not None else time.localtime().tm_year
+
+ print("BASE_URL: %s" % base_url)
+ print("")
+
+ try:
+ students = fetch_students(base_url, args.api_key, page=1, page_size=args.page_size)
+ print("=== 学生基本信息 GET /api/v1/students ===")
+ print(
+ "total=%s page=%s/%s 本页=%s条"
+ % (
+ students.get("total"),
+ students.get("page"),
+ students.get("total_pages"),
+ len(students.get("data") or []),
+ )
+ )
+ if students.get("data"):
+ print("首条:", json.dumps(students["data"][0], ensure_ascii=False, indent=2))
+ print("")
+
+ changes = fetch_student_changes(base_url, args.api_key, year)
+ print("=== 学生异动 GET /api/v1/student-changes?year=%s ===" % year)
+ print("total=%s filter_year=%s" % (changes.get("total"), changes.get("filter_year")))
+ if changes.get("data"):
+ print("首条:", json.dumps(changes["data"][0], ensure_ascii=False, indent=2))
+ elif changes.get("total") == 0:
+ print("(当年无异动记录)")
+
+ except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError, ValueError) as e:
+ print("调用失败: %s" % e, file=sys.stderr)
+ sys.exit(1)
+
+ print("")
+ print("调用成功")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/跟教务系统同步学生异动信息/api_service/学生信息接口和学生异动接口调用说明.md b/跟教务系统同步学生异动信息/api_service/学生信息接口和学生异动接口调用说明.md
new file mode 100644
index 00000000..15fd3b2f
--- /dev/null
+++ b/跟教务系统同步学生异动信息/api_service/学生信息接口和学生异动接口调用说明.md
@@ -0,0 +1,402 @@
+# 教务系统对接 — 学工数据只读 API 调用说明
+
+本文档面向**教务系统**开发/联调人员,说明如何调用学工系统提供的学生基本信息、学生学籍异动两类只读接口。
+
+---
+
+## 1. 概述
+
+| 项目 | 说明 |
+|------|------|
+| 协议 | HTTP GET |
+| 数据格式 | JSON,`Content-Type: application/json` |
+| 字符编码 | UTF-8 |
+| 鉴权方式 | 请求头 `X-Jw-Cx-Key`(由学工方分配) |
+| 接口数量 | 2 个业务接口 + 1 个健康检查 |
+
+| 接口 | 路径 | 用途 |
+|------|------|------|
+| 学生基本信息 | `GET /api/v1/students` | 分页拉取全部在库学生快照 |
+| 学生学籍异动 | `GET /api/v1/student-changes` | 按自然年拉取已办结异动 |
+| 健康检查 | `GET /health` | 探测服务是否存活(无需密钥) |
+
+---
+
+## 2. 访问地址
+
+| 项目 | 值 |
+|------|-----|
+| Base URL | `http://web.hzdyjsxy.cn:18081/jw-cx-api` |
+
+完整示例:
+
+```
+http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/students?page=1
+http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/student-changes?year=2026
+http://web.hzdyjsxy.cn:18081/jw-cx-api/health
+```
+
+> 若域名、端口或路径前缀有调整,以学工方最新通知为准。
+
+---
+
+## 3. 鉴权
+
+除 `/health` 外,所有业务接口必须在 **HTTP Header** 中携带:
+
+```http
+X-Jw-Cx-Key: <学工方分配的密钥>
+```
+
+| 情况 | HTTP 状态 | 响应示例 |
+|------|-----------|----------|
+| 未携带或密钥错误 | 401 | `{"code":401,"msg":"无效的密钥或未提供请求头 X-Jw-Cx-Key"}` |
+| 参数错误 | 400 | `{"code":400,"msg":"..."}` |
+| 成功 | 200 | `{"code":200,"msg":"success",...}` |
+| 服务端异常 | 500 | `{"code":500,"msg":"数据库查询失败",...}` |
+
+---
+
+## 4. 统一响应结构
+
+### 4.1 成功
+
+```json
+{
+ "code": 200,
+ "msg": "success",
+ "data": [ /* 记录数组 */ ],
+ "total": 100
+}
+```
+
+各接口还会在顶层返回分页或筛选元数据(见下文)。
+
+### 4.2 字段值约定
+
+- 日期:`YYYY-MM-DD`(如 `"2026-04-21"`)
+- 日期时间:`YYYY-MM-DD HH:mm:ss`
+- 无数据:JSON `null`(不是空字符串 `""`)
+- 异动类型、学生当前状态等枚举:**中文名称**(非内部代码)
+
+---
+
+## 5. 接口一:学生基本信息
+
+### 5.1 请求
+
+```
+GET /api/v1/students
+```
+
+**Query 参数**
+
+| 参数 | 必填 | 说明 | 默认值 |
+|------|------|------|--------|
+| `page` | 否 | 页码,从 1 开始 | `1` |
+| `page_size` | 否 | 每页条数,范围 1~5000 | `500` |
+
+**示例**
+
+```http
+GET /api/v1/students?page=1&page_size=500
+X-Jw-Cx-Key: JWCX001
+```
+
+### 5.2 响应元数据
+
+| 字段 | 说明 |
+|------|------|
+| `total` | 学生总条数 |
+| `page` | 当前页码 |
+| `page_size` | 本页大小 |
+| `total_pages` | 总页数 |
+
+### 5.3 拉取全量建议流程
+
+学生数量可能达到数万条,**必须分页循环**:
+
+```
+1. 请求 page=1,读取 total、total_pages
+2. 处理 data 数组
+3. page 递增,直到 page >= total_pages
+```
+
+伪代码:
+
+```python
+page = 1
+page_size = 500
+while True:
+ resp = GET(f"/api/v1/students?page={page}&page_size={page_size}")
+ # 入库或处理 resp["data"]
+ if page >= resp["total_pages"]:
+ break
+ page += 1
+```
+
+### 5.4 `data` 每条记录字段
+
+| 字段名 | 必填(教务) | 说明 |
+|--------|--------------|------|
+| 姓名 | 是 | |
+| 学号 | 是 | |
+| 联系电话 | 否 | |
+| 身份证件类型 | 否 | 有身份证号时为「居民身份证」,否则 `null` |
+| 身份证件号 | 否 | |
+| 班级信息 | 是 | 班级名称 |
+| 所在校区 | 是 | 有宿舍分配时取校区名,否则 `null` |
+| 所在年级 | 是 | |
+| 入学年份 | 是 | 整数,如 `2024` |
+| 系部信息 | 是 | 院系名称 |
+| 专业信息 | 是 | 专业名称 |
+| 班主任工号 | 是 | |
+| 班主任姓名 | 是 | |
+| 学生当前状态 | 是 | 教务中文名:在读、休学、复学、退学、毕业、结业、停学、其他 |
+| 学生是否在校 | 是 | 「在校」或「不在校」;退学、毕业为不在校 |
+| 异动类型 | 否 | 固定 `null` |
+| 培养层次 | 否 | 如三年制技师、六年制技师等 |
+| 学制 | 是 | 年数,整数 |
+| 毕业时间 | 是 | **年份**整数;已毕业取异动生效年,否则按入学年+学制推算 |
+| 毕业学年学期 | 是 | 如 `2025-2026-2`;无法推算时为 `null` |
+| 学习经历 | 否 | 固定 `null` |
+| 何年何月-何年何月 | 否 | 固定 `null` |
+| 在何地学习 | 否 | 固定 `null` |
+| 性别 | 是 | 「男」或「女」 |
+| 籍贯地 | 否 | |
+| 在校照片 | 否 | 固定 `null` |
+| 出生日期 | 否 | `YYYY-MM-DD` |
+| 家庭现住址 | 否 | |
+| 成员1姓名 | 否 | 父亲姓名 |
+| 成员2姓名 | 否 | 母亲姓名 |
+| 成员1关系 | 否 | 有父亲姓名时为「父亲」 |
+| 成员2关系 | 否 | 有母亲姓名时为「母亲」 |
+| 成员1联系电话 | 否 | 父亲电话 |
+| 成员2联系电话 | 否 | 母亲电话 |
+
+### 5.5 响应示例(节选)
+
+```json
+{
+ "code": 200,
+ "msg": "success",
+ "data": [
+ {
+ "姓名": "张三",
+ "学号": "2024010101",
+ "联系电话": "13800138000",
+ "身份证件类型": "居民身份证",
+ "身份证件号": "3301************",
+ "班级信息": "24技师1班",
+ "所在校区": "西溪校区",
+ "所在年级": "2024",
+ "入学年份": 2024,
+ "系部信息": "电气工程系",
+ "专业信息": "电气工程",
+ "班主任工号": "T001",
+ "班主任姓名": "李老师",
+ "学生当前状态": "在读",
+ "学生是否在校": "在校",
+ "异动类型": null,
+ "培养层次": "三年制技师",
+ "学制": 3,
+ "毕业时间": 2027,
+ "毕业学年学期": "2026-2027-2",
+ "性别": "男"
+ }
+ ],
+ "total": 12500,
+ "page": 1,
+ "page_size": 500,
+ "total_pages": 25
+}
+```
+
+---
+
+## 6. 接口二:学生学籍异动
+
+### 6.1 请求
+
+```
+GET /api/v1/student-changes
+```
+
+**Query 参数**
+
+| 参数 | 必填 | 说明 | 默认值 |
+|------|------|------|--------|
+| `year` | 否 | 按「异动生效时间」所在**自然年**筛选,范围 2000~2100 | 当前自然年 |
+
+**示例**
+
+```http
+GET /api/v1/student-changes?year=2026
+X-Jw-Cx-Key: JWCX001
+```
+
+### 6.2 数据范围说明
+
+- 仅返回 **已办结** 记录(学籍异动 `process_status=1`,换班级 `status=1`)
+- 按 **`异动生效时间` 的年份** 过滤(非提交时间)
+- **不分页**,一次返回该年全部记录(数据量通常远小于学生全量)
+- **不包含毕业(BI_YE)** 记录
+- **去重**:同一学号、同一登记日(`提交时间`)、同一异动类型仅保留一条;视图与 API 双层去重
+
+### 6.3 响应元数据
+
+| 字段 | 说明 |
+|------|------|
+| `total` | 本次返回条数(等于 `data` 长度) |
+| `filter_year` | 实际筛选年份 |
+| `filter_by_column` | 固定为 `异动生效时间` |
+
+### 6.4 `data` 每条记录字段
+
+| 字段名 | 说明 |
+|--------|------|
+| 提交时间 | `YYYY-MM-DD` |
+| 异动生效学期 | 固定 `null`(本系统无学年学期) |
+| 异动类型 | 教务中文名:**退学、休学、复学、转班级** |
+| 申请人学号 | |
+| 姓名 | |
+| 异动前所在年级 / 院系 / 专业 / 班级 / 学制 | |
+| 异动前所在校区 | 固定 `null` |
+| 异动后所在年级 / 班级 / 院系 / 专业 / 学制 | |
+| 异动后所在校区 | 固定 `null` |
+| 异动后班主任工号 / 姓名 | |
+| 新名字 | 固定 `null` |
+| 审核状态 | 固定「已通过」 |
+
+### 6.5 异动类型说明
+
+| 接口返回值 | 含义 |
+|------------|------|
+| 退学 | 学籍异动-退学 |
+| 休学 | 学籍异动-休学 |
+| 复学 | 学籍异动-复学 |
+| 转班级 | 换班级记录 |
+
+> 本系统**不同步**毕业异动;毕业相关信息请从「学生基本信息」接口获取。
+
+### 6.6 响应示例
+
+```json
+{
+ "code": 200,
+ "msg": "success",
+ "data": [
+ {
+ "提交时间": "2026-03-15",
+ "异动生效学期": null,
+ "异动类型": "休学",
+ "申请人学号": "2024010101",
+ "姓名": "张三",
+ "异动前所在年级": "2024",
+ "异动前所在校区": null,
+ "异动前所在院系": "电气工程系",
+ "异动前所在专业": "电气工程",
+ "异动前所在班级": "24技师1班",
+ "异动前学制": 3,
+ "异动后所在校区": null,
+ "异动后所在年级": "2024",
+ "异动后所在班级": "24技师1班",
+ "异动后所在院系": "电气工程系",
+ "异动后所在专业": "电气工程",
+ "异动后学制": 3,
+ "异动后班主任工号": "T001",
+ "异动后班主任姓名": "李老师",
+ "新名字": null,
+ "审核状态": "已通过"
+ }
+ ],
+ "total": 12,
+ "filter_year": 2026,
+ "filter_by_column": "异动生效时间"
+}
+```
+
+---
+
+## 7. 调用示例
+
+### 7.1 cURL
+
+```bash
+# 健康检查
+curl -s "http://web.hzdyjsxy.cn:18081/jw-cx-api/health"
+
+# 学生基本信息(第 1 页)
+curl -s -H "X-Jw-Cx-Key: JWCX001" \
+ "http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/students?page=1&page_size=500"
+
+# 学生异动(2026 年)
+curl -s -H "X-Jw-Cx-Key: JWCX001" \
+ "http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/student-changes?year=2026"
+```
+
+### 7.2 Postman
+
+1. Method 选 **GET**
+2. URL 填入完整地址(含 Query)
+3. **Headers** 增加一行:
+ - Key: `X-Jw-Cx-Key`
+ - Value: 学工分配的密钥
+4. 点击 Send
+
+### 7.3 Python(标准库)
+
+项目内提供联调脚本:
+
+```bash
+cd 跟教务系统同步学生异动信息/api_service
+python test_jw_cx_api.py --base-url http://web.hzdyjsxy.cn:18081/jw-cx-api --year 2026
+```
+
+---
+
+## 8. 联调检查清单
+
+| 步骤 | 操作 |
+|------|------|
+| 1 | 访问 `/health` 返回 `{"status":"ok"}` |
+| 2 | 不带密钥访问业务接口,应返回 401 |
+| 3 | 带正确密钥访问 `/api/v1/students?page=1`,`code=200` 且含 `total`、`total_pages` |
+| 4 | 循环分页直至拉取全部学生 |
+| 5 | 带密钥访问 `/api/v1/student-changes?year=2026`,核对异动类型中文名 |
+| 6 | 确认 `null` 字段(校区、异动生效学期等)教务侧可接受 |
+
+---
+
+## 9. 常见问题
+
+**Q:为什么 80 端口访问 404?**
+A:公网 API 走 **18081** 端口及 `/jw-cx-api` 路径,请使用本文 2.1 节地址。
+
+**Q:学生接口为什么要分页?**
+A:全量学生可能数万条,单次响应过大易超时;请按 `total_pages` 逐页拉取。
+
+**Q:异动接口为何没有毕业?**
+A:按对接约定,毕业不同步至异动接口;毕业状态与毕业时间见学生基本信息接口。
+
+**Q:「复学并换班级」会有几条异动?**
+A:可能同时存在「复学」「转班级」两条记录,请按学号+提交时间处理。
+
+**Q:密钥如何更换?**
+A:由学工方在服务端配置 `[api] api_key` 后通知教务更新 Header。
+
+---
+
+## 10. 相关文档
+
+| 文档 | 说明 |
+|------|------|
+| [README.md](./README.md) | 服务部署与运维(学工侧) |
+| [../异动类型映射说明.md](../异动类型映射说明.md) | 异动类型详细映射 |
+| [../../跟教务系统同步学生基础信息/学生基本信息对接说明.md](../../跟教务系统同步学生基础信息/学生基本信息对接说明.md) | 学生字段口径与状态映射 |
+| [test_jw_cx_api.py](./test_jw_cx_api.py) | 联调测试脚本 |
+
+---
+
+**文档版本**:2026-07-02
+**维护**:学工系统开发组(接口变更时请同步更新本文档)