From 8c25f7c11edb87a4fe83e3868bfd91e8fc6b00d6 Mon Sep 17 00:00:00 2001 From: Code Migration Date: Sun, 18 Oct 2026 13:35:32 +0800 Subject: [PATCH] chore: seed migration objects batch 3 --- .../screenshots/00-menu-overview.png | Bin 0 -> 25933 bytes .../screenshots/01-dashboard.png | Bin 0 -> 21651 bytes .../screenshots/02-user-management.png | Bin 0 -> 24712 bytes .../screenshots/03-user-groups.png | Bin 0 -> 17673 bytes .../screenshots/04-group-management.png | Bin 0 -> 26016 bytes .../screenshots/05-group-members.png | Bin 0 -> 16533 bytes .../screenshots/06-invite-codes.png | Bin 0 -> 19677 bytes .../screenshots/07-direct-messages.png | Bin 0 -> 17912 bytes .../screenshots/08-group-messages.png | Bin 0 -> 17394 bytes .../screenshots/09-sensitive-words.png | Bin 0 -> 16873 bytes .../screenshots/10-files.png | Bin 0 -> 25436 bytes .../screenshots/11-feedback.png | Bin 0 -> 16640 bytes .../screenshots/12-operation-logs.png | Bin 0 -> 26484 bytes .../screenshots/13-user-account-audit.png | Bin 0 -> 27512 bytes .../screenshots/14-group-event-audit.png | Bin 0 -> 27094 bytes .../screenshots/15-admin-login.png | Bin 0 -> 28333 bytes .../screenshots/16-user-login.png | Bin 0 -> 26971 bytes .../screenshots/17-ip-account-stats.png | Bin 0 -> 15546 bytes .../screenshots/18-login-failures.png | Bin 0 -> 21417 bytes .../19-private-domain-settings.png | Bin 0 -> 20325 bytes .../screenshots/20-security-policy.png | Bin 0 -> 25710 bytes .../sql/Admin-Server改造.sql | 350 ++++ .../sql/IM-Server改造.sql | 464 +++++ .../sql/IM-租户业务库初始化.sql | 1618 +++++++++++++++++ .../服务端消息加密与内容审核改造文档.md | 570 ++++++ docs/admin-adfahi-research/测试用例文档.md | 460 +++++ .../测试问题记录-2026-09-07.md | 353 ++++ docs/admin-adfahi-research/需求文档.md | 1462 +++++++++++++++ .../backstage/IM-Admin内部查询接口缺口清单.md | 582 ++++++ docs/backstage/IM-Kafka接入方案.md | 53 +- docs/backstage/IM-Kafka接入联调测试文档.md | 1144 ++++++++++++ docs/backstage/IM-Kafka接入范围识别.md | 13 +- docs/backstage/IM-后台全量需求文档.md | 570 ++++++ docs/backstage/IM-后台菜单覆盖测试文档.md | 418 +++++ docs/backstage/IM-未实现增强项.md | 358 ++++ docs/im/IM-前后端对接文档.md | 742 +++++++- 36 files changed, 9080 insertions(+), 77 deletions(-) create mode 100644 docs/admin-adfahi-research/screenshots/00-menu-overview.png create mode 100644 docs/admin-adfahi-research/screenshots/01-dashboard.png create mode 100644 docs/admin-adfahi-research/screenshots/02-user-management.png create mode 100644 docs/admin-adfahi-research/screenshots/03-user-groups.png create mode 100644 docs/admin-adfahi-research/screenshots/04-group-management.png create mode 100644 docs/admin-adfahi-research/screenshots/05-group-members.png create mode 100644 docs/admin-adfahi-research/screenshots/06-invite-codes.png create mode 100644 docs/admin-adfahi-research/screenshots/07-direct-messages.png create mode 100644 docs/admin-adfahi-research/screenshots/08-group-messages.png create mode 100644 docs/admin-adfahi-research/screenshots/09-sensitive-words.png create mode 100644 docs/admin-adfahi-research/screenshots/10-files.png create mode 100644 docs/admin-adfahi-research/screenshots/11-feedback.png create mode 100644 docs/admin-adfahi-research/screenshots/12-operation-logs.png create mode 100644 docs/admin-adfahi-research/screenshots/13-user-account-audit.png create mode 100644 docs/admin-adfahi-research/screenshots/14-group-event-audit.png create mode 100644 docs/admin-adfahi-research/screenshots/15-admin-login.png create mode 100644 docs/admin-adfahi-research/screenshots/16-user-login.png create mode 100644 docs/admin-adfahi-research/screenshots/17-ip-account-stats.png create mode 100644 docs/admin-adfahi-research/screenshots/18-login-failures.png create mode 100644 docs/admin-adfahi-research/screenshots/19-private-domain-settings.png create mode 100644 docs/admin-adfahi-research/screenshots/20-security-policy.png create mode 100644 docs/admin-adfahi-research/sql/Admin-Server改造.sql create mode 100644 docs/admin-adfahi-research/sql/IM-Server改造.sql create mode 100644 docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql create mode 100644 docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md create mode 100644 docs/admin-adfahi-research/测试用例文档.md create mode 100644 docs/admin-adfahi-research/测试问题记录-2026-09-07.md create mode 100644 docs/admin-adfahi-research/需求文档.md create mode 100644 docs/backstage/IM-Admin内部查询接口缺口清单.md create mode 100644 docs/backstage/IM-Kafka接入联调测试文档.md create mode 100644 docs/backstage/IM-后台全量需求文档.md create mode 100644 docs/backstage/IM-后台菜单覆盖测试文档.md create mode 100644 docs/backstage/IM-未实现增强项.md diff --git a/docs/admin-adfahi-research/screenshots/00-menu-overview.png b/docs/admin-adfahi-research/screenshots/00-menu-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..5299a52a12f60f8c1f9a7b08ea30c867a82d5ba3 GIT binary patch literal 25933 zcmdSB30zaxwlE$iY^z{X1VpA56{LtXhIt6>l|f~yQklX~i3UOpk$DEIR~Z7NN@a#9 zV4@O8kqpXMg^(hI2!TWsNn|#}2qDY__=2^4_rBMA-+TAH|M&a;2Y%USuf5OOd#%0p zK5MPL_Stx`@fz^yDd&^UfGt}9fGvtYzy=?19H6v)htiJiN=iGFcJ5UA=#x)B`Q+n| zKiR9YXZNQXd-v_r*sHF-U+0kCeys!A>gt~#`TT%^q0yH{ntCQ*nH>J=&|#y)n@G0o z+`03Uk3LcTC!zW{(AZM(E>X4@8hz*gXvZNM!X9RM9gC%10fqPPIR zUt6|s-KOa2#~&$*9X?eQZ`rzi+ecfrZQZ_or=r@Ht=qN(cYOB68CB2?CH-UHoIV$M zXYT><4?ETV;#FC%e(-oy$jv8x!!uIzD+&afo2V7n@5mMDCZ-*VB17ON?(GWnJAW_T z0^GXoGu3lnfVS_@KXCBaH-9;OXM+dWy-iUOxD5z!0Ic8N`OGYMN>80BaZgA)HM7L# zL)?G?3A47g=_JIIRlQl5h;geQg;VfO{3s z#a5=}Ez5xDEZQ-uHUuoYmA0@}rn##2xxQ`ub2H z9Lm*AB+A?*NcTuqM&NxkhBO+{=F*cP(TTR=Yzn(G>^+Y0ATtR;a@8^06W?M zKSrh!M&gH8!o7C7y^PPKU34kCN`}ObOo>%X%!}>9i04aW9;@;*w;tDkR2vH*dXk93 z(^Z)8DCkiV#Ljy96r(AlFj>IX>hQ1kC8EL2yp$(%cVrjkw)|IQx?-}5;ctRi72S03L0ct{K=0C^6`OIZPD0?&l*`utD3^bdiJ z6YR?~B`~VZC8iGIVnfAX-Tn^f!A^Ffh{KN=N?S&p&{1~2W}l|FAat#=$L># zfVbF;wx6BDmiLJWZ1gB zbx4T80X#ouf<|n_{YoEN$zR^r)OH4SbGV!JmbPWUO9wm?p;z!|rnBNkiW<$pN*LZL z?LUwx((?)Js(D{G{eVXNl>5T~i`?M9LqO43GFd{trwkwEy(BR&U$}zbb)B(|_fD_S zhrlS^0bgiuoyS^dZ2*?0qkS6fJ;E1V9vDwL^t9kFMWG2rV>!vrQoWE6Ea|7YOn@ItzhcG*(&X1NSi{ci1QqMFy-E>CyJLmBJk0)2Eb3*J7 zos8b(DSkx#>wh?R{;Xh29$|Ii?BJBqUAEBT1ew$JWv2{04g|`IZ`FD(z9rPMy?h8% z?R=0b2n3o`Rb1TZPv;QaTtU%z8iL@|fq1~WXwhD(;+q!{a6<={N zpZwDWFZEx7I*py;ct}y(auR4*xR?}aD{l9w;prIQeB?;>k+F$!epp_UqkIB#kG<+r zHGpixWIu+>WhW|bsDfHo(GGAwE2Ujiqv-Iz3D2g){u5bxCceC9#e zyzWuE@i^QDz-t45bd4+h?WU~dD1_VU z7J>q`_DwxYsR-p~a{0$ANy_D(W|LWMKeU;7A4r`HtH7-dcjn=aMqV2vvrOvIuG6Up z1{PayOf7fT$)hzsW{=bb#<|T)jgefPkyrIe?xCov-ugCrTMKCZ+mU^1c9)?0^|OO=f$MvVCCU5Sm54)3nQB3hP+2o z&ST^JQoLP?jArLJ9Y*sb%GW)Vlnu2=R1PEmEPc{xKQUi@y?UgmL?&T}@!%FR9^~s4 zT$X+PR6;&qmsFg5=xW}%1)95uVIgMeESnSM>@T<^TZ&fIezkywG_9p!BRfn`iNuH#aV41QkqRP44FJhIq>x{%jU~fNeP$d-B}De^TL3inPov z85+ESiK5lFY!-IiXYEXQdK-EJkmeDuIe5PBQf*Mxj*w;PXfgd{Rzs9BIB$9K+r@w-#ZsLb{6 z#VMY!oY zpDnP>WHV2>q=ha)Q3o?O07p`wsb5^)Er+IlE=A2<2Y?m7SLw9=eE@y{r*6%7(BaoM zl>gTe>rSiT>H2FF#FnOI4at2uW?`^eX?iS}N|-g}d;h4_b|a04`+vCV4(u2!RC52S z`Coth6M6n8Am;}Sns~U4tJ}ORcDs+3ZmE(ntSljt6UlC`riWM~*+W>`iMeyQ0Q;|aZz%~fFsonUA;xlMy;6JOXsG;jA^XbsfGxD&b(cpQ2* zr8HJZ>_|;&dTkB6x2-@(AkL1JH%2`ZYvcH|k#C%6o+nx+ODuW&OXqNEZ2;jg`(bNF zhR3s&V=$ISL{PXa5!LpsolTt2^BK|0VCQ}FR>y+#buaqR`e|7OUWM9cP?n@4F~!2_ zttr1(a_paPM!PyyiB%iF!EU0`)v+~x^Q#hMGf9YfORD;O-;yG^v)NY8W!S$pxuxoK zUOhG;GYebUqR^>QLoc*=E`-X{hrDq*>0j--k$pp9o}}(kDZe#$zH`|KJKBPjiEX$1 zfxx`C#uL~)Ne9v_8M?vW_15f&ih@E8w|T~)wN|z{Y(BS1N*^ekiLVcv$ZOD=KLOgL z>v%iiLaHW!v6=jaew*GOwSV0G2Wie`r{gsm!VB%d_w|RCKooD%7pi|y z0a|y~sZ0q-c0;Q&b=@!j7drJHXi%f5WecofVMS2y!IW6}w&NDNIiG#~Ph|H$7Hx^` zTzF~%VTJ4GCoI;>-1Xs!DW_yH3Z9|cUcLkdxxC2nep1Knhn?=Ws&|h%PaozUi4GPJ zqdJ_WWCaIxW)GVz0kdbBc`C4nSuP~`+cG|XQnktBFW)-#p z_Jy0=EII~NaZo+t4JS{GPn0)0ZvYIww|@1R{0tAt%^mIMo`xn<+5YeX;{0qyJ!pK9 zpH?c-m`#rmW}RmkE=ZPksIM8)(e206@<(W|)dOt8D6u0DhiiWPg8~~a3UAZ78tpbe zJ1bHd{fLCVT(%a!ocI^+ zr?1K7Di@PZ(I1Xw)^UrnC9Sg7-nx7rJO@@*;_j?Hmo;7*((JFFR@#2aI%SVjzLVzY zp%DEAj8EMLplDu@t_qxu{VkjW~JoDO$c1#-Py@?Ws zkQpaS+~L%sRyJ3}4fP}V)Ue-3o!x^{d_g_*#D@Z7Emn8pPF&kZoXOV3yp!X2t~4~E zMBr!H}|R%KGS-V;O`RD zOP%?SJuFYtCj5uq9p0Qeq~9ix0Jmy>j>Jgexjm|vFcZebwkXy9(7p-<_+mxH_cG0; zAeyNPGw)G7y*AwQkiveaE&GIFt;Cak)=Nhp#5^|kRN<74lbupo*%nwM@p{nJlQnFl z5mIEnFS@^>+k&1zc3iVb;r}rH@Dj=!(ay|n@_uX#M$RwRg0*6CM0Be{9e6{wnpD9k z89Q_C>NjxH>Xl#r*40mVq`bCkw@^Ne;9tzZ-!uQ)8qhCJTSgw$O3#nTPnDf|nOKv= zb=87noKn7I%{_J)ohXgXYk~Z-P=(E3dnV{wtq&Vn=y!}67l7UER^~*eTQc@gB7d>^ z>r|xlaF=FCq!XB7=VW*&l<2YQ9)c^U$;zM1N6xP61&4{AHbBiHjRu&aVV5if|5f=U zaZRK<7hArVmk-K&@G8p6at}k5Zm`sF0q+{zF;O(Z%LjYfSLTmv#bSvOdS~`pY2>am zD|3CaY_f-Iqk2#9*a^A#P9Xj_@*V^uAeXZi=mR({>HW4!{-hUP^Eq&QeAkL>qlizGJd zdRIVaP%WcREBs1&I2J{GqEn^e*y6p~ka~qL2GaFGQ~=0<4FEiyu{Ma1a%H>xac@}) zHLI}}Vzd6eM#TZ1ybSCq!G6q=92C_&g&n_-x1FNSAS+PV>S=ZfLY<#pXu*oWcvEw0 zfuOmfo#h(<4@99}y`911nwzB|IF-lxA%k=#L|dUhj+C{coqun3>$O%R`LmVH-(O8c z8-NBM#iM6LHC=D@_7hZUN%zX_Rw=?{pM=T0@+59)cTFEcQOWz4lnua|9TQ(+-w}D~ ztsI<*NYA>$t{YxDpr+3k98i7?M}mPINTe!Rj{DjPsg_#<1TLKTz0ukSOX4NJe|^<% z-u|uHLovCCQbaC}wL(1T)?zD@fXObwhh;dUxgY)Z&udB%KL=Y>9Ri)Jjt*KRJ0bP_ zif7Nzw1#6D)+QY>k$L(JmSux{r1QMEt=Hxk8~r?pY5PM*5Bs!&KCoKuLrzi;8aOG- zyVuoe6>(&r>^xV5z#Npg@=Zca#>d6Fw_8DsqzjCIA_=s=PrbIrwxdHn;s^L!32K<; z&ph9D`!L$UZ{89|T)DDzr+F=OK#%h|2n^G5+?P6AUk=B(6I^ZzrSQkba354?WBRPHKo?fAqSPfhA z<~1a_YQZp=Fq}9i>|*nl@y(o~VqU#p7PerONf7ndvq^5z$yyy70DeqKibX$nX*r4s z)0x~G6~vbSfgn9)`%n@0&1IuYD3iGW6~#Ac3vIv}iGs$EhNZqq}*odGSZ^8v`sgr&$!epYJxSHV84Yzn~~hPa-Y zJ8?j4YciGI;4r#AMP3pwU-r-0x0)XM1D3Y%Vq5TLlAy?=E}Jj?tKfjw3|gV&%)hCP zSJQT%rCL#{5(_rzc&qT1!)q^dU&AD86-p2x|9j;$Me=epPX+u7;F`(mGpjz&&P77c zO1U%2yI+8PPyW8f{MS;Y8K8@4wzNfWw_+=USL1&ye7fsoE|v5XI-(WIgyGlIPZZ}> z{m`*iOz$vHBEF>XS}4H1RQq}+GUz19B)+=Gr3E3Ak=LjyI1TRTa2eREw%e`P=t_Pf z-b%pXkEB6glnP!__Wjo8jizQ_ijD54g z2N@RbRI#n2vy8g-#nb*|<@UM8BaIjR%L9B&tW8kjR)L%$x3?%O#I;$ln`4xidekI) z@9Vq=B8qwS`4)YH$my_J0~s2FqGCy+8ZSw2h7wLCco;eB0d)^45<%S@qk3f&Dj%tE zqi6Z`uYS=!6Q)peESMLg(~>2ilWmW~QDEOJegRuI-L!YbT(|vFi+3Re=Y?`Ubv`S* zL%5pU$!Bolc$Vezw$JBEOk8HkMGZ@SmzHj{8Tilg+KQGf$zkLdb4daIDL@cLClRS? zD^Y%}FWkNU+2-=N1O9hvrFP7q;x!lDYgZrqS?IGg&0Lx=EvOsq~OI6J(SUOzED(Bar0Vyt74OT*eX&a#O1M|vDP zWx%$ZmbnW8%av`+OOm4vdaC$2C)H`d?lW6iu6v8LlqB{x*GHb5GvKU$r-^n3p2dNTy}c9X+!>8-RK7q=R3(LFrjJ z(rd>tFT>oH)E7jj)ZV7p<>}SJJ6RZKDG0Q%WgLt!FYFT;-88oyDZ2soX9Z5Pr~3~~ z&L;(G2Q|8xcQIN1j?hw8!u8j`b!o?@PWbd0wPuv`dcWq+@Sksf=<0tlQ4Ia%kzQIB zG!t2a$72kXAt&hjX8m7C+76+aOzl1gb$%3Ma!loURV7Cl_#|dUV(}$i_PQ<{)`hCH zu2BVofeyj;73e)=`EiGB%v!eSs|ytKy-&0YiWl*H>REOCIbk7wm0!o-_Hqv{QV+S* zs`7D$UxqtD3iHWV^3>4v_vZWe0k!*7+nT^_fmHDXp>Bbnhe*&>{%&MgR#u3rVwu!26znCY^ztVHPKj?gzSy0BAAtWu!8(C{Z03iB$`o~VKn)0m; zo2Jq5owk!w=*%wDkMF)t@>`JcbWF1zjByl09k7_|G_p|4#3f+REwHd`i55!di+3+N zZJhnO_L8zdYNaSnh$YW*B^LK>B6jZSE&5x_-HbeI#EC9{M*o3Bm0wKxjxgN5%MQ7V zI5Arb3%maH2c9b@{f&Bf4(YX4yaCW#JWF5%_iUBX)9-O!Q`XfI>TvFL+=-l`7#owb zBVX~@Gz(Y9xlE!9bh*Q=BbLgRoQ*BBb*d}rrMch-+-H8>*?(5QnRH~nm--Mypm6L`N_r8U^R?P9fS?= z-LJ%2iM!nz@~p*rCvaAmeLoPoV^@mT@~!#J-yfly;$OFCB`e>;G$f&AZlTN7S#y$V zT||kT)|RR2#AnMqU6MW5N~ms*oovpQ;k6s>?PjenBkdg;&a%C(rEl}hH!*9_Rf~2f zkvoU1@i(;O6Z#FNm-?#uL}XurF4i-{GY^h3r=O^HW3j@}zYDk;C0HkY*O!|X(&aGt zlai-@=&`@o_~)tRrhTw(SoWYya;+tFjNsAR>>S?F|IEqZ26HwBadDO7Pgo!GUvMITlY zUtHeW5;|D-$}j`!WaAnZ!|Ctc0Q8leHWP!v?CxH8`m-73q?CQ+7f6g}UQxY|LYR05 zq{^4KDlDDh!?bpX%iiezlsLJWfk8-o7Vn#sGg7qB5R%w`c2F~tLFFKn0fl%dZE;!v*Es1ZyPzgGUR9e8%X}Ft-(!ExL|Dx z;`INbVYcam3du!>%PopL|7RTzR2UHtv43npJbLuvt-HtI-TCGSUshtVu%Of>BlxFzfgLR~ z_4A#XqjnD2VOBvp5S=uVy*&LhyHCaiAg-=JSI()R@`)@~`r$&;oRLw@M=GVdI0p^- zz1AaS3aDS2#HSM_NH@(TJS8oBr_*UDr7N^Y{77r#BN7MZPcL|8sDkt`~Xk)_kDP z;(;0EBmaHnbjS0^mY*@64ZO^1f&JSQ_KD((;LnS^XPF-EvMA4^Ybp2H)8hkr&brW-7XwbklDmlm;iGypm(W+u^$*}uZbT^D zOgHR9)9RZBF7Gy1?{d>L$f4y6AGhUKru$q!$0mAWEGL6%t8Kqu5Y zzB{7Czk&NKse@4{M6IC7!Ch-jX$SX0H$C`&cxJZcArmB4>nL~g=2Inhjk#{aWXF)r8t>z3fK_7RwG{`XTvAJK0PECq=c5h`Oimcc=%}31g8g#u( z-xOtL^7Qo~J;%<`Z}P_&t{!}Jc*oVT6@g`^+4@WAZvR|(USojvU-I*JBhjZuh_C5p zttYaho$Xf=>4~-6gsE9=7hamtx~yY(zxdX`^eNsPgf#*6;#?X}f(W^)P!w>?2W z&G=PAG~+Wfn?4068DKB_9NoPPnwTW_7vblrCv$wtE0N&}W7h%)xm8j6cK)X&<0Vsn zfkjJ5-(1lvy&d$@Zue|uapEsPqGtX9GE0b|H-rTU4@ECt*(wm8(q7eJ3fFv62lno zzg26&FE|i3xAT{^8>6p64eRA-A8ck2BI9(WfVbv8`m|bbAb}Q?uYJQ-UK!e(Bp6-q zle+0X8)f8YC8Yf2mCyEI0~4o*L4yL6MsdX{AGI2PCfuTWnu{1(;Jt45Evh>bsY;cq zSi#e;&LmiVde^Fx=7rg_mwh2;j(X}svuBp&U44}HD|ViNF_5}g zUKN(xdUZW_Vx*yRBq_+`0rP$<&cvEvUwg4=me(Z6Z>iOC@x+y}PNZ&AY@Znkb+u#4 z7Z;Jh!Ez8=&+?FQ5JKTDM@>%czWm3?YDQ+z ze{T85KWK9{?e9~lvH;=`K39Um!>U|=^YmMXHhTEI`>7HIgnS>xiYaRtGPF-4*r(n8 zzn;0Dl{KrRL7asOaEDB9uC4D%+Y9-+=@jcws%Z8?w|&3YYtk?nr2BoI-@E*RW-Qyl=5?|uH{uY%eFdPT!8SzE8?bLVM`RHx$hYd_{J9%9M`};Q+%1*o8 z(CW06^`O9Q0-jMMgOz2Aa;IGpUxFk5H6LDUR#IE)lZ zyH?OoywFq{MdIHPv)tV{D(%(K_EGsH-4If+6zyYv>|8dpGQ_}PBnyjsuIrkW{y^LC zo7>N)X2a%Rg2Lr#o;%ySXjc>NwoD0-GJ(M%aVl=)(Iep}^tvY*7W>ar#KRLrC$V`m zM-kkY7F$?6f)lZJjWk}gdXz_kR=lV!=(~wJ)w5J=J>BdSVzZo>n7YzpxnIdJLu*MURp)o&2Gj4q(%bV9;ZwYFP=NO!)RpXA zPv$i9r@>Ty)CNH2GBjmhGHPYD0Z@9<5kv06st2KJ@*@=CF_1urCz!qgU`Ob6EL|X5 zo3F?d&=o7C9u6u7Q9(`qX}ULPU80tf*GrG~>}+rU8Qxv0ZbYWfN|<(>Qjb-~l!2L61XS z3IoeAt0u0I9}loT<19r#ppurP`Su*}O5U}w3!k5xMu}2fPc6`{^{toSv+|_;z_-hYbk{lO!heujvFET}_GS>bs*~)O9_Wq@H9X5)2k5h1EWU zMU$yLmjEAQt^NY*-O}qqZi8m3GSu)8KdGd5y!Y1P$t+rq$N(wB?RRpbYd-muanC6GL7@SFi)H1l_pyL zu!QmzH%U|X%yh^sB07|g99=(pkwTH2%!wb*;dE<7ooVuXqML5bM>S^L&ogf~R5{Z2 zZolWQBONeWZXgLvJ0a#IF^FI$Jt1xXnPm#^Vr;9t;UXn>fuGvAHaa+Ie|zCCjYa(% z0BTP^q9AI#jECvrClF)E(~z@Rr-TK|Lrdy~o~4IIz3!Gqd6$$jP1MEYo@LZLzpl^0 z{)Z`WWQFGS7o(=1_MeGz^sm1-m^%VXz&RM@)$HXUNa37fsXvEy(9z#8IZB3F9v3eq zR9L^YsL6t6K-Fm~dv@-KJdX(}EA=d=gY)gWpaRVunXw9Y5N(tk==S8eV#= z-=`~|l>iaKp$))r9UnEF*zz@KMm)Dxp_7KA{-?JdF60-neld;dBclTs?na|a)p4fR zZqAL99i3=(e19Y)vfpq+>|5Ol8-Q#nkO^rbUe!}+ z^L+V!02JXYZJs=&FX`sjPXA#&+vROCv8gb8&iI2@hJjV1nz8%e7R5B5DGEJJxzzbk zKg!#vKz|;mIwV-Ao}J@SNbZZ*Thgv#C%SO$q)+K;)Gx;<=4!Yx$CUW$gd5$qJt*Dj z6XlD-?~_Tt_ahM%fy*6xy|RCTfn@6Emn!9(v5&B>g%wSf&hrm)Qsh;WGSZWnp#F2C z`*TXl^25sP>MwFj&y}WoA~G@4TqLVKkcPvgK6#t+yQ~Z^Q89VpVDY3&M&|3g4c^Ao zN)ruYbdR8Zq?6ynv5v3n1A^P0F4!G=T+<$NYp|oU5_IMmIs%;>CM2;)JzDbLF>S{A zd=S9@^!Arwn}|bL>{L`Z%ueCwu4N`qOfd43IgwF5iR0td{)W|PhhWP)is+v-T*D?; zH^ZNPzx|)8tctbpwpH_StaG8CP0D_|m6|iJiVb!9D31-~_06lC@!SP@tRQY@SoHiW z9~I0G<0HlO6FK{4mKLhn1yU_EnZrjpAYOJbBrN5w>Vc^PDmD3@7!@eDpt&!gG-^&b z&D{XdzD=qhTZ*RzSO&L~c|BIUJqNEGdtz31L}cs#LG|`cOR8#@+yP{5~~5 zFbQb{?Ki>@(dK#eVAT!4-qgll?5$+ng@$k3A$DBGy;}4>6mVGP@e@>gIA1*UJCJUS&bDR;p-pc7=tMimHyTxU%L= z+-&_rJI1>%#uaCG^u!W-`&9emMOH(*d9o=c=HEDhmxh+GBSy=m#kiegvKQ#R# zP(cn{k*VIUcyg0A#FkAyC^Ee75~!`b{fJR@JuOI;;kCT7SM^{u3L$YhhPVMJ{~MAad6ufBqBewzzoN`3>Po@Y8QT zG|Oj&uT^&u1}E9+ThWC&Be(cRqWG`6Qt@$%sBUa(qdZ38j6iT!q3-6D4%cq3AB2_J zQA^z)FOFlJkYK-}tfEYeN8dPLZuJ8D1ZdbSE21k--!R+6BR<<*Z&Kia;s+7ADl^E`4&2GlqDuycnEO{DkP#bNM@cU z)XZEt6>Au_L*18gZZrZLB&>p|Bj z3~yaJUd%5{#T$?7rXS8s`dT;o+N#degoq5Kd=;bCBk?(fIf}?AOc=vz?M+2RuxA-) zU{&J@#m@G4RTp8LQI)+EotJ3ILx92EWH;8qh`HNV(qGJe$~oJ9@r87RepT5>L!keN z#0qw>UPe0@eh=8(8#=VP!T$+!Gl1y&_00{>UyeM8JgRedalf@%(5 z5k%&frHJ5w!py4^>ECPj2w0bz1*VV!{iv}VYRMQSw8Q|VIIzL4#V4gqgfd~SPv`st z1Lv*2a*7sM!`TR&J&m6zYVB(kBs;Ixy1{Z6$KO==qp5?hwBMHPynenF$#?Z_h01t? zl$tQA!v7o+Pl7_bQOg9eNl6{>&LF~UCE$_86G91JEmH7e*`S_`GB29V3gne1`4EcD zt6hAFTdDaO2(yx$mop*R%gtLuxVkr69$H2RUO#mRQ={Nj+`4hA@u;*UC?MP zh+}-J!wCr_JEm$LxY<(wCd!9uzeV5Ln6I00C>i$rv-iNFOzh#z?ZK~U!^g+k6i556 z?fKD^t$4{F-kj5L{gjb|+NG=ZPp94HTT4&@FAdGb)@TsRb{fRR%Q;zm)Ckr94opJgOy{el~ zEvk2PG71!T0ks^R1%Z~>j-LGyYIpjlrth;wBCL5O609Ww+M>yc&hq-r(Gf@cJEB3p zR|`rdl=z(%@@>}zv8q<4ijc;xkukpcXmZMg&D=^1pWtces$+!Ji5#s(p6c+X5K%zj z?3IuktI&ShpMZxLY2VFLK@sELZDmv9{adi01aBZw+t8M36T9eC9Xfm2s zG35PXmYtYe(`>lO+>e2q{NH5mVFjC^VYh)itw4Ab^Q`n85Gkqtz)Od@61KKDi2;91wO2vIwLwt@fe*_*L6#YCw zuh2i%KbM7(jJI*W4;$q*uXyYv4!(iu$Y6A9BTI2$^CFJu7|Wk*XjdFSu;Ns?+#d*S zzg-(S{v=Nk4V;%uPm%>epsXeSyA_j}k=BYACV36l2ggd>VoD@beyW8x`%;TiVaK`! z#uL+#iJ66EPGkyE3v$*yJBM8h-8tb2N`df;Hbw(JURIS~Ny0n4X%yNzp&u74HP#O0 zV7~`e^Qpirrs`xdi*!@CHB_hxA>EI#z_0x;l@0pcBzQWOEo~vrh6@Gl(-v_JD3K>s9FeA!cN3l zqI4Hu=pOKT%lNb`BI*WBR&?OmvSc%Cc}j4|xI<@Nor$#LK=JH3~^U)&if zX`UY#ed1Q1`j7)VdQvc0!W9rs5FNR_@gjQ*o_&1Fg8lT*c1PP@9{u7TwWc7Z9y&pp z2w8>Nc|ZjQY%{VWw?y9(6C>=#59uEG#e4+|zuzv&_q^XV?4@_qy(YkGZwd20;x0a3 z5z0zkb#on96~Y9o+j^57o|p$XWS<`hcfit)o+vBACpIvr)$ zHH)pk{Ap5nfET1!n28|Iw_`kUPrFB;N|kWXc%G{dqMYyyr9N9h!FJb9r(%=eej*2Z zrWz{uBOZ>IJ^pG0Z+jkR+vDiSTK02!Pu;3=%13 zW%U}!tgT4T?{64rPkczmyYtvAx9&c*XNx1#|LK0&F2D4UG8@0)4MX1 zL43m)dWb=~E94(@`@;~tzEq)nuRl`a++g^_zS`0BGx^|rOM7v+OtCSqZ&1%k^6c(< z1ANc)kN-aTUs#m(s7l~R?X}}|YyRqWIw^$yE`(87U}BDO>*-(}>|lxl@z#U_xIq*8 z3rU7CRuM+}vL$#XMd1{9T{2vPcI9w$YamdrVo!$VIU_C8a$?MkaS&1AvC=||Q~kx@ z3tc5-VrX;Ebhmp|JLy@{v@Wz`n`qmUtaI;Hr;=s9J zl?DXqhwTs837feOWu-Dqy3{Wtp7;vrqazXKi()g94{*+=fncv34HpOL%q zXT8Gx`Mvx&$>ikGO%#Kq(^w_K-jM+>^#HF)*A9O}`3dtG=6B+Ri2SowlY1xWZq50V zL19_^g@<`bi!S$BiWAq{Gbc$gNoDizu-~581U?8+oVqypj~1NbOj*(8MEiJh5))G8 zm3O0(`X#*VEDIcQiis##sBN4t%8AV?;_0J7+=BQ);sODhE!&@Z^vJ6PdR~1{!}Ue6 zTI+&>mBdcPmO$=geDwwZJuFlN?sbVAYQkDQMLt5ZhZM{vP7k#08Rk-v!6&OJ4Kdx| z>;`Lx#TfDWH=Qhrse|(KiGGezzLy$#Y#^j1(%Vq&;k0^rrBG$a5Wkj|EkD2$i>>n9 zl5!2)lF*+-d9~9o78a8-)D>DvFFPNx2t&1|!tI?e+FV>p(S9L4PiMbs2|=>ED~&ST zu@JX8#`VV!9i3*KLQKN2CX*9;tZNrAepmg7bc}kTfn7l1-N$%K;@B8DMwsm4K9U$$ zQ66+Zs4>+9T`=E%#k3$K%HLQ^h&EZTmH)xlKV|m62S!?jYIfI*TE`SJvOyaFsA6-Q z<@w1xp=ATmRGKg=(zA@j7tPHv^UaO2A4j2B37HruTG2uNpN-^i$lohn>Ss42j{wjA zwNR9u#@VaKpIUiTsjD37h~Zs)4B0={%BFGBZY)+@p2|Tbd6z9vLfZT)vZWnnUWBfS z#umHRblc#A=d)_l!EImr@2#OHr2Y&wk8MP>`%aWD)prBgiW4{<_2L~?q&=;B_LmHq z|15L29yNj2hvgx17iE0XYYnF(c0S+WJOh?u8&irPoZqtcNHJnV5d{1GNrd=u>g$$^w4xt4}B!QA}uJ4A10i% zrOpU1gu2uYoX?Fo^?f%BqKhllo<=$~vdlXBKp-Gc%L#9DwLTv+^D50Ui`373LiRCO zMr!BVN>sVkbGVbJ0r6Z?pgnjh))(}B>#N763ALzZw3gdiMfB;n6#<3aNyz(9?xAS{ zEhC{UW!WSdZQkt0Y6GdN>Oh*hBhVMD6TGo5mE7|Ri=*?)-h?vpc#b#S)+d1%M^zXP zJMXZk$SatMIvnH(wLQY^hN~04fS*c08}VR-p~uIs&G2UAye3$ic{HyeTQW1-PmwK8 zcegDEzt`CJ$Ck7}FuNx>q4cYJJzg!FsO4?|j=h?UtW^q(v$rR|6kR<{=pGrRx~0qu z5aDj_@wqFCGhNBs?)YK6P5iQmol=nORGUga_qZZd*)5K8|EgM~H);~(n<;+tv!aQQ z-UkzOrEPw5Z*@Y8;uCyD`+d0U%stLH`bhW%+-UGztB zws4G?OewQXxM7WahqiTIONxy&4`L!?_m_KA%8$E`3eALSZ5UfGJ%dwD2G%PQO%vO> zMSSQaIec*SK&e-=c@ALiqJbU4Q7n82k0W>n@x&Iwhvry+%*ra0X3{!WZH#XJTUL9Z`HA ze72?5&dGcU0L6xda+ElQZq9RlfGHX!0zuEq`Rp<$u1%#`PU;D7R42a+=)oW zPtL2ot5=)SOxo{Xyw%#~3Jqz7HGB9oOz?4;6)gs#_-+zdx4WnJ!Le|0co3(|pp_!XC6g)1VT)lwWdJn(K=|=@HE(%VD!?vU8#oZT@R?0 zrrCDrUD`0-pcD-imhe}HknC!dp3iy3sQfQP^FrUyb>uW{|-k9 z*`;C)0(If|Qy`t;4==Tij#3Y|#Hx1%r7RYWm!;eQH+u#(*jvn!g0Lyu=)#wQ&hF|c zi&Kcuz3j8u^%o1<%+VmLiTK%~BJt#Ud|_=LqF!Y7Xy)8qMBWXuck8!;zdO(KH~T%# zoOf%7+83HK&nqlJwVCi5JS+bojA2Q1Mc7J^V#7|*6>8;#P`wprZ{Qo~b@ zJCfoYHPx)iNW7bV7dSj7J?8zBAPHq&YXY{FXt93kUvqp$t7U(UTWv%!qkHe4916N-A=g5zP>VwgY#ezZutd@L=Znc7Fg_C}2pX**8TLO(a8ZHd}QZ*(37| z3XV^BrQ7|1LkYP)UGsZB87BHq~c>p)8592Vqusd1#hvC2DqD|N8G(w3#`${eys<+_K5K%^_vYR2(7>M6P+1noZ|b zSVA23pm!F`=LDvu%oo#w3GURSPyZebduZjv*3DR!;S2vS9Iv5$#(@p5C_9rJ%tqT! zwp6yj^3C^M#7HNO;XRLlz{`3?zNF}Q2#I}NJL7wN-L z^U8+g7#=(c)x2&INlxoJ$3~djB#!KVJJ0H3TI2K4(cOsb4n|N5Bn`SPAQ}C1@~^$E z^(8MxxLJ2YsFXycg&?jtmw+EiyTr;)zR*rqXQ;Y6hqYRFsWW`}lRzD~VPX-#dvf=j z;MW4ok)#16j7iP|cIJ|MZLbJb%AmQE`?Zu!vPu0n5qUTsFoSF;W&r5mQ@ z+qJ|At69O14g^cm_w@A_wjA>>V0YpT&{%Dz0gBqE`2NyrL%)7nu=koLnP=~yN!ZA32r`&g1#9N4a89NV!Q&zS_)WO6&wbv4rJ|af|3a{k9ip z`5Eum9Rf3o5Edoyy!Ptgov z-MbYOiOudtH z)>R@%-r2RvVRCm$kcI2{+DYv*kI;ygN|dTvCN>#SOXH_fr-ts2`YSu^VT%=C@4yc@ zLe-@G+{rw@Rw+=`HmZX)vgP?Cw(Sl}FWt%AQni07w%F_uDUo?fCQ*3T+~fHP#kqKg zU6=n)J7*uwc9zC*-Pw+IrsbHHF_t(-2Q?W~y$i8D-r8wGJ34|W9qp<#LQ(HQ&pNXh zTV`a&TZoycghj1ZLJ%2~;Iv*+qF$0lrnCqRBJq}p?v1UP?V0WTF|%j>S^wmo-~HWt zpZojV-$_oM=leYQ$x4NxbX|V>z{}B|uXd50r^WEX`=}i3^7X9?_iJfuOa?Ug<1rc) zQ$eQXmWq$K=X??La;~?N?d6e6y~M*)8+QY&&-#2#zMNci=@k>?@T3*d!+q*XVCOci zMLWLhx8*=-W0t5;90l!Z8#O}1A$g?_)V(^33NrP@#rm>k!x|;KBy-Ja^ zVB|L8UB&l(mqP4`T5tT&lQRgFB$o38eGis&p!Q2TN6Y$qhL$`L2t;p@@S+hsD)koJbsRU4XBsYV88vgv zeG?DBI!`aneW3%6!Gi?f&fTYG{bQFWc&mnkG*& zS|PnsYViq;@8e*;Yo)j7vaL7%ZAaF|jk;kF4c$7(uIUUM_#ybw=iOKss*!2KLXWy+ z;M?faYjqxkobX{3gd9^iS|4n3Pshfw;5aE}!~7X9S%lF{Vk=3;!Wm}i+L(t#Tc@+& z?CmN2xQ_{GXt;jEWlTPi!wqe+K6l7fz3qvcY__tA=}=A0EVN5y!dw0rF*Msw%Aw|w;WkOM%Pd`{yF3H+O$&|XAg_YbMKI3Nu!(D%14i}LlMPy_ z?CI>ldnq%qlTh8swT1>a6SD|hAn6_~K@ge5$dJv)sb)tYN&e4^3eFqkbXK|coJH2^ z-GTL3E;%-X{~IzIC3OjojuH?jLXBNMbirBY-CnLc`uCCuXG&9ktOUmCWn&7=lZuz% zRO`v~Q~eLGhZtCy7o!WT(4iY!$3cx6=*6jU$DCX`DDYRxcN7ea_<(`FVI7LUda8{- zQ*vwG|9O|`*}UuXQp3Qi-T`Xg-?q(MTNx>}uJ?jA$d@0C<~PYgz`WW%;LFDjiJRu+ zC3fS`zU;%${4YC|0mmY7KVNXihQChB;FZ^TZAwJ5C$!y&6Zn+7*W$u*7N}NHyt0#- zeniMB%^B-jscku9VxHcywO+~wKPMq$ec1U`Tc%WWG`Llw@9M;Fx+q@eAmkO>7M0Gz zxy_ew4Pj|E**n?VtA z;ZjRImeml831k|!YZJWJ5Z*c19Y4x%$l$W7i?!5)93%HtkI0gP97IKlCH$n^+%1kn z@*_oW?KEluQgg>gV=Z3u05{zyFN&WR5ZlcN|MIAqSJVzfx4^INOFDcro|Dvve6iaQnCFDjcK|F2?}9}TGU&j-U_Qv&Elpx zKhG$>xv=mL9yWo2)2C`%VFR!>wntX#6MG3f`yZd+8$KIuaP7nuc3H>xN&x1XDjZ~u z|FkRnNTrzJ#`5vZ?bVqzYt^z_*XRLjXtv(*O4n{{^0F_5rxpWwQZ ztEt2XpVsW>8XG6gNu}!m3Ar;) z052F?+|?X7E1Z-W*H;AebS-ouw@RM2s~gbD$cKx7P)AbfQ6#)Kr-JNi6Fe%b3R-5h zE!(d?RGV`5VeIM)X6JT|{N*D7sq^`!%DAdr9T_hI;qrKS?ArJ0NM3}ie02QK_j#9W u-?F^ff1Lit3G)B)()O2`?%mxZut#8zz#f4;0(%7Z2<#E~KP8a)V&Y$2gtL(Zfng$JCE&9#hp;Q&M{G)O*@Gx+gz8siFx!4L))D_=%Gzn+6-$CQqp_?NHk??4J~?7FsVZr2Vi&`yONyA*b8Q$gwgk-K)hx{4aPs6`G zY#8y`z9ZUyPzvgN!{}U>J?x8m))?@)%8!@FcCg8#7h{rgq?Y^jdTO_zmUe=xDD9Ter<;cL-3g zw1N!gDWBG%GCZ5aLvU*$Vx~&hcg-B~xhfWi>%7?XCW9F|$wnV3zS-Z#G9{Ml=+3B{ za4*|9Yu-RmCUJ;|2{yPutj$}s&fK=b#Dt;H^=4EKVw&O2*U>WN)wm6nZ)t9Wv=YwM zjJ7F4t^CeJ`#fhL?X6TIF?R5qJ`+W*QjHHDZ&Kb%`p(9fvyP>bnxkZhZ)taF+aTB4 z2CxWc#lhfX8u86DU9|&b+R!{XkTu7T__RvwSVik4T}SE>=5{hQyzLDByYqz|Z;EUE9$jO;Q|qspTBNRb3mFzKy)rpd zAZ=oQ70XRhSr9cR-L^4pI)pMr#C_$WakYtbWk6^&$Oxes;&Qi?5Ysp)($L%x zISke%9ZPzjR!46PFWc)}B&=3Y^)Qz}$o^X@l_<;Tnyt&$r+*$Kzw&S>t9%*fP(3iT zNBj60AGqGpsjOvQUD=3odj)HXp1YXYhsYSNKoIeRY3T)A9k|t2f(eB%>y|yF;c6pZ zPW_%Pb~w}?$+hyj;gPXaM{g6iCs6{aUC-w_v{TbB6*mVB@~Wy)Ov;w@o>{VYx{!(8k$R%7?j8D&~pf+D-{B)PXs zh?eGC_WF{60vB9SAUsYTZ0M@+P3=61da1^C8$`@;gRg=iaGL9p^4e2_8IzTt)_AZK z6-nJZRNJ}d+aT?uv9oTIK`%EQc`i5N%#Oa~*Ia*VFrtBGw}jBDz4 z5VpYnhhbT*8(e5BxK2WG&~_XEE#`mgfQ_HmUMeYf4B@ zNf$S6_zUhi2h$Dbld`#2V>Q*b-q$xTKjZ97jlymDM|!qcUkEG{k%pV?>x?2?mxY-h zbk8%O$&#?rdryQ7;n)D>$r2G;|9n(Jse{s7K}I%PVj_h@qjn>GV?S(@NM-vUtFd&RS8jCAWy#h2Hd6oZWuDh9KtK7VKW z{7TlrXd40CKcKwO-&%S((*?OugEoX-P|!3l2p!l4Ve^+-LPHmqyKcd?;5s@cLI?z6 z9gm?}t6QzUT!S%^!tGPjJuj9%MW>kTXQ{FLilVdD#1KPGv!yT99I_FlV+CEM1+i)A zMRu_@XoyB~KzxeY~kA;ZE%{c6(bzt@Is&Z)en6Ux16f@8!(PbY1J@s9$4-Tpy{;lqMk;F-|NcJx1>$aAIYEM z$Kt>pIgl-GGMcv)Ya=R~4HfTGq7esp2i0Qv^@b+=1_hFMp+sOOC0Tt~Xzh>*tiVxR zBqED|7r2&Qm|DahEvp_LS;bmTJX!$tJpq}H$&$ZJmU$a;w6yQHX&TJcZG-f5CveoZ z6>*!jq%yvArQbTROepL|igglmd+M@GrLo)~soNn;)&!oVu4N+X=Pgbpj;ObPTXJ!w zr#8Pst>JuoM1&ndjTT#abH+EZZQ3GQxTq^$rxdRZl`(~DdF_ihcmA3~wKPmo4XrkR zVqC>6&rfL%j=U8VVOI`A5q^lb=Q%j$3;LdKrfqqCNz950ov=w)H>8bgnaI$F8j1Ws za7XW&pkh9}t+k5oXEDNYj*lxD*PYcW+mrleT+y$LpI?BeTx$J^UqJrhi*eu)152)H zo(-MGTfbA2h`fkB`qtp=X^uf@{&e^!u%rZ;N2^1rl$6|nxS2dZAG0;N1=SZkYg?&L zVg+Umd8WE6Q*!X+cC*b9kx4r&USjF?2bJMGI$!gp)7v4y0E&%j7iV713y`96}(Z;>nOsj=C^=ncM1S9M0uT z7vF?1`a*cQiFd}9i`*?jt;rOd+!>T>y!3O3F!@pdX_;xNG>f_OS}VMeCdu|Xk)QkraN zAL}_R1UD=v>m=A*9W|0C!SeUVJD(5uXldsX77Tb7-_zf+TMtdv+HCw}r(`m)>HjO> zu2=29*VUN*(LWrq|FggjQfbGf41!Xg+gIVPr_6+A_r~0F>(xlYm-K!M2e*bS`xM8! zMA6;;QHEbmUtGHuhLD$s*#`OBs2}?%JoTW};mNM-W?>sIw42rTSPHELSF!~c{aqfw zn#voeY8`7_%Aq&)t_RV+k4sY1I0!yin2&*XsKK)R@v-?l={qGy-yU028)B3{5t3wR zRiD**kA@IIQiH{EcwUHaAQu#G>}4klks&iMK&J)`($dr&aC*kVpN9B9iPts8AD zuW!d|S+#s_Wf9}~L=JW^9r%>))#ThP@-8Vyg=9?`GqMMX)wgndqterb@0A@axw9xQ z%u>u#3%rF6NXBpar~C!_BRtndxPny&&B&8O@~@+IrPCa7Us)?pn-t(G z683bQ-1xH*VxALD?F)h@Rm;C;JuX`deaXq_Ud~Tj`?L1XpN3l$D|E=}qqw^K?4t5$ zpST)skL1LOkUfaq1nYJT+*#ntF$8~Zn?aEoCjgo(rz*p_L%Xv%X zFRpbwRS}0Ko@3j?Ej4+$(99Af;8W5*Ju<18`gHL~tt@bFpAab@BSLfJpW1xC6 z=v!VY^4;tIb8!B{=UvFDgbRvc`|hR{**f=wjnfOJW@XIJRP0#jYrjs)16n3Cry1X=B?KRofL@uy%Ky1FMj>UJf6{uXJ52*ZjD z8Eq274j!gk3Jur*fiw*>E{#7j_Q8y`v?)hPU(Vdfmk|{@HU`#Z-E4O4HV8}*UOzMY zYS|NeF4I@ z9BD#}BiYg!guZJ~ouV&X#_tS9Moi2UE~OVtcWlNArW9t^k0oC0OwmwFSb)`dv~IpH zUuJ-7mqzl-1{iRA0PasT*{JQ9cZ#68Gzk?g(H};Y9iZrSPEUL(vQl@o=)Z{!>NwH( zC?>zmK)=Bnn(S$|s(&9cwRK-Qq-KYFxkSV#j>j(WhF+|%Mi*WkUKs7V+m&Nt@Ph73 z<(B13zZ_j?99?4|>L;2$l=$6llltOW{seSt zNo6msFHTHY8R}PjN2eO)^-cBo4Hn2Y$m>pzMeU{InD&Zo&|P<(gI>H_!6xS?xxC&8 zx+7ojd}+OUFyXxWx669aL&!7pRxNEu`?(xSin10Mf<8J@gr3qkj)nSuWSxD$W4}}( z#l)(p|B-esPvVOvZRsKw{iLOj*R28H3&-=~JvyNYZrbF1CTtfic%Ek4Z;`}A^KC5$*+hG|;a(CuC~_Fn&2Q`P_e*3)g!k@o~{ zpHosxw?SXW#pI9!Q}#B9 zJ0yw-ck$fcnGvUxw`$hNO;(j9cx-_uZjxL1u$ScY3Zi z;5(M+6zl43&~4AK)&)ruvz4$_ShMfTWhaBT2G)a#eJ`j&;`bA&pW=}SEvu7^qvQ{w zgnfqv$;Gxq$vGohjYL=|)fTh(#r4-U?g!L|mcASE`*&o=Io^WAT}9U|_jIp)fa7Gr znwZs{Ge(|@v(eZF-H`ZMNZ=11yJ}XC8{!%MF={?ZF7{t-)SDKQ`EdA97Llorf4(-rU@LX`os5|pLA>| zmhJbbC(&KTgX_HV?t(yj)sv4txwbLTMI3~6$jLz9MZu#&!Zc~8|qWE=(*7MR+ZEE{_?;oPXZ4N6p2XYfg)T_bBIm`zZIGl% z;4*_Y7d!z;p^pK6Ms?*oY$L!EN?M) zX`;oK-r4}54d2mu>s~u40OW-{ivy~YL#%+T#_PXP_)#YgZNU4Wn+;h+Un0|N3ihl* zJf4novZg)al-HBtfHv{q$SUv@{27kC{9sSgUZ`Mr6eixR*%2s*$O|Dso^nVjkj4G( z?-gd;YAevGpE}U{YJC#9Ws~`+p(}jgJ6@J<7y5^}e-h6>tds|hw=-x=#H*_O`zzEN z-@IPz7(a>?y+(O|RH_Q+Jm+ZLFfggH_86DVR5bLT3CV61D=IF?qEUp-!0*paFMK*E zIQLZ%yvEfDtp<2Dtz+{`W>72Hl-o!n?z0vvhGUlV#6m_CAlF#s8-4Dbk3#+rD0=UkN zG(}TVM7IFEhTdu$kxQHE9W$pV26=X++6P10AHnNMR9mWBRId!REF@273XR#{GBOQt zNc*IuQ0h{)mvAYc&$gKjz=IW4soivA42GN?SUbAVlBMx*vA@4@(AnOS!x12|rZKU& zvQh!Mq5s-yTgU_Z0Q9>j6~fTgTgdNzBv4tp4T>&$b^k0(E^wkN;(Kj-oi`>X338#- zrg^Z%+Cxud(vv_PCwrx5BgOp|%s}S2HsSn)QkgOA!jY~xe)FTClZVsu8_6;zBbc9Q z+e>wJrWc-_(sl&=WrRsu5xwz|2$7#IQdEbDAnjx!rEeup-zvWu3z?qRVVQ_>9tQ=- zZG$$%TSeD@B<$62IB<$vmrc3;NAbU5(C_}^kBC55URlj|xOHOO{3iz%f$4~m$lgvJ z8;8=v{o)H4V-xHv42E;g`8FDuU$!tol^5KG9HcZ3nN%P*FKKMvnPwg%^vuoV=L{%yX%pUxg8uOkpc@_I>JAs& zFe!R|uX=#(fG8-LvqyGY6UeA?n`Nr?$B_PJp$}kL+{vo;>1HYXtoG2$)@z>1Z*RY{ zCJg%~_BzrO%*}&ybs5+eJ-N+Mw{J>=I+QvwxPG+gOJB8llt&)8xv|=2B_f%Z-Zm}k zt1vy4z;`!%XVc&QfJUBMnf}#dSIrvWB@36Uw7{74UQFHVo(KIW2a(eObbL1pk>-|} zB-_i%q)`$}!RwQ%_6ACu3vp!0*C^#S-<6(mLC0BA&^%h8`&-52OL?CAEvY(6e@BDc zrmizFsZ$J(Mb`Y-bMEU;C&{Sx zgYx(0y&6FBMEqt51_JTaApkO$qX!FH_ZxcW3SfHrvhtdCd)xXhNg(*un zM>MdQT~6BJ^luowPZU1-v3GXlUEHkS`q1;0$N0Rd+^U*Po@_;;zF&DiI? zUBqKwU?MKBl*@ElIrWB9(0~`#hg8S-{d3S=J`P6)B7HA@)ePG|HON!fbBaVG(n{L! z@c5|NO;T~7w=Ptmhap6O=vw*`Bb9n{yY>^qjhJAQ_% zGpu|3q+-alpesFN2*1MBMm4Pn=BAUtSJm8IXiMe|f&)TicR8oY*&D5BO6LGR90D_` zpN{e^8ixdwQ}8*=NNjq1rajKKaWmfn3>QB>8$h1Z-8xd#tK0 z%5on)!t?0vaTSBomUyKBZ@K8X^5nBncve>b!M!)u7%g z5O*0c*caDMJv--{XN-s|wz$_}6^WjZFhX6Wfg4cY^@gQ9_Hr^e;AH06djrVTulC=r zJJdJIFKnIU0$!n~BCi_Tscau^5NDdpm$(QTy45b^ltSFgu(C%m>{crt(yw?sn=40#!ZRl>aXuUl4sQwDxDL|{ZIl`xDI&*>cs9GH!#9~4$Q3`>8vT;Nd zC)lbQyLo^7*t1pRW65vlcqa5kxNF!XfUSjs#3HIQ)t2r*)3fd;eBkQXHcB%s+jyj% z?3tu)<(l-dRLtlj`*#$jeDjws`rkU6eq-L5^f&Yb#wV6UFr(zYN#|aNeC^zX%BfPJ zxo8Ra-}PUA(PBYA*>`HiNeNi@rXFP)Ec-6JO=211#`9-ea}%lp^40*y%M*uFD~=57 z{>Y(O;2$XdUw*MGPr3xKtRWPJ?3Nm`?g7$hXzs0l$0)s?@w$)qx4hB}TVJh;XFGrh z>A?uux%=cEgW6urLwsvN7CSMtKgnURCtCE`u?rHs?#EYx+fhQ{-ZemZd>Yg&g7BvQu zk%*{5l&e~PeYf{K2CieuL&pSH+q}IE%8m=(Ptecttf^})tZ##HwZTDTVUKjGW=S?A zu^f33_`t{D^3qzC2hO)H2VyGPK1B9Q_*O7TR9-WZm>`SE_IJa+9-ZpQ1`VEZe8omcGdt!ic6+ zJ9L!+?^;~o+v6&vg1BTwMvl^nKEq6PxK2ZMp0K@QDtVw?SA99jtQK!Lloq-evt_-= znbTI2daxBOr5u4a-uk@!oo$Sz;*ODynjuo~YV=C_Zz!w~jb}Vu*0X8Rabz-E# z4=1+l#wcrqt<(*FPuvD>VzpFaU^;|a;U$$it=2v8GffXC1|MeJ9dy5NKg0e!L5Dxt zkDT!?&8-S%PAwL|ajP@uwn6tNaLjjxT&uh1+iW7c1VzdEe@wUza>jhGsB!J>VYwar zA2NKvUgQG6!$|Ock%Eh zxt8kniG2NlbuT9yJ`Lsmi=J^f>3xJDrY^i5(%u;F5cuA5$4_)Gs2z0>E7=4ud4ZGPz8K2a!@5k%bl4fK&dvcW zg`?BkAYWIW#4wwCC`30c+f`9p4px2`8nj$xULR@iIB&B&nZLT`i~2@GBIksh zh5W!9qZ8)Q@m{o4{>6i?-8nrMdk(97ba=r#|pydy_x2T z{z7wNJHD+^7AXj88!;z{*w}BNGpT`u7xFRFvV-|gKX&XrVEQgDR0?kk&QjyT4a~`O zvkeQ|Pfk*tB55<&whnAe{5yZ$7e98(f6^Ru?%k+}a+gOavc$Y~Q!-jSXB@z~+*#XK znYCYFj6O_n@|tQ|>cJ8%6B-!V!C~!S|78Il8`wC&rlF2m<<*7;H68SI!6!PV)dc7% z8VhQu4o1Bm@z`^0Ntj@`y)_w~iVj&EHsU(6;(SU12swFfD&;!GPiJ)7q2Vt%N&fL? z<^)%xEc&j@^R->@waR@e(f~bwJmfK(;|!O!2|k5C$bN(Bbj!_&rBQOP@> z!cXGBL8!iVgMd&bFw=0k0vpr8Uw&@;coT+R{(7%moG_3|fq*e~%t$Uwn8O}Fsv>5QpN z7r34hzn>BLV>ivobD|;unu{_3>IJnN>u{K8dzHsF=Lh8`E zUFN>eavU>^*AnYdb@-m6jre**J`u(Y3uuQuwi;Tu><$knLMwDW<{C(DN)4mkZRyDcX3c|t~PJ@C5|F=JfnH?L*mKbdfMo_JmImr)dXNdEX?xq=IX z@N8420*AL$;s)#%r6J5)5qw?n@=VD}KCl=}>!Qf%ydINLwKJ%@uMw|g0EfJ ziV8_9i-F~~I-{&Lovvb&G)gabn+cKvN|P^LW`0jHUsPW7Hd@>=IgwVS*N)NB4&lOT zqJ*pVMPEEOYH1CPS__5s8Bg(B#R`^a62^X`Q4k^`VO4cVY5bs8HIe{T-3&#o9y-|G zXi{t{xR9)`ZBZ>rr9)hgQ7pN5@}jVr4@31 z){5W@2jk#Tc@O zw~o5)MON0XqWZjxeBi-x|OtASBycnx0mIw>eEdTh4xd`Y~7^`mwXrb-BoeAOd_zGKVr&GFU5y+j`T-8Li;G z?+^0Ur~VpC{u-EDZ0YFwJ~sLq$JjI5yQghw0cQYSCc=`f%b{6e7(@rpGiu>yhW_=} zue{9v(ZMfSc&ef2`+`rtK;+Eo+I8s|lojR&pIU%ddVb)Lu2y!`#1*?HvvtUo+BXbC z-@jX2A1~C=Q%3j>8F9FU9Hi+ywr7P1X>(oX<(lv?d95oQ^!DX_c>~w<-%nEbrZZ0` z&l_b0o9Vw&rsARIM|?Jjku>Vf3b<-%hBF%Hx3W?C}xNG zFlmZmr57Noi^6Pv&-~l-2bA!QEX4fyfIWI*yvHgZR&==6^O=Z}YRJwZ5^AW7k#zx# zzrKqR&fiftu3~i4-(EFPNovtb^*o-qW@{I}SX$)Mp187r)x6Ri7N%0kbY5Lt4c7N+ zza|tLI)^I@19Bi5k>2FIzU5zI?=N{iclsLE&iZ~IRrk9aN{w|YTI_LWR}1&9eBM}k z4UhR4R`Eoezpkgc6ndOxzNZQ!cI)!`Kf z%~-A0$+i5KQc2iiL)U85V?>EYP9nQ6XKN5E6JW*c8p+Uobk{0YQaIF)0AFP_c^6+S z=+ShF5>07bS{+)>p^dHWFASxfnJv#Tj~s8m)(_4YHk}qKt;Ptgb-d$j8do^;Z@WWp ztuC@tXmzRC!v=i{nek%(>6qeHw^mh@R7AeZNOdXN_gRsKT7JAkYAAJxkoSlDsDXh% zYsgq_0@d!JT@W|~ccMW0HRkdAVvRbt`yBU!U=zQ%`c+Z^se37thEg}>v)a&#N4UXZ zb$?Z_Z_u?{ z64#QX>(UKVE(1OqUjipM-HKMeclBnbhMr5zjf*`BCUr)hair*itgV}q6}pWrHGU3z zZ(S2gZG&r14cDW4j2r81eJmEYMkos+{}uaT<;zP0GgT*ZH(0=SCZwWLzK}9T&`~#Wek#38&ls+k^dxxb3<{VC znI$3h;{|qZBkp$8^_aiv1;Vd&%`2d$;vp4Aa>&A*3~#WP+;oB)T&e?M>skS4Emma7 zmeC^i%ab?a;|5&g@F$p|3y)nY3SmM@DFXxaEpTY(r(yG^0#klyM-F_Y|8+T;R8Q26 z9=5zD4*DsE`cGz+6J>Q={r@<-+mIp#XC zLf|NhTji!=61c0L2S(TSbDjq2e&Fh}ULjsA%EM zQ#X(JgA_9BMiSGWmAeZ_LVd9^Xyv4E=;zgSS3Rc8ogxKw!h z&W=BosAwPBtcx$z34E805b#UZ9CBGpBf{&i7^`ENj=Ot;B{FK^`m9;ot1S^9q0A&o zX=Rl!+UGZmMu*0f4V+stX2?2L7YipQ)9@AxLvj4rGV7Vebvc5+jW;yDHm2arI+k$h zNN3r_6r}_Yw6f%u7F$8l&=PBszF1a%KRih_r*m>hh?;Qj20!Frrgag#x`4Ve@xGnR zZez&N!Et=%cO5owcNPGfD@R-M=56t(x;4P&s&_ zcSva%X(d%`Vhz=KrzHkxz9=3Y9bQc$1Qss!_uKQn|l9eOeaO4w^9(}L$q z;xc`-XB4&wEPmdFRL{=~79WLA13MiaLAYRF2QI8{{>-|$$+&WZr+307ARsf&Hga_m zbhX#{*BSPkwn_Tq?ME~{zj#g&uI-NxOIEyvvf`m;5M>6-2QIzp$rH5=PN*xSI|HAV zTtxO=g~ZLI35U=&#qr;-bkjP~t~NPouH_A14HdI8ySOY2-8oW%=9mzG6Cg9J<&OXq z|Dm|Vt9YQH;zG$yxyb(9gdF>vJ0c)taOTAqqg!S_9pV@@H>`U0j8b{~X%Wui0DcJF zW#%y)ICgo0vu;PzinQPVwBoq-foP*11-A(wX#gUf8=PBj>4MiW&FB4aNkfW^P0AvU ztYJ_N$2iP=PvAY77rw=N(ao1)tH4JxVMS53Qiv7HXMGTtSz)kmqAl3qXt4!R$+;EL z=GP3d(;EYJYd2;i(gpk;ijml=<0Y^Gu$1$1Q`NV1ao(aX#JD-R%zR0yQ+ayc_{PO) z8d?dNanq13sO1!tj02YWyUWK^1v;5-i9_@pa6Jc=n0$Z`))4qWA82R=K-{gJuUlPt zT}~(PB79H#HPMciVoS`=T={O=AYkhppzhabR{;?ur3QP5gqJoZZDWQT9}!NlmA&~T16 zreIBC_%SVpAHS6Lbpq;j6DC#-G{#$S!AssV4mlUAtI)=c=J1;#G+$cqSiuS&Ndk8L zI6E(n+}cRa81k&d7wIl6%8ZR^nYyVykK-d@ncNYpYNZ|phO}17zLwDb8tqi$MsS*Y z#u#1sVTdSnqHwJ5V?J6D+3n=aftyqzeWL)+s~J}F@{-I9x4)@;Ep6bFVX(4r67(9dB`A-%s-L)L>N=x~BZ)#-tKx z|KfYU>^MT};@5lSzK#EoYH4{@2XIEFzB4ONjRr5H z_n44roO*`D;ZagOrAt3rqkpd5#x(P8dywAInpIEkyrDw7E;?Zx2j1vhdh`>X9Cw;g z3ra)X)%Wt86g9?aCL+0TotO;UNHob=Om?aANX@>snRMT1&3Q}5v6A0#nB+09*1Et$ z5yCau+a{yKP431JU?WXa>PTGIwO5=lGT6w+!YMFd$L9DUN?vD|mi=HDQCk7Gq zp{ISzlzi-js3v}cDmA4eZhcK{er56e8vurHnVQMA*R%fyRQl0OAvEWCDUbn~>o{3C zo-f8KIR!NGl0tpj0g;uKpEVBqA+hMJ9FzHkV&6um7z`Y6=ed(LF9N zWgF?xk5!IKRc2j#F-WiJN%R{mihOM8Vv@mSx0jowb8}0`vXk4O0V@kVr$8MakD<9~ z^yf<%zSyRY`Io&CRdmyO%B&p8!H_k#ii5ZtA<@N_`mG1Ow{%VvbU5`7qyai`JT+># z)VgvkCr87)@75!5TqWlz+PexLRDYCjJu*GW!;<+sUUkYVPAXMrhAg&l5s{N5IIxdg zSqxF_rN!n0M>v4E*v#pM)bIl~@6d0pAp>CQ88=DVQ(yd{U0=I{Hi??vC4nSo%k0%uhg88_GiXy-*oTSFs;o~;IvNw%)*XiDiq zwZTIRzuca_>Oq%Sni(>5f^=!FX);6Nc0$x2yNXX-Yu7a3V_gH=)>6jk4aZK!TbrnZ zr3fiv{HBX5G*EG*vfq@iL)qVpNgPCAzaERnq8c?c(-pru?WtbvU+pz1quLSHL^Fny zi&hlOl6jJJ_Rwd^R~9*2ww~^HiD#sC2uSuI-B(b$l-3B>0i&U)jS5s5@*KVvXGiFt zXcRk}iL#s8B#5*ua)ImzK0rw^fM|Ex)H?GXW2Nkm=d=F?BV1g%jUV_*21mweuuU0?=B(yaK+pML{Fp{2NIS zO8jwy$AG40=A4?JyQfN{1<~!x(ZGH4qMn|{@gR=Kf1It@G!MP7QX+MGxLTKYM2cK0 zz@QQ)i4&UoNFS{Rq=T+w2K`ion1o&$dHz%N8p-Eg;0~~_ZL~Pw#M(vJ4wY^+7E&i_ ztt)c1v`1>9d9f_-;Hnv3i(Kf#seC+dsHj9@I%c<`TLX8Gd9Awce>#7qCy&N3MVPceD5L7X_y>l%=w`11bV8jP?< z!H`77s>iLaQ=BiE$P)(~u640{SFk+!FM*wNOOei-S3m}xxuhD-hy`(eekCdYyQ}g9 zr>Pk$ys?)FB+vg#-~uV%04th$r)I~$aTHS4*%u~UGAE8e?`oc%b~A1z3`~=w*1p*- z^=-krB4gd|gMJ2fP)LED0vA9do1>)kyp(J{<2jgSt_T?DShPXW#OD|Q3jx3|2+}Pa zVb4Ya82-3x6ZqvC)<*9(XtsSi7|{H;LCX{tQ;ORLIRJhZy=~BXAaEvNQ*Ayvxdd=T zT{nz*rSJdU+pd3oKi@uSO72L*^9WuOR+IJe<7N7{@;F4i)mA~=CZ*V_NnCbLp1*~Y z#}N+7^JfT;8*t}-Qtd{l^ks&>lOIfpWmC_ literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/02-user-management.png b/docs/admin-adfahi-research/screenshots/02-user-management.png new file mode 100644 index 0000000000000000000000000000000000000000..77ed862ebccae412054c955de087a87f1ed8a9ab GIT binary patch literal 24712 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 DT$GA5 literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/04-group-management.png b/docs/admin-adfahi-research/screenshots/04-group-management.png new file mode 100644 index 0000000000000000000000000000000000000000..39f5997dbc144ee3bd37540e82a92600fd8c3117 GIT binary patch literal 26016 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 TslcWJn+j|y@J@jb-cJ4#(qzL2 literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/05-group-members.png b/docs/admin-adfahi-research/screenshots/05-group-members.png new file mode 100644 index 0000000000000000000000000000000000000000..79bbc9fdfc1ca781c58fb6ef1ec73eaa91a11b7c GIT binary patch literal 16533 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 qyNdFE^!YRWDSUF3 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 +{`Xwa{il$HP;kO0v1!N+` zg>hKB#rZ|&Cc3>+<7|w5-LT`ygp*o@X2Rst<}v*x)qW4bAdMi`-imgHB*fJW->uBN zqP>E0Jb9|5ucR;=PXQJ=XE8lRuM}AuE8(HvqdFfGp7ByWl2U6yogzoS5XPCnfl{bM}x*Y@qdib>AO=#5K~&9_R!YmzFf0;c2(| z`W?nJHs201=LsMuXZfeOgNI&Elhy$qYag0>tOJNCTS4Fclwa?w7i=vpCUOaYUhmgMFCkD`X{v6f5 zhl?PS^a$!vfyt4AR)E-9-?Y2@#}>p26bV29Nt{3gVN# zPe%pNsLG~4*TeMF(p&W!l*`Hpllc$)oeZppb2T)lCp%lj1^9gS;DGR1TUnq0Hpf?7 znnJDvF4U`zgNaM2LfN4tMxQ_}(EjQdeSE<~DUK=9R!KoCsrAePgJa=7Fbm`y7z@lq z?H{f2#Nj%02WLNc22H+;C$BnfDud3pe1Z*po4#@`;~+ACx3N?64%{xcTga~T7W3{D zdFQVd==yQqC}v>sMH@>0D{Td33wO;J8#C+Ntow|78WeJpkFLPmm^Iw9{QRF8fG?Uh zhbPfBU_erO1L){Q3Wn(`+3m(29UNW(Yd-(zGF^NcN2_$XHGdX+p;o{-5bblupES|-of4iwL zHuZ~|K@h*e@4F*TTX~27lN$L4#SaFwG7k@B?tqyxUP|+Kio*<=`p{I36*nZXS{mH} z(|JpMn~zXM!IA(FOe?;L#C^urP=yc~EaZLnM zm)$_%j%}2iWnv}s=t=|_T*9!R`*+TTY1*_b=-TCU$1R2;n1Tha&o~4!=0T)2H~GN0 zW3416{>X5g8Dbp}BRald+RE>igu_T_@r@W}kB^p?j&@&3{M_i==c?Xn`l#&I)~g^h zSl9mOMjjaq+=tPMUwNSR9RTo9ZOZObAY+-+0tn^GGzt!zY0-X5b{aCbJ^o^3c2T`7 z0)!5NSkuD|ydd_ZeN}qAcBW^h-*;9$?SyBH!e``CQGDiyV#sJ3GAq9rnRzjB=;Uw* z$;V)Ccn4> zURejM$ZFQE1M+PT;g1%|`nfW-4oGCiod~!pO*Vyf6O;D^pT5O9MG^8v(_G)NK{2FU z1x@y>rU{vlaYwf-FbD`#Dq4xcSTy+LaG!Z*d$2!xlTy`M;Bc&XD#39bfLu;)FA|wv zfjE;uX~}+!B|B;O5V84Ij42$HS9EFQIKQ`HyLL=FLFNKvYbr=|BIT{}p3|8^e#XvI zgmzA%Cyw9neBNb3a~*I^j9&{%nX^slTvEhmKB#>fE)|Wx4egknx!Tqf(VJv%BpERo z9-Ha?#w0f279EBs%1W&}@V6OtVvVET6!X1mDM&J4eRW4mm@0w8+X> z>f~Dn*C(ZYuD7|j>tjkdd=Ct>jM|~=>qNS)si1ASh9A|3HT2tdXusqPtV{U;poL;RaUe4490jV=?h-d z?K639T~uDf{+pxA*&*I@RbtJSTS-#gSC2pLtr`mz*xmh#=tfc!?Y#DjKKyr2XNGcn zBc$a$EfcIs2ZLP7L!enwD+E!DdgvWF!$Yc5X6Mk@H|A1xpz{sAVaU_8IxU^B`AWB} z_^LWWu0zG-1)PNKAa9tOKH<4?hF;0jOFJuoYR5zu7!Y#3bZ4^J?u}}8^12h;Amrt? zw^K!Qopk{8=MESNNDvdO*ED?sSaE7sN1i-gM75Fk-=m>#(fTLnF(#Rw+|#`1nWfZ@ zD`bt%dyUP5&;g0I56f#((BQFPc-_4U3?`;JTblWb^EX?TmN6?qSW&aMXrMT^bm z*!I^??~PYPqMe_;MbO*uijlTfrRt;KSg?Bu!z2AOv6t6sDpNx6QvBbu^;y4iRMWTO zSGEo~?7!fwDMt0IIZxQ}V=~3uHU8u2l5Zk34s`QdCKhOvaDxUk>I{!xU_3JMH?cJ1 z-yC!~mkB=>)9euzF6kRK`SP|E4Y8lf9n)+n)X*I(ldYzUzY421_|D)^a_+{_vaxck z0gbeOHddtjDqUVtDnaGDu%6J|J&Kz?uz7wQ!6swYuq&?iQ^jQUhE_MVe8VC zX{FD@;Z~JpmEcaM>tXcu9NO(8O*G15ak(h8u1>ApR+<~q%pXvE+1>&VE$pgBNxip| zP^j(0uDkaLPKIuH%(aqGbMdvBK=IC`G)w_gWCRL^(2cYsxORcTN&&fJ6$Y{K$g=9O z?`*zZl(tKf`9F1GEm-PQzw`sOke$xQGz|2E4FYarUZeRFtZ|Mk?51*7XxfAGNxPn# zZ-6)F|3qi-+KYqt*6EjLp@AY~1eYy0*UsrWP>Yf_w~j=fyRpBkQdqS;x&q&nzYZvU z|KPOCyX%}%nveFtw-!se&j9}$Um2QZzg|cA;A0S;RSpwp9#d3qLO8@QhoqlH-q|@jQGSHW+ zRPr!jTe&T#Wq5$0XzQ*3+-_)o$FW1lti%(?<>$Y356&W&-OVq4Vu8F0_nU@q*U2A& z#mCEzz==whq8o zqhg%}99|ne6B!mmb8i~4f!kS|67Wb}+VUU!Gp5GMUj8{6Hi^2vgSBz-^F(L3K4s(WUo%vYj; z7+lEmhr-A2mUl1{FO(UVs|&S)1NwN{yyU)k)Y^GP{zg+y7kBa$ZB6}mY?lN<>V~i+ zE`sQ#O?i-F^3!G`hc6u+LS^W-ci)HN$Vkh8QWI=r>s3DDWf*>jZRQs#g_51jxJ=z|eNha+#$nd<rbC?6eL*k1SJJfn}`x6tLOlIbUm?RU0lnrU=6rQlk792PWi!lGWnc45td)Ap` z>72DZb4$NOU z&lPm{);b`qEEwX=)Al`A7FD@8#&pSI1_fpJKc}&;hJ!5@=nPt!9vYL5#(24brofhy z+|z^nLF@TU1E}U{xgk?FM}XUvlbZ9KGa{L49&*_7^cmw|*xcZF9bz=tg0&JogN~50 zmAe>S7D3kaw){qx-wx$$!a+lovZs^+0|a`s#x~s2d1A~GQlBl48~mZU{CSJJ8OnQD zW{eW2%c3!6`FR8YU^}ROcIFG~jeURHs?6W3M4$Qkk<5|yjoCf}So`=4r)t>QBs|ZuV7DMQTup4m21IfdoP!&bh0P zN*H!+lbv%I|DSm_Ukpbi?`8YJu0(jUcp8ezf6)aR><63W4u^V2NGoYbS-hxh#o-E) zcYEI-PBc}aII9m&Htz|W?-MSJRoEzxYY<3p0Lb5d%V~VMiP?=?c(`oA3U2t=J7C$~ zFCsFzy#|Byn>b}OUZX^~kL#_nO+cy>pvGM<;^fW9wdbt7^$UKYl|`C1mw`&Hy6yzh zVbt6)rM(4X6?z5myw|zSkBAAW*GWu+wXdO_)xJ*)DRTe|@I{M>X9t`dfy?yD)jJSErijjG_)I-^os9elDt-&vf;eS71%2 zkXcgqtMCZl?t2_QC=lALvJJbpY-oAbs@^0mwEm`JjRVFJEl)*8FCQR6&hi%SAO@ZH8I1!IFSA(9ie%9u zi&0U$IUI^EUsHW`=+h&d?yiN}prD;c%5`)NDWMjH=QI!CQ;Gj&w6^TW@BT5N`N!Hb z8Gc{B^K}2~&L{aO^@_bGdoGV?>9hll)19EO*x2!kBa4Bz-ef8bpdfXoYd^OR3`2){ zb|@&;o@taBOV8l)PC-fgMguYuSc~Hil1*>VjgVdMGuW5u5tf7Yipo%ld2WAeSeX)V za#^qkT;>y*qAuVha;V9!66*CzgKtxnqB1`fk3Zju$j@y2+4!}sX zRhAhwW@s>tiu5{V!ssjLi!KaLV~&bgnx{0}8yCFKMn3hZX0bz_CO4(lUB{f}rg+n< zIX0*gj&FErr_7X%qZpZ%>mUuS%*?K*=HSVh`>~0?f6^}Mkx(b8!u zpiCA_i0k-d-<@J|Ot}MsC+%J&9*rv+3n;E`7L9>GAiMooEZmB6{D8XG;3&8{Xs65q zPS82(2D3e9d#P8|6H`W>S?GMufV0&>8M0{Q`VpDs zkhXf@Tza5eKm7jqn5Wp%ps^Nunf^e>Wa0Fuey>Qq>g4w z6gdT@Pe3!#6n$hHOh-aY$~#ppjXLRmXF4-&Hh$Mblr*8TsbPBi4Qwb;9y7= zjarUdB8#lu2Z=&KkA*c)(Ntqs#2f_~tZ$0W6-1AE;u9l>r*X3h0~$cH!J})(c&yR! z2_CI(Jl(5DqrGgXD`DJ(h)_MB6wf9-coPQ?a1FUMLO5R4dU?2iPuQrkKu8WcQ=%se zjT~~ByvZ%B|5<@kyL(__t+9t&&~_=PtEqlhDdH!bupd{R#opK@n%d?*1^^;W33O)g zQJ$Zs#=<;RMc;jgGUB4wc)57U<~2_%CH5Z61@e9v(xx>rTTeE#U%V$}0EA{wekHqj z7A?xV87R}}*cqN($AP}gZ%A{vuf^F-IGzc5@vJS=w={N}mX-;3sL*Xb-=?cD9K7~~ zb`>e1)DW8*37Rzk?0a-Sku8d^UeaeWf6&=HE0}t1Twgx_GWR3m9}X{6`urW~f3ima z>v~57Y!vD2tpY03R@p-$+Gi;Ntw)``AZO22lB1rpy@9IF=sI0qLNZu=4vXpH_u*z2 z>W)n$IKZMX(H$lV_nQROZ(fn6&=^OrTK)4-?N*pp|Cos5;X7$3<4^v==dXhJ#_(Q}>xPQ>F7MxTrKCHh$~PkieKtePzSuM< zcxHg@kk!NpQpAU2-6m=vNXZ-Cd z{bz-DCWU`R4#jG&iEQpN?r+lFv*}b(*)WNSgXjW1FGLZ3r*-WejrX_F_vrpk;g1Ip z$wzJd^-}grFkkn^GA;U@2Hpd2{o^;fkZZSoA+hTsU)Vf&zGm~JaP2+1zf<@Zc7t}z z(!4`ta^1PUeRqD%_8!>Zzws|CV9@Y*h)efZzr)yj;4OcA@z|N0 zm37**jYqr0D7=iv39G((y8-BuE0BRA6Z%~Ht{wOl*q5S|s8}63S*Y@OGN~YWIq|e` zXk~teJ257bgg^g{54z-5WnPxlMcg{z);tcs%9nYbSJl_T*8$Jpt^=N}1J)|mN|!8# zL@V?4n)l@L9>3R{-z)(Cco_EL^O_LcL`V-I(axbtpL-C~I@b(;$eCs~&#VmgRY9a? z!CV{s!=C$N84OW^KWTKqI>nHEuqTS+AJYf!Ub3n7#ZH8{6Rb2+QvaJj8Id{2G-=H% z?nFq+WW!R$>sJr7tRH-mZ6O?|V%wY!=~#4#<&4@Zw>H_o1nyG%9AX^&-&Fj<=r_gt iC-6@R{3(GyCGe*N{*=I<68KXBe@fv0a|tM{cmEIU*bG?! literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/08-group-messages.png b/docs/admin-adfahi-research/screenshots/08-group-messages.png new file mode 100644 index 0000000000000000000000000000000000000000..40f37fb14ffe30a03bea5866137f54c28cf0fc44 GIT binary patch literal 17394 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�SrQ5(EzHQw*k<@w~pO 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 literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/09-sensitive-words.png b/docs/admin-adfahi-research/screenshots/09-sensitive-words.png new file mode 100644 index 0000000000000000000000000000000000000000..14a3f0f59820115c40bccafa1a3780ec901e9101 GIT binary patch literal 16873 zcmeHtd0f-iwr>=tdK60$5SdyipfVYm=MD%e0WMMj!_G$gdU4;;&%^JjpE3L<;#)WR zemRXVzAAaf$*MUZ?|wWmO9SJF^-4bLZWv66zxY#9W zc=!JI#k*v7OUdr}=#y`Nd)3bzeI}>jdeQA3UCaa=l9JSwk&*$}0=6z5IYCFBOYkx! z7<1NjTE|_kt)D&{KHg=D-kyMkdGaSSp+pSwPVW&9)$o#4flv9%xs~t-?Z<%EXJ;Qz zBo}id+zZ$J)dO{`YC?FviCip%*hj1aa%qaKPwN<@FCWkuOwcm%gDzUgL?2A9@W&C~ z-~t9+R+gX1C8CdjRDosNS=MEp^V`;l<;_#|h9OH3KF_CO^*T$dkD6241hRvX5?qRJ zAdhVSXnl`y-S>lHReePZT;x?ksp_HnCiU8LxNs^qHnTSf>GD+dsnwttSNKscr*E<> zNxoEJB`OLH^NyXWr5X}&#uV$K=m%sWvMWpsaA=L~ypv1N$|3j%W&;iVx*F?bC4eT2 z1WR{3Q7yJ7uI!@2i{<*m4+mZdpkA{GfdS>vnQzv5Mzs%(HDsz)Qb1^2ffJ%Ewk)V2 z5!iCm^5vLy<;z*K;#y|2R%)MaKEAYGqIE_3+Lo7_eQ6nQEHh~EOtoM z(mNtx%lqZ)8j))&&wDkKp&?h&ydo0yum)+g$%a>*}aDZlmsTH1{j?^I!g+q5mvLzm9obRTdOYaaJtF>E+yD5?VdeuH-Rc)+4K| z#+Xml>rzFAPiG+c*E9CUWRz*__19h<%8xgF21zqHbsZ-c9$4w2sd3n!n4DKsX2ODw zDLjo?Yt5%z1|;5<*sFNZ#9J!xrS-#=Ly>u}vTG~!A-cB<8ruwEe}%V7tjR>BG6 zinv(R4T66S)fR?aa3ZpxV!(;w&h8r?11g>*%?e|ZE+YVvvUV%Es{?ld=A?;()|Z&C ziVWrQ(CAF=dP8g8eZj?AAka{WYb%vc4)33;eq?b=LwR7u!7ZQsaQ0C84k_o5jnao_ zfIDKCSIlPq{0qex;rjncRnDTw_Lj7Bg1MlVZi0_k5`u&-tlB|PHryG)R-TyWHQi2C4^)?8l9ZH$+&A)bxT3^CEGC{3T6&8foo)q2~hb; zE)ZzVSLstyFTb8>jY^(7133dR23tFYnro=StC^J`yKbkzfW|nUs%?nXfSHi6A^ky{ z#`#K0IeHR@)jZj((_GRBTHnI0@`qxQ7QqlWiabHPHdLy@9~4(W8?KQ7 z4|d@5M187tGrGaeO&oBKv9jS_V|ki651ZEQe$u3psSPQB*~YX&MZNtZx%>U0PiDE5 z1A{h>XSqfb)y&kk8!;Y~`8MA41m;M9(ecESD8C2P^45Gb4x{7fev)cQK}4^)L;ZgG zMRySf!4G@+S@@V7qK7aTgtGp;DhAWvU#}^_MosO7x8Pfj4fPvN{@TDdj1XSf<+O`} z%Q#Xp>TqL|&WIA?)uwRzreuydu<4bUCkD8~!hA|JVzX@J*=4o^$}Mp%C#CSkpeW1p z%M_1%NGZyJpt$7htKp-)k~BhqhD3XH!~ANABXp%0pK9W_nVps?-NtJ1v)U_V#8PsP{m**JVdUMrY4t zmY*#fSxIII!t-#I+mG>A(&EbmotE|j8#gf^=tbOiJRBD#|2lv6O-WyQp?tb`EnW~I zgxbV6bXb;sq7|Ztms@d#F5a%AcO2E@32bVN0|IPRM~f z>l-eB%h3$BrXXNa6lph}&z6&rL_*acaz^OswQzZPGtB zPU6(pxNW=`0#0<9Y%>SY$@9bvVezr)g_JyYOw8+WOw*C0Av)z{iI)rP9anQxcx&A; z+dkbf%T1p4K6D!&PHjU-VR|RQ+EOq+FhktKo zzvbL-xc@lox6~hc`Pbl@W{{kg>2%BYp?EmY(qSRB&Vp8xNM~n= zrVfMrg+;nsS7l(p^gad0!p8TBP7KQ20|sm%jY;HEMai~SS?rG_nB4myI* zPZ^6fT0eQ``f9z7HzB|KtUnPCvl3CFfTnm6&6Lvo&+4UsF`jkQOE=kWVqzE?w<7QZy zf#4Nzf~Q#+!so_T{sbj>@&RdkTMURc8I9pBvO4^=v-hRfTUynQkXKMBYSnsKpRAlj z0fR2B)og%2w_%L_TQqU$Ho%2I`bh=XbPpiQg-(9ZXT@lYXxrv~tQ+6GVS{a=mCjb} zrtCa+(vM!7vRn<-qAhB83d;lc_wnE@W!NUaKKCD#n#Z>DF?^&G__Hu!+D1rx`<5|e zSL3L`O!@!IRXv61Wd!&Kr*A&}L)!LRC57`m{vP*2u#b&myV>LM`(xOv&fvC)zQ%dV zPcaTL6PXCR?|8ZNpYHrg(%qz#xj=k z<7dmu$;#3F7tKSnLS;iJKe_Pqo}TpQc-uULCb@5rM~&n>-=NojKaKN`vy+CWw# zmpw|2M0eNE9N2liH`GV>O#z^ST+YU+6j{r?X`3jT(s2Z9G{Rp^lNPHPwt0p-Rg!ip z0BJCeUnSBI!+zf4x&1Zmw~r{+E#D{BZ1%(>4{iml-?H8z6ur}s^6IwS21THg9e)<; zKd6u$X`0W|_)VZscmKv}$vLnH>`x$+Sw*wz6n|sv*gHz- zcaonD+F@3DM}mNNAOyXW{NG8H`3{7_carammq_|@hn)R;X5Vx8$D6p;yUCc$rv_0V zQopr+ZqILY?QS~!hitlswR$eoO1nAS>ymJ8Y2WRae_px2f)9fhyXi_D0-x0kQGF@0 zWU+3N_0xG^xUlm?%+uO$l#XJ|pGk=<-Y^k8q$=z3YWLsYe#$kk`MvB7dPU}?={ zW_i=sx}?*js(Qq~4`^6Cza$1gx<&-4E76?$D>p73qqLik38H7oJ%h=Ees`i6w76)w zO6D-%H@G@S1Y?hro+s+}ABurab~Tib9$Re(oQcHZ$mIH(c#N(>yUMzyBgm=r1=)>1 zn&_u8_F9+Pc2@J$Y>T|DqhnYF9~pT6(gxziW}!BLxw&ciIhKm;8gghFtMclx31MUh zr#WHaOv_DVFfZ5CrQR?5&T1{wMs0pjx~d{Yz|3vY1ddmN2a8qT6x|zbj)lHB{apX* znHRvc;w0CujyfvbB;VNEy}>#RG+0<`=3jf~$1b3=WW9r~N1O+q?WpWz+S1y8lJ)$qiKdeoWl ziZIX~?{_$yd;UH%^jr}Mv$0OQ4duthyfavO^-xOc>%5er=`i2*S!u$&i`>8*+}C_C zBz*Qpd>e;&UQ$*gaHe}E7Zb>RBeH%3)zkB9+uMEv+ds5{16mK4Vg+A7b4d8UL)~62 zWICXNXKq_*soz^j_F*M7#jwSIc;h$5=1a3D6*>)lL}Mn!cf^1L>-s(3H6)V*m9TNz zA(qw!R!&_ggcZviRr?Hf+|pYN;D`aV{h`I1w(GBzn6+$8ElZ0VZNdcXy2g7Xf2{<- z_|^Mt##V>{W)7EgANK}Pn*(a^v$!C;7>ABPe~m{bWJjj}E&U5HSwreataz?f&E+O8 zTjsHqKn<@PvT2y?r@7=$O{*mMtJv)hgo|)V!TcHSe>(WDVa7yP$myH*U6w?}`!3=z>RsneGw$kR}!42K@ zh>+;c&!GPShHND^<;~$YF5`pCw9VISgOXxCvc?n7&gaYEp)#^{T04t^ zgUsp{zpNL;3f|zv;SYY!e|~N!_m2nDo2gO{!o9{i+t==z-F+f(U#ZG5p~|POUMili zw{4-C@G`e~zo_(IU$?Eq%@+@)IVcdbG#Yc^R3kobVIlXk)+A=#YvnJZ%+`Lol3rt0 zf*!}HXJ^&2WyyV>SQ>OQ=z3h;(zcpk@XYgCFQf32{nVKu2MB~U?<`>37HM!D5_nB9 zptdOY@gJYnC7NMQnzk3{odIuQjVLDl=zKW$Xz>9S*=qdy$|qlb8_n)#o*0PEepq#U zPhP3FFNRCSK77<4tEVR0K+@WT>%8E|UUN2m^nrUmWXVvTc zEwAbnX#+WN*N;uGCk;$u#FaNF^u;k#z7b*Ihk-#mJ>#a6XI)MP|kkO&sm z^7%&VMsk#+ouQP~SS>zK2|BtU2E5!a26&FO2qm&Cxk^m+2;Pvmfl(O|16C#G?np+S z-EIrny4#8C4?naP5l92~5?T$7lT+A;DUD{%2FkDRDm|z|rD(`y3)=T)NU8K>$33=@ z81OA>xwUI~bRfJy3^=#S865P-sPI$@znL#FsWz@q3mvz9xy)R}SGu3{vWu}3K(~`v z?8J_O?dsJ;%~fA0*A`#s;lOPRjvNk>Kdb;vQmJu1$jZfps>LO$2n?H=O|?0!Nxd5i z=+Wx|=qvMf7q{e{)i7%jey(74)?GZ+ z#+tEaFWR!vr-v|W&)>IO>R^GmVnE9tsJR}=v%?cWZeO$iq~x ze)=&w6GA>#C6Hsb#PRWz0u)2!#g$g%2vDv${nQ6jqo)}GdKK5QfwDwFY$~JCp58>c z!61*aqK#OHZJ|gF)+jn214mRJ`Pr&3E#9#sAU%L`%f~OKIv{H#eJr^NIMoSRDwo`l!yt6~>yx_@sXo>nH zemqMa4oAcsW(3!~sU3d?`l-NoFts~T|M)tO13?{0dl-M2oAgm1(%l}FHjaY#b_iQ9 zaLsK-1$f>JtLxdUz+5Y)&k|KxTwx* zvuo>Ni}?FxxV8$E*2REUpTq)(imniF4$U}~s-dZ&J(`z-xm=t%_iAMW;c-ebs zmk=)J4xMsI%5%L-Y!_gxoDN{nH@LL zje5Xe^7UU=*NcN$uT2-^WRq^g3~dg_nql#-Pqja_hxd;$SaMH#tiGDU8ds1;@eW9X z*7e5jp7WSZqvB1!`JQ!?y!mpx7{C+*zK)WXYhNQ*3~d>gkXnusL&10Pz44$+X!KNm z2VIu!iCNh8qq6YeN-$O5+8#6fil@}Z#ZwAF30hWKHXnIl?xyw~*73|JQD;Gyg9;25 zCG)6)Xt$wIPcm<-3GPJY7lQS)pw4BRdExG`c$eoI9nL*{sR4}`D0LmaKr`{7aDBjpHaCbuAhjW5iI`=Gn90 zF*H=#%k5Fo>QVLzvSxL?Y$U_H%mh8fEB@Jn{lpvQ+7?y6I7>KgRZ9-N*pSd%M)&UX znTpORg;Y8e+AmO%9TZUw*-1EG-ZCo&_}9c=N%O7otaQ1}t24Rzc5>4RA#Wu*p4yrH zelXJdF7$(s|M;A@Q*(6piGxE?Oc1ym3~DGe8n~J1+5` z1_iEJd5?R|tK*sK1e7^?@ghly6GbuCYl{reRIfEH9AJgoukhI8DvQ!~u9Wt*=yNHQ z>RzUne+>~#OX%RM+8*NtXdmO$Jr1B|`#mVr%_bOJBP5fOl00YsHfF{H)y-wARK$VR z2M9s%yyEl=p8*alw#cr2ZnZO?{q0qmUS_T8`4e{Ca1iCiEf!9FT!<+dZRDODMr#t^}kb-AagV$bIzdl$Veq*1Sm5{ti|?u+li3{s7!@NGQ6s-K&>G1x8h7m+`U$u(!NL;5Y1JGKUu71Q@GiX3h~AN!YA`kxf=bs0lD z5nJt!+tR5}vpa`X5jM(wRzut_pC%1KaaU~e)^=!^smJq^k7xy2PWI3z9eqv}K$$Ke z9_F*kD;@>Ur3WTT_B7@`aIe9T(9+((eqSOXoB=higc+`XnbFRbM`z_t-e={rpuVtLV;)n3qZf-PqEEGE%U|vc8P(E=M~reCTcAw| zMQzK%sTrE!Y7Khi#DwT@SiImR>m$PX`%x6O_##RkfTK0#>UPq#1** zzQ&3Xkc!ko28-N~Ds))IC>M0Q+^t^!%=#6aRJ%=!Ka0A}Kag%QTz_NsG&K~{RLJj& z^UH^Yg?X~`qbJeq;8DvW_Qq+>#)48S93!py-Sr6}%Si*0N{zR$Utx2aT#}+i^U@RV zhFs7*5O!o(@i+m_&2=nLHBWAyF-|EGl?5#2VY2wrN9M7F{FCFyV!FZ*him*<@O%(c z=P0hsJm`7QINpmxel7fbT8$odD;;7+I!bMi%*W~LomKBouaTu$Kj2z&IU((1WVgk| zI5Pks3}|D#eoF%<*{*Epd3?C4lRAS}shl-PlN^I>t}^`tbx0inja!g)danZY$oxY9u%INB*%g`P{qW1iEr?zY+6VFX9-YysFEa`cY zl8p|J6eW7q{nYi50q&{cj@cpU@)NTF03|!aVO?SgitSAETKif66sk<=V_p<;Ga( z#Nd`+X_}xT_mH2&tL9t^vsTtOTZWYN@?ia1BFg4iYq4XJ)`_NxRNo(gRLkz3QG{Ti zYFInT>#T)ia3rI*N3wnCIguaJ-7SQ}yJ-teQohCe^h}PA^^KS5URuDNRI*FB_ZR>; zZn)FWv63bBX9_ctPQIGDv&i{pqJ7xKQ8RieiB3Dcz1+C`7M*YJ8OeH{|7YL+zI@-J zjKkU0M;lERnVN9P<$c*IF48P{-z5Va|A3UjN78^{+g+8o1|6inC(`bUjM}Xr_KU>(7=NL>#3kb zFjS=|QI_2VhkdwwQ$-_var@-Y9C+kf)JBt#D>^r?lMAPK$4AEt!0}vC0*!(^kgL!U z9-cnD-wEYXoHo|)Cl3ba&A^+XOhz=JOuHo~!9N;vnHi0a^OQCmdPwt?Dygbkhhczi zzC-@8Q%Gyka2^0)6!@z--8ATv%#2+}cdq?@j{@Mp6+Ex=d?qY?{Y#bNt;Q7yLFd{~ z;6O^zc!%wY!0b4W9Ac*D;^?j?{;#$NpeCqvmkkq!|3FG5PnL74_8g&XNL>%ISrFN1 ze=K=%6gmAd^LGs#xVnhS50*rETDV7|m37^Uo$cahFaP|0VfP!e>JWA4(-j%n;FSEc zWi^IYO)KmMTRpn>!x`1qiM~D*-$`G2D1s%g9L3dE2=Xofbu)G3lB*FVmO?b2ePxT7 z5jjh6dqAzE$i3vHo*wJ>)z%U&7P=>=h+5*2H!XjDli*=+6crN7_wYOJ9WDckEW4cE z$TV*pqkFQlqS>%x3q?Zrm%)g8VnEfphQUe(90oCDdR=N(FPNId_y{p;l#%tdm|Jv* zORIhg9~Fh^Hc*|b>b59HOb+#D)C-%uBsXD;#VVrlm=FM9zhe98aPNGa)#8ZnA&Xd% z`M8!%s2!}u*w*$KGrm!{2DQpN71E-x8WJ{;sGFGcgCBOdx-;jb#8|HU4rG8oy*bcy z=m$9jTN7&NNI_;UM#pZ>(J2u4NNs446Vz0%RI}Vrel#F>R2xdda5praO$||(;=g7+ zPFd*?B}P};B_!GcB4pA`A2rK9ORD7)7c|uHGEf_DQlFEy@_0rWolTvb<}WV!ydJh6 zc93^u?HQXaBBIfwi8esM`z6SGrgj}7%LtryqCNlBInrmJLEQNL(bZIjMy$l^O6Ky> zI+)x`zQyRU?WHuUi_!RtBYaUT2n2HJ(9%+5@SXN0>4nc3H(XQHQ(~acv^caY^!c>qp^5@JmyBaR2{2i0wUo z9j9q*!}q?SSdU8#Zd#KZaM~8uhABS4gPiobj3@HjB}Y?86f!|bLryj9PG7`*I!?6d z8O}*g$&8O_gFB#}H9C|$D^0V<)Or$GJV^18{HMRyfKPIt+d%F~qObGc?n|D$wKl0Q zg0=p%J57}(Q&r8%<8~08(T}S(&MWBPuR`T{Zzk+KTvr{2wtR0Tvwf;?K=zYDpl=^u zGJLS9_!va-5k+<^3vrL@R|_T^cSOcjn%8iuFgZ9VA&_q9<-E0m77Z7x-KM3H0B`bJ zt`OGYM691*G|_RiKGZ7+4}%k}h4~DVjB*=ZS*_9_zWBcVh)DQvs)%{p8s_$13fAk-N*E!0f=Cl9LG|Ksz}rAtq&z{l9EOACc7;wE&tfldwo2{C^L}1v$MdMwa=uK zST8IJB>!6)|4rUb>N-x1ke^;RrW$c|UWAG%2W7j9hjZ>GLy z`S;FJzn9}5r~c}?-{bPP|K42(r0uxg&&_5&tW3W#C~?S-d+U(@Yp%p0-&*33KmE>k ze?{WEk9p_2|7%s9w~Fi-R^}fe1i1`J7JvUd?E3S2n*BY}p>f!9D(tJ3uMzvvnBNln zVsWz=tZ}p3z)i&|(xbRS#)^xF0au}pSGHX~-%QD#=dBI1985zc7n!6XR_hq&*+2YP ztyBzngEX{Vsg6a90h7qBYC5gQcG*S@Sof2pB^Ti>4pv@Bb6C^g^hrFoqnK5^DX5>wplE6 z29u=MY)D^@&m!l&iSX!Ckw!YGk$O}mgzk00K+6c#9YV@?mnmNs(gY>UkLn#!2di1_KS!97v%Io2LJ#7 literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/10-files.png b/docs/admin-adfahi-research/screenshots/10-files.png new file mode 100644 index 0000000000000000000000000000000000000000..e0410d9b1401921223a240be8e6a3161e175f41b GIT binary patch literal 25436 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
9ty|F&}@tU|v+4pg^#_ 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 SzTD9f0=9{1;~(M?j;AA|96c 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%0DNI`UQ-gQ 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 L|0M&?ivzy{9Ced$ literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/15-admin-login.png b/docs/admin-adfahi-research/screenshots/15-admin-login.png new file mode 100644 index 0000000000000000000000000000000000000000..703bee6041e804d8c1dcd3c69327a11953ee33a4 GIT binary patch literal 28333 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 literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/16-user-login.png b/docs/admin-adfahi-research/screenshots/16-user-login.png new file mode 100644 index 0000000000000000000000000000000000000000..2d25c57e595dd5f4eb9291e1092a5f382eb22024 GIT binary patch literal 26971 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

W%wYl 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 EUreGAX8-^I literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/17-ip-account-stats.png b/docs/admin-adfahi-research/screenshots/17-ip-account-stats.png new file mode 100644 index 0000000000000000000000000000000000000000..3a1e44e7e2a0dc7fb0b36988e0815cf95281178a GIT binary patch literal 15546 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 V!vlYK;13V{;er1p9#B&Z{U2tjaykG2 literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/18-login-failures.png b/docs/admin-adfahi-research/screenshots/18-login-failures.png new file mode 100644 index 0000000000000000000000000000000000000000..80b47884a2383815e16dd0eddd4bed2547475eae GIT binary patch literal 21417 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 F{}0(~Oe6pR literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/19-private-domain-settings.png b/docs/admin-adfahi-research/screenshots/19-private-domain-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..73af3bfca88309ef0314ac96f1925453a6b6fd02 GIT binary patch literal 20325 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 T82B9nzhmJ42@GuC9RB|R@9m)U literal 0 HcmV?d00001 diff --git a/docs/admin-adfahi-research/screenshots/20-security-policy.png b/docs/admin-adfahi-research/screenshots/20-security-policy.png new file mode 100644 index 0000000000000000000000000000000000000000..4d975a58d58561c8473fb76f3d8e4986cbe262b1 GIT binary patch literal 25710 zcmd?R2UuIjwl*xsaf#y+f&tsK1Y)p53l@kDC)XHkBha-urU(NGU_b~VQw5?W!N)X% zVq;SzNDGH!M5h6Co3ITyZAdCbOy$K;i5k-_wILSFV_dd6s@BIJsegD6Cgx2gm zduGj=Su<pR zIC0{=_fDKWedgo`Dre7~Q#q@wd_n!9<^@%4HD%?G^gq(p(Y^fX<@1^bpBY^G?Bb=% zmktU!bnMu%6UR>|o;aa+>4NfwOaJ3*|0jUL@x%8IFCRXn1$al{&|!r``yBvvIg{@k zJ|w>YzhCdXd+4y7)Ax?cA7A@G{`k;4^7Olhj~#yJ=sSl2^3=NuM?U;m(fHQw$4Adz z`TWapEuiby5l_BX(*75}(ieTluGT3pAOFOpeO#jA@J-4Cc`@e?Dv)2lS0VpCboiZj zkH{bCD#&lYbLic7-Z^ySx95l6Q8@hWhl<7@e|hEBk+Z8SdmMr zZ&jAzr5Bm0?srl@vEK*8KOxO_DJU9yPG`EKSJ${PID}Qmq{fJ((E9x>c$B5LI-5D>|wAL9U@$6LBV=1D!T7F64Yy|%5e9GhnTN)u4}jhX*z(vX@onwz`Xyh{H~CtVMFf9 zGdw?`)K#{)>R%DzjOg_Vr4_E)NV)fWWRP~7QPi1=GdSKcN&$&>RR;s|f?ipO!iG$f z{0f*|NnIwZj>)E_y07#U)ipAQ4RlEtl0Ie-ndI=2qhQ>8m4d39h1i(ty?4GGVHI7y zJ7x3PA4bW)BTs`exi%U^*SlvQJ?(_$SNrCg@xog70dB_ej%8gKgLxG!TdBc;?d4ph zpa>*^yHPt*1Ul)4gge=Z5pmV@lLqd~hHT2nX^dbZbSJ)tH^gL$KAT&nYp14vQP}7= z$g8MAP^)()Vzyr1Ywvu(cXa~3+PT?S-dx^e@bB39Pj4I<0u>&6z*etgKMWQ+-7A#V z+~;Tw>ws{*E)*Vq>D!Dc_u=AWPGI(fU_Bbb_?(`C9i^K+On)EjS?Btf;HpehzoyIQ z;7TO>4v{g1hx|YYnEhhxuI|wsP~NdqeUt3K0t)y}yGIS)Pb<(L(Nd-HEm5QE{&Duu zyZ3$6&*>QlGndw;)6?>^aR@e+U%&X(WRTSBtA)$i1)FAPy6(pML$1?L@N(3UWdGQC z1=<=$C8$x}qC;t$v!avVvlO2A1!|WkH{Tsxb^O6Lxt1L`eu384X~o0q=cqFEH}+&v z>p@rXEXI1Q{}U7u+@uA{_E_58gCr*PNXT0V4aEUM&~-B z!+s;0R5J|y&e0!B4_@~h$*ty$GXQA|;@`$qCU%rt-9ibE#XyKAgv z!|f4m7;mYVs}dXZZY{>wvYU%rh-4UK=a^Tzc#unnHuY4+#)?C`)9#t^7r&n?^uFe|x`TpM2#wxb3n1UOYlb7+Q$&O96> zR}LeMTa!=oqSAOoCo%ikFZ~b_l`}0c`Y=1u7RF%RZ7qa`Loc^QMPYgLIvOh|to87s zdij?BfJnDXgZOkgc5*W^JXxexc$+sbURn*ghwbqoff1w)m-Q@4c8;-PzfaX0s}O=R z#Z*#VTSiKs9&nQ=s3tVw{U>IYikEyIzt(6VmFS}@lyTA34gz8gn#^Ix`130%!jkgvP#j8*D0fc+O5zxjjwzIgJeq=oxLL&mDyyPv!9m0 zBCQDTKZ|H-DHhKqMGI`^Q*o2*xr0I>jByQO?krpOxzGFMc>TiLSKPL&5VJZv4NwF} zx1|k1-9{Lp7Dvgz#XX~V3ar-$&8ktJEvhkA=JvIDQ-!B3vvb;06Mpe6GSW0kS`clz+Fzoq{h)H`#R z!)tt;&%8IdaR0VZiH4QSr(p1^0NL(cRxP%2T*_#+?yXDe^UWH%lj=Gg--AVwCbPYT zpjLmh*a|XMEQIQocdT8?_&gDhdNaszBQ#GAd8I;!BbZApZwveTPZw&Dkb@ zgzfn5zcJ+gkfj;ovwRK4sCbd_W>#4(sxWG?(zM#qz%uFFc2stWz3GKhI;GUwGN%-S z2#D##TKY_}kN2!}UOHF*&bj-OFI>Rp!fYIiQjJjU76TXlLc+!`R8>+=#tgX8$epI? zB*zC%_fL2YIqdhMe12qO`JI2cb~5LJu9Kv;4}`BD+Nb0OQ4 zkpa)qG3;sb$I7jix-JFPS8S?hT0S2&uONM5&T7mq17Sd+XlFGCkDBBqa6i1uBco?^ z6F-W|5-L!=C*J_J@XyVs8$||oT+O0~E$%uV1cTBB7+*WL1-za`cjcHtu9ixl6u+6B zC4R7Rfy?&tb*Ed@ZZ%m(i+P&ayp8C@kB)sM)hY}r^g7a5vmR&M^W-Uxf5okEy&BmX z7>6pFLGi*i`9Up$uKs%?5!YdaFd+=jEitD zN5xjO(zFLOA|J@kzufdefSk>cw&LNKX!2z6qgYY)V`BwXkqT*DNE5H5I&n`cq-Nje z&6#>eupv+UNi|vxy&9e~)oWW~Rmk;)IWhA1x*7EWI;DF;YLq&^Y3rg?J^=shzA)g? z<8P36>uv~vRG60w8%Ptk`G|6NbENPai?1F=CD?)D=cUzd1zIJkxghxh;2*~eYoM*g zY{(14@m|_|{y0lCu7Q^>WBxV3KR(v6@_^~{=lFbu1*E*rqYekP{$G>v8N!|ETmMSl znmY)W9SJSr>xzG1utTG;3LpU)Qp+Qd&2@B>foxvR~en$7CTQLm6 zJg5djSk|7m)-5*^YbU2#`b}89Gcj8W;dOg3KV1Oh`dRDtWd)sm9US3c6G(L?(1vh% zhEPt7L})X5v0dt#3Xf8APwVl~B=pT8!BOlXWe=LjdqWVMULYT&1qC*QF>H@^CCAqp z>Kr*Y2~o7{&W|BE%Y+{Jq@+vY?Ua_Ji{CRVvuxI}=-K>&N0CpoMnM9CSfeI(v-f1z z4~xcjeNy!F#Pga}7L5;=8;C%q;a?UY*L?;mUNdy8hW8q&eRw{>hWH+$O zP396adQ-UFG>9Sb$Qp%AeDmsfCfJL-14PKmNJK(c@UglV&tDY#eHO^fLA!qv!Ale( zq$MPgZC08A7Y`!KPc$K4kz6+B$Gl*M-_?{!9ceY#40S;`EZ{{0ws~{yd ztDHB@(8hX}UT%xHD;ASsmR2%93RGkt@phkKE|aJs2YOeoGQaG;*mZVOPZ~UQuQ%gq z5$HGzM!Qyupc*7a=;N9_G<3O`5IKLD(|z;svgsPXzR;87mhY z>tkk0lextocQhuIxt2guj!35P=IyZJuzt4jFqj!#hGzPR3lTP-h9v$t5xW_d39`Nb z&SO<=c#Zf@@SIAUZ@jJ1ShL(EkIGvVw$Kz-ROc*m3^4hzN< z_WoiWXvJu7MTS~y*oB&MCi?Ic>pO%tFXyh%Tgup19_8TVxHgZ1qOBzFUuf_1SYgD> zAbbNP!^C6Kgn2JxWVO?S=EyJxb{W$J7GG;RXn1*wPB)y2_NXc+(ynb8yT~|2;T@~7 zA}*5M23v}ZC6Gt1I=`5_lX@B(;Zmkyfx9+ynWYG;?y&DQg;Q2XrN>&-d;>P|Rhjn} zo5*i2r+}Yx7L4%Y-Q(8&Dj#%)hG)N7>4-!CmD7nXElj@M&PJirBfr-n%kwK= z^u|wT2}9g0lH&^I#O)=hK>FPc7J{vou=g5vUzo@VpC(W01_4#=?L`_u4Y|hkMnq3y z;USgZkh(x5mY<&%nrYURxDRNzGb%*ZZ(5kPs&DR%-eQdL`(TZ=wcLbrrXFG_3m-Ab z)o#JdcD)JKg>m8hk(uX{Pk#vmnExxht3+9E5`Xt;MLonoi<5?P;fXYCKK}7_)$^S< zd&;?@w@UQQuI-84f`W`!()=_@?q#_u{bfQIWL{iH>J`5}yWV_Sh1dpaaV{I+<>e%h zYPMsew>~a;upGJt%d<`WJjVbu-72{q*s5je!dKAvHkX<)(I;_M&NNiZLEL49^L1K~ z3P2mPnRx|)0@Wib=9uk>)nA}G1{Hm*KxwCOr#GInS!29fAc1Eqz9`d$Em@vsCvLS^ ziWe3_Lr>o`cR$ATthG^oSZ6w&Uu*eFLV6|OB;K5LW*sUiBUIx76o6|J|F(Cc1S$e*7!- z>+OF|2ID6=R@a2U@egx+V$)yb5HVTnOY2(kTGsb^2ABDY)hr7#+F3c2tr}Bc z%t4F`_^rX|z0P}g3go?AkcWYV%dTNkoUg=lZbo+OsZ+S@Q8~ghUfhD9|I}bSr4$l$@R12O(9*uwrG8;C^aM8)ZDWA&Hofnc z2?jH?^yqyGE8F&ti=+Qf3hqDX8{pliB#{><=;cKN?7D(_)@)dvDsx0tiR`PM)b;6s zj(-vC{4VF>A2k2JK_1~01>!V_&I#wKQ`96jX+!*;wB#yj`@>m3mc5NW7<9MA>u{sWQq1r<%8mi7Vqs0N?+GMeAW-;eF|g081yyB$rsd!5@@ zRHd`8UV{#m%sOt91>-TaJfz|J95QGI~eD%s*ai;=VJQ*w&WXwYTgk%C~j(mxa+J*2*d$#rHc#8!2V6B%;IK24iLt#sGlEdtnH*L3z35d+~ z%L}XdvDs9ew)#go<`Wb?TY+j~zqTZxo5>PYO%j}JRs$(uX<-7nFkGz#ELNaKCCn0i za68T5)MN@{`{}yX+XmnUBnDvy$4r8_X%5e`z~#Lg{68@7UK(qiw2oOrr9y_D3+ z^`VgAp7V+xm<&Hq(TraL=vbMkE~&!Cg{QLMIyh~PIXiJqSYBisH%)_>Qp%BJye@OD zsdD1N!Zh2zqRQ?er88I#>0A;dw(Ou2%GLJ{-WzyZginll@pJRMdkFh$HWM%?_wi9&~ME4}l%}&V=#MNopc`^T%d)K!O32y{ZZ*+Qg2SU~*{a)@yi)HA!*5F7+XpPbt@~_hn&O%QX(KHw;x3@7i2if@ zwURrjPLS}{$~*})4(R~&bJg{xKND+!X3bv2%X=U=gL$2lP&0R0@OB8i zWq@eKFy)F238(esZk~8^T+dpX0LJ%^<@=X!Cv^u-SdO5(2gstc476B*BvvGemUkv` zv#VdBZU4{-{x=xw1?D5;6puphiiykCb*he_(HKEk(NgXyhX#|GN1oaj{gGm0j*c0( z?dYy9(JUFWc$)YJE^Jb6b<~u#5)3^BCjJ#O`I9^G3HYS8%X5RcG@)diu)zZ_XyJO; z>|6uVT2_lxQ2W!`=z$(ozEz3J<-1!A%Us`EAp#Sm7eW=~A{c7hxYZwi%A4$QIfNw) zjV>w+#)10-gIZc*d2Iy92Qe)NL-^N05J^Ul46q1knP1{uILz4eA{rXmCbF@b&OTq& z`9oJmyboJ_@)M@qWunsp7LD2j5gu&*Wa<64`tkDR@~_PNmx<@+BW`bspil$2L!W{b z6`*$jCFI^Yn5KVvjeW2B=6_>W{zH~iV_F!CSBX{q%lwZmUD^%d{`n$&i~AE@1Vu1W z!cgb8h!p4e@tHCs{Dk#YzAQAqCMTHXQG@7Jpzy%V@#4oEyH(OpQPDK_qj}#1OcXX< znXgQx^3;=+swlf!Ftr3Se>q-nSznZGf)-qB?w@cqjc38t&o~zL*cEGsZ zF#E=a&o7@b0#Q50s*cIYOOx}vnAa1GoKvd{^lTgETS2)U4hO9l3bRLe<5Y3 z&b+|se>uPi=MM;>9bCVLFwr&&<-(RB4XmAbJ?iaiScHSO4t=dR^l5h2S>N0Nsv)mK zWYxWvU+{3p_SM{vut?bQg6W?bId({6#T(ps3#Kur)ju~z*cO$D8my9P->B@LH<1nr zg_+LW2t>###JYorjYAdit)?_E4;SW%Z{sKqQ`fv zoXhY|Q}~n73@C`2Xfg|8D)gX-c2!FE(vk$yGi`R{g0!MHfSN#I8JDDF?dqwn-M&PH92xg)8i{ z?xC?0vKUu{Og5vVRmYxPmsz!^(ibe6-YiM?0n>W~+l$)40HRx~r^r-Mves`9X!rdaZ%Y%DtLqPJ=N}zT4_+dz1+6NwE_8 zt}|vIi)6_w`v6z!KEP-_ax*MGgmexVknP2<17%UTNpK_6$tMe>0^W+?Zx#Nj=y7~i z%niQ8cq~(n^PE|3KY4-39wQFBW`r9Uz2A;8q<%7q2R~zYW;jzx8xDORQspBc$cXEr zxZChOzJ#=hZ_?V4@nW;Zzl4Iarre(D6UovAH`$BculkSli5Er3GEppS69fMrlxWi z?Dh0kxO!jMCO2j)dP&lv!GvOyG;fB4vq+Dh=mAx-8*;C>p`m8e`PsV@3ATR{3Alzz zP7~F_%f?UY!eZ00933rxYZ9wfl(DJoD?6LxkIga0B-dYR<#^)v@8PPH-XuI$dBSt`<5G zl#gz<#pG2_NhI;oE*wrnExtZ6AhuYkBX->;r=7XJEiy#+oEw;8yC2h!iAR#4x)Z#4 z0eeB(XVu|k8>Y&L4s8P^Bu!QWmjHf?f2;Q`G=-R8_kxc@Xo zOlj);VPt|I|dvjCm*|H}qsEnz0b9qgFNIu{9v zquX_yvf!s}1UZTi8#;iFsyZfZ>ilc5Y%ktLzaS#2cRP&ycJiBzoSyhIlaNODAbpt$ zg=ZjIo5d?)p}JKL1(8e(N)v$upSUi25pH06Q&R^8hi2-gCWxZp*66s-jKYY;Xau1<_W$y9+pd8ZHzOsX-RPY`xg@>0)nbQFDT< zOb!OzD2iPTKNH>QH8tSecG3-=O5CI_mmDiSG_!ubu|kNa81g~*Ex*fD@7Xg1$u{FKkAn$8689KVLJ9g%R+R@&d z$8-%7sBxqDlg69h9>RV1YaPG;Kjd{sGPGD<^L$e%J^J9av)`|4#_x~+%Wv;PXMfA1 z^s8%f^exQst?z-K>Qm!*dxUa6rp2aWhrRHAX&x-uWV`79_w;ivu^ZFidmxN5AkeB? z{L#x`ow`f^rL6xhgW*>hzokz!6IqyXSlC;);dOoXk-$1o0sr;}Ix+E1?2yTQ^{DAt>!z^gGvbpY3||w zo!izygKm9YFwA?tx4X7-W#7aa%h4xT@oqCnCa(aWC$|$&9Z_80(6YVnrqYUmYM@xM zXmed<>XDixSg!6lc}ch(3UBHR^{)(V1)b^(AGK}qt>!E=7Hx(RPWwhaiW=bQNV5HF z407Ww3U==BbrUH&K;zRfxqOZvi&=%G!4t3u=O~_Y{WT*V=P9<^Ww#H;4@xWzv_z#2 zAIQP=Kp+WMbK`-ljBH8uo2sctzCEQ_GVq+<{meapYZ2w)z?uu{D5A@b!FAsU$vP_>X z$g-a*e3njP!C|d%SiaoIrF&c20RCA*|NEZn!xj}u)8~zwCn!~u#5WfdKQ~SP)8DDc zf0Z~C_3rPp-QWA%V{)vY?Ept|SEqBeVwj3+4`Zm0(1&PS0sMq|1oGS(=>MqXJty56 zuca2xq8VNK*_FL}^f3XxU{QugQ^dBO@cupDr9x*yHP}A!>!R5%oz>|acr5dKGR^<> zssCe=pK=TM5*nf}C@wv%h!CA9cNhL+-h<=c{nqJ!kG6|g?oIb9mqmk)6lVu?J&q>nBF8@(pkg{TN_N_<~(C$JLaOr;el=_$Nz58>x{u zL`Mk*S9+3Eb58oVY9Yh=pJBOd^^FR2hdpvdC{Pzt$7olEgY+lTig6tQB=_bzbu(V` zGYko_`m83*MyCM+T`=-wC}(!Oa`CCN>JTfg6cNNfTE8Oj3e6h>)qM=~XEKEb6(oP! z)N}@`O?fRdnp@W&BS=~!6%XGVSPn%m+#gP-oN|CMEp!?bGx}5=P2qIe5D`+6G>UeY zCO-&xVQIxPowv}{y-1ETFqWgv{_#jvv2FdR_2CCDC!>l$7K=ns#f%`k!E81^6V(x! z(DHTrgf0KeexWYJ`66D@rFh<#p}N?c(#hrFV&x_a{%qom?(|TwV1Y*%r|mRKoN7kRr+OPy^X5gzUf9hWhG`Nfu)oNzc3g25W;dj0 zAe^hbP^*vxq%oVo@%5ELOs4xoCY9l{ef|SbESO5-A~7NIP>7^UnUVI89xV-e%XfP@ zR;e-TL`mi9_SU@@z0)}c@iCK=C8W#KJ6(xG+P_l*i#d-2tr|WsS6hvy_e4+SUeIGW zj?8t5-{(K>GaU~d74UBxg^3v+0z@-2p`&M4`&I54MAIh6TiN-ntA_P(xR)*rmY?BV zYCzQ)4b-yQ2Qc_cvt!tkJ1)(OIVF4{&8@GpQyz-|RI5X@wX1`+Oda1DS0`VYWJ2#T zhl&dVbx>(SV4EBarR_|7qQ+McMQ`SYo~=!7$egb!X2)~28a%oFNuISl@er=r8g2YC z@y9*(_~Ym8rCfZ)5UM(jCu>**Ys{NYZ=^F1O)KrHtfbL+H%K*J!d_?_%W&c?Ra{JaE?w;d5<&odZ@(xt{LTmh`y20 ztB+o6>=9~tRg&v=Sc^j+cGgH+Za`q>#kJVG1$a|sdc1orn*Wn@tnW7B=;$H+DS6Q4;1oA72$ny~w|#6>HM9>p8hucv*m=%s*qRE zB&*f#j%#sf2t*E<@iz7W4ozd*Kj6)4uV?yamHO*<^p2+$6mXNGQs|GvTQUFL=B%nI(6B=woqen0hW*h(kM*7F!{AHd$4y)o>i zV?l-or(+7cIr4~>dAm=PRVT%mv zS)x>3YX>&VYOQE{ZQ)hf*9#HzQEw*JYU&(DjYyCR|Cbt=-}rKfDwx!t&K$zRhaiIZ=5534GY=_tPSm{T!!}7K|8Mbm)j@&R*2U#`oay}Qdwi8l0!yo5$wnKY2OA%s0|PLs64<5rF#hXb29~>rO`D=kTD1_K z*u!WAU0a00#dL3 z&MboBNj$>xU~_fPdYy!fRd?SOWHfL<%NTdmZ$f%WcyEAxE7NdO_el#F-1`gYV2}tp ziJFH8+|T;Wv@2yImHB}&eEB%Ykru44S+l2e$Q&vsD!@z;cAGcr;RB z+_qtj8$Xa$>A}?wUdo>PcXIdtkGe*wep#dp=Wa!z;)-q4o}A1^^@PA8($3K|y#n>QX?Axl*aea=_(&vd4o`f)*PH8iGmVr_AP zBId!t;vpE<((p^EyO;APlSQZ-B3Nh)iEjXwyVP;rNZN|HUmHf+*tm`&t0g~}Uy;T& zKE&p68%R^xLYKk4+Ghq{{nTQCl$x{#D&TTM0qyvU?Z2M->FtaS0GzQ9ZIIfwy}t7DzH5v#U2)L= za{<=^uY)SV_ZgVS*3a~WkDI{@SMvQMx(3k3t7pDQ{85_675O~R7ru;Yj2f)wuclm_ zmE&^uk<16J8%Kujar_4+2K%25!XjNxwib##i=kW{vn^+YCvSR0P&wCR8CrW@7|CyY zH4?t+-)V+)O9kv@AE1wmccdRSw`1owkx&?#ZJ#zIN4B?DONeJLR}$ zH_Zl57c3Plr5Dc?Y}v0D5yGZJ;;+3V))ZZg4!2Ar&v$hqnyM7#3m}i6zirOl2Lv`O z*!;lks7$$LE|0p&9>EFXs>OCA-Qvxl9bem=Q-79Tb;YE|51KYkl3P~-((vqTPB9GI z+gsg@u!<_lw?f;&8bS5iqtTo7$n1y?2ic0f2uK<2GgxJs2lx*J6a`+wmxOWHR@cnr zXPjj1zbyOuMWaui6XvBkIJr6{>bt=eA&lVSFdq-TfA#v-Vz%a&4PUf(7*Ddk>3h!f zpxT*;(fpxo5hXj!SF_|#@rLI+NC~bGi_N6c0*UE!yMPlrPVO$6?PO?o9}ok{7)B@v$bNyz%k_6_ zzYn9k9ecH@xeJ!2U}RSb<0E!A+CO_$x_Y*d+}fn400aV+v$pMbN}ONAP2V(@-`~4z znNV=$OG<+0kGEmU87#j((}E(rMb%JJ%MK+TO`L3M_TqKb2+@L`{P6X25j&If9BN|H zK46h}0C{=*fsXYe{UZTO|JjzqCk_UWnpa(S+#5{}c=3W!GohCEh+GV#j%7RJ;iR5@ z0R1%^kwiE4!%bs(NTzYNZZ$_YtOW&zYJ7XFgwo*a9STJx#}v$~7okOhRl&5LO7X?a zYwN?0vu{3cWk}mDSS2Dqs67SlfiQeLnbtT5w%pv8hc)fm2J^f-;`Jk) zRwHk-&E^&4r=fO$4gG+=6f;CcW`|cvu2~kPNGb@c1y$YZSe{$q8^m>SBZk5}xz@HC zdrQPW9qtK<9&xzh|LE>cgJ%KhHfY`!sETsjlkrTrqCjUKbNY=8L0*W9Ij@D-o3xpi zaIBz{VajfAn5qz-hu?%YbgGJhRy(uPE87W!s}h%s@{s(SfqT-vxK4%aw#)N^4fL1- z(`vH1o6(G7vOYT0BTY#OZrnK8`Q3#1%%%}(tq9+s&Q@H>vyXll_vO0s*Jab~vWBfq~c{}F$sQpj1q?*!g%xeqo6Uxcq)vcha)d~SBkS9x;d(2GwHVh#mqfQRuc}tRB1$Qo=H8a8Ri~iP_8@wz*`LkgL`LodA`KeaGf&!` z$Nz1;&oz*^i}!xnmd7Fm6_owUKMz4Q+y`)pi+KhKb#oXwZqOYP5d%$V0+E|8gSd6H zp1zed@wbp%y2qLYd>Z4})##AIb_Vi;<)PPnXk%HVBs@+{i0R@sCWwo zOUFAwwQ}SE3S2)6D#@PdJ%P4Q5v&Y#X`~*sXbX96Fy1jSF-Q$As-S+tn8>SMc@AqX*6YACvc-9)&w= z9v$B@r8%13pO@;Z2^|z?h19=fNK6mjCN%Vy=zG)xRVU7@lE}fhi9s zCN31#sj=^(VMS%H5;RIug+GR$G70MEBAjO$l&d*|BvePodOGGkco#cjSw2!!?RbT8 z#=VjjWVJmsCad{h@CSzaztz=7n-eo z!M0@zugZdP+8$~dvvhRhql;z<387^K2D3_Z2L2>^70)U$khmeAXu;%;=WIk38NrU7 zH(gpf*0=S$pc0r5y|O39xY?Rmb3DSkE+)EbcM)RkRf4?&wvWzap#(c0uz!M!G@EBT ztFtc~54(kPhK+#0-c_NZqJnb4ZkZex`*xdf(18*1p2Xzx6TizoT*=6hxp6ZVs$e*0 zd|-}z5)X|>s10kkyhJU{FC}7%+@+^a2t`MZUNcwO+e^>!oZ3!c=a9;xZIgQ(!g zN8FFL7=lglp_Y%rl__eQGkMd+^J&GK+wFZ_{gL6$D_;i5f-r{X_srl83d*&83WH?_ z-l@8})el`YQekEuwWctWyt?fZ zT4s^Oe0|4rOfg^dW=hP%TJBJnkj3XKux<@b-R><3x$~+BYI3+5(CI>2@7XrExwN_{DKm5GeX^Pv*XEUUnVTE{d$|@XU6e#1RP8xzMRk<;E4Km3^Lfbxd2P#1)67Lg zDWW5C75_d8&rKjPvNC6?)K%9xDqR- zCy(9q1pp9-+0I<#`-a#Q6)EEk!b6Ow9=+MxQw7_N?RLr;uAH&pN2wl4#OsDF)o<$_>RT{*vmUqJS`=+bTPe7kme z2Z7;36+3OC_444LpJt~g-h{)A2&AM>-4MWDpE=B_4S^J1Y?@J*i;$m)812knQaTiq)-hEr+!8tKZz7i8N5b6@;^sIfr^Vf~MtPv+O ziLb(#=9-L)3`#6#hpU6sm1~>f-5oAr9<6~ox^zny_QH0_nHRN#;c4>7ISA&)xgWib z>YH|Qp>Z{yvy&;sp{ zB>6_PZ>G>~#{-`$NAsO4uaoRW3UnuE3wYypP;w68MIxTbF!lUw$ngh8|4@6vV+EBo zycHOXY(=P-N9FInn3I=;gJ6ABQ{p3WY2gXb%YEZSkOhr<1=Z~qL=gL)lIDCaqL+bE zeM@eAU#pLe=fG~v@^ma{_FMz1{ziv=$9mC58t;qvA_7Z?J(Jf96D<+V-s6Nw=6>Wv z4cASOSWTrCc>g#qN}QHfQkCS3h`u95Id>okFH*>?(1b0^Ns|4_D>kUQLu`exRkZnO zBOZcvCa^wDQ1r}6yPu^&1gU<}iBm#`%7d*=b|;TVo#Q6-BWz&-M}KAB^HA2?x^w^J z4_m!K+N${W-)7)j|H+|_OsL)c*wskxsB6+x+x;GLK)rvU@#$dY$Kz!YjlU zD^(Bj9JA@7usSam-X9LtMwIQC<_qN8Rj1kQk}%VJQY*1#oanU|Xl2jT zUtt8&cw*4JX6(`yIyoSg?qd2fQD}0zN&8}@X-!}YVzfUn$yHI7((o%kKcAG#O)jVs(tpC-kU(5c;_&>_(YBcC{cY!qRp?XxCVmhn|0x-peSmjc>h#Ld`^!m3 zQtzm+$Jk#E*t7iW+dk>&j_Bcm5mw-RfXt&<>1EV5I6ndw)t6$rRUcEQ$n|QA2k*FU zj6SDn^%3m zAVI|gGuY^Av2wO(V{Kb^H|GT$43!&lE&7FPwO|g3!($K-vq_tLfAPAb>`dZmeYZ|n zCnmOzIUspfoO z4vAH^V`s^Ht)hGDVS}+ncn9-FIw{JZzb43(?f9ln?1ZzIW~Rr@==Dt#16)crG+G`A zn7q(utztbNFVaIF$u-cVJ$9@*!JG(*{oeP z=<@q3elx7rs$NB!SQYAr)}I)U3l~-v>*OfOt)kK({HWlPJbdb>xmOL&#=r}YfC~M| zYCvzdV&2Lu!^*x}6ugvbq%?w!Sy(~N7vsPpxh6&p#I`owpC~Nki@A=7u4!&EUWQ!r(J>PrOOax?-w&xv z4(zcg_7qvkk0Otn4mOXTw2;_s9M*34HY=J zC-bCug;$P3IYyxzQA4lXVv%S1@3ZW_{J#CJIZBY*$`dK<1JW?pzB^d(f4vT6qK1A0 z8Sed!{D;my{YQI{xhzRX;>YsPj8DnEAsp@Cq9+@fabGB#mFVMobMNd<@I7qgpCegs zgEgKd$5M08$Ulet<5oIpTlakV2Gc3>&sQGu*c7C#r~ARn|L+vwS7+7T%X+(#4U3hR z9~Q{3;;kb!{~ia66qekvFby}Qj@2|JAMI15t%$6m`Jj^Go-mwOMkM}I}mXM;CmbgjZv0s>CY3n8NGc(-Bq<%9YeU-f^vY<{{ zqWu|hc%QpCQUCvH=h|bMy5ew&y2UU+qlns8oQp67gJ1!9STY~2I37_H1hrEc={@3It z=iGDdIl1@y-E))sedqTfcju5ei$@LE2Sq*muV0_<6~aqR*W>zZ!*=FlT%?7aDlZ=H z)wyt}h&QoeIMlIq0YV+T5GT#ycQ+s%vU&{#!9mg*Zs^fY4cB;YXzqZ9x{cy}Xml^v zpGG+xTT3b1b+V&F!66S!LhKW&8w369o3D)11Ttl-6TLS#FFGl$0+>v>018jHk6TJ0-yZ|UFhtuE-aELuiQ3QcV)jsv={xt2y%?sJ-P>}`^ahK!v?}%j za>-BElhSKg5+Va-id*4lAZ*;M%P1aWgt-#sS(t2PJOMadttVX#;KD0w_s}Yd%wnDp zWm?g2UuRYrpOk;ytUZ*lI?k=I{)=IfTLHU&{v(YuUwj+>V*nia8B?e70vRuKGEt)$ zfM?69hT4(qO4x|dQ3Y9>ims@qa~(OYry?jtu*Y>u{vj_Xx$mF)G<~Mwa@om2PhmR3(UfG!~1IIboC5)dgsNVj%jaU3YR&#;Nywe-0u)!@l_2D!?7nhH11dpS;V-PRp?$Wz8Mx2(97k&?2z58u8?Q#ISH_x`MB&pKtQDlU_N2e0jMO`BR1o{Zvb?+%iYetek{w(*-F8x7!eg zi2R2n2S3Bv|L5u#baYzF)moM$=?qW9^MTAjiCi4+E_BmAHH)yU@bwy90V#Jd$ZvFoQtrMN4-uy57=i=?c1xs$ zEg(`Z!EYk&M3BkA3#v1dhFwes;2kRgU&>s@}FawG@k-^%U_ z@ZjZzhtzm?N!p+&V@umGnv)GhpYVU6<~Tv0>RH42xrv^L#yDQ|dRJ_RF)C|(q_iv~ zsa>=#9*zvs*U*zPQ#Q4GVuhP81&$>Y2Q8Nc*+F-R)t=P`Qn(_2==bETjB|{`Zm>={ zwlQ4=6}G(WW>AmpSg+=9dwU{_O}Q8za$iaOPvx|W;kSf<0gy*F7Y^7P(Uo{DFuiok8}ACMpcV)RSWExfKR_vk|^t+BIonIDaTqGUz6-^Ckwb1A>g&7 z!&>TFKc!vFaLB4bcgI#(RqfE}MA_V9?=OGrxfKB`0#*d92v`xYB49 0 +); + +UPDATE `im_users` AS u +JOIN ( + SELECT ordered.`id`, (@im_user_account_no := @im_user_account_no + 1) AS next_no + FROM ( + SELECT `id` + FROM `im_users` + WHERE `account_no` = 0 + ORDER BY `createdat`, `id` + ) AS ordered +) AS seq ON seq.`id` = u.`id` +SET u.`account_no` = seq.next_no +WHERE u.`account_no` = 0; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_users' AND INDEX_NAME = 'uk_im_users_account_no') = 0, + 'CREATE UNIQUE INDEX `uk_im_users_account_no` ON `im_users` (`account_no`)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ============================================================ +-- 1. Applied runtime policy snapshots. +-- policy_type: security, user_policy_groups, sensitive_words. +-- The application uses the latest row with apply_status=applied and also keeps +-- an in-memory cache. This table makes restart and Kafka replay deterministic. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_runtime_policy_snapshots` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `policy_type` varchar(32) NOT NULL, + `policy_version` bigint unsigned NOT NULL, + `payload` json NOT NULL, + `payload_sha256` char(64) NOT NULL, + `source_event_id` varchar(96) DEFAULT NULL COMMENT 'NULL for a manual bootstrap snapshot', + `apply_status` varchar(16) NOT NULL DEFAULT 'applied' COMMENT 'applied or failed', + `apply_error` varchar(1000) NOT NULL DEFAULT '', + `applied_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_runtime_policy_version` (`policy_type`, `policy_version`), + UNIQUE KEY `uk_im_runtime_policy_event` (`source_event_id`), + KEY `idx_im_runtime_policy_latest` (`policy_type`, `apply_status`, `policy_version` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Last applied Admin policy payloads'; + +-- ============================================================ +-- 2. User login history and login failure history. +-- Never store plaintext passwords, verification codes, access tokens or refresh +-- tokens. account_hash is SHA-256 of the normalized login account; account_mask +-- is the value shown in Admin. +-- login_type: password, sms_code, sso, qr_code, token_refresh. +-- event_type: login, refresh, logout, kicked, session_expired. +-- failure_type: password_error, code_error, account_not_found, +-- account_disabled, rate_limited, risk_blocked. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_user_login_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `username` varchar(64) NOT NULL DEFAULT '', + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `login_type` varchar(24) NOT NULL, + `event_type` varchar(24) NOT NULL DEFAULT 'login', + `session_id` varchar(96) NOT NULL DEFAULT '' COMMENT 'Opaque session identifier, never an auth token', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'low', + `request_id` varchar(64) NOT NULL DEFAULT '', + `login_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `logout_at` datetime(3) DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_user_login_user_time` (`user_id`, `login_at` DESC, `id` DESC), + KEY `idx_im_user_login_ip_time` (`ip`, `login_at` DESC, `id` DESC), + KEY `idx_im_user_login_device_time` (`device_id`(64), `login_at` DESC), + KEY `idx_im_user_login_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='User login and session event history'; + +CREATE TABLE IF NOT EXISTS `im_login_failure_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint DEFAULT NULL COMMENT 'NULL when the account cannot be resolved', + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `failure_type` varchar(32) NOT NULL, + `reason` varchar(96) NOT NULL DEFAULT '' COMMENT 'Stable backend reason code', + `detail` varchar(500) NOT NULL DEFAULT '' COMMENT 'Sanitized detail without credentials', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `request_id` varchar(64) NOT NULL DEFAULT '', + `attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_login_failure_account_time` (`account_hash`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_user_time` (`user_id`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_ip_time` (`ip`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_type_time` (`failure_type`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Authentication failure and risk-block history'; + +-- ============================================================ +-- 3. Message administration actions. +-- Messages remain in im_messages/im_group_messages. This table is an immutable +-- action overlay so hide/delete can be audited and restored. +-- conversation_type: private, group. +-- action: hide, unhide, delete, restore. +-- action_status: pending, success, failed, dead. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_message_admin_actions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `action_id` varchar(96) NOT NULL, + `command_id` varchar(96) NOT NULL, + `conversation_type` varchar(16) NOT NULL, + `topic` varchar(64) NOT NULL, + `seqid` bigint NOT NULL, + `sender_user_id` bigint DEFAULT NULL, + `action` varchar(16) NOT NULL, + `action_status` varchar(16) NOT NULL DEFAULT 'pending', + `reason` varchar(500) NOT NULL DEFAULT '', + `actor_admin_id` bigint NOT NULL DEFAULT 0, + `request_id` varchar(64) NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `failure_reason` varchar(96) NOT NULL DEFAULT '', + `failure_message` varchar(1000) NOT NULL DEFAULT '', + `processed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_admin_action_id` (`action_id`), + UNIQUE KEY `uk_im_message_admin_command` (`command_id`), + KEY `idx_im_message_admin_target` (`conversation_type`, `topic`, `seqid`, `id` DESC), + KEY `idx_im_message_admin_status` (`action_status`, `created_at`, `id`), + KEY `idx_im_message_admin_actor` (`actor_admin_id`, `created_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Auditable Admin message actions'; + +-- ============================================================ +-- 4. Sensitive-word hits and policy decisions. +-- Store only a digest and a short masked excerpt. Do not duplicate complete +-- private messages in log tables. +-- policy decision reasons include FRIEND_DISABLED and MESSAGE_CURFEW_ACTIVE. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_sensitive_word_hits` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `word_id` bigint NOT NULL DEFAULT 0 COMMENT 'Admin control-database word ID', + `word_group_id` bigint NOT NULL DEFAULT 0, + `word_snapshot` varchar(255) NOT NULL DEFAULT '' COMMENT 'Masked word snapshot', + `scene` varchar(32) NOT NULL, + `user_id` bigint NOT NULL DEFAULT 0, + `target_user_id` bigint DEFAULT NULL, + `topic` varchar(64) NOT NULL DEFAULT '', + `seqid` bigint DEFAULT NULL, + `content_sha256` char(64) NOT NULL DEFAULT '', + `matched_excerpt` varchar(500) NOT NULL DEFAULT '', + `match_type` varchar(24) NOT NULL DEFAULT 'contains', + `action` varchar(24) NOT NULL, + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `request_id` varchar(64) NOT NULL DEFAULT '', + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_sensitive_hit_scene_time` (`scene`, `created_at` DESC, `id` DESC), + KEY `idx_im_sensitive_hit_user_time` (`user_id`, `created_at` DESC, `id` DESC), + KEY `idx_im_sensitive_hit_target` (`topic`, `seqid`, `id` DESC), + KEY `idx_im_sensitive_hit_word` (`word_id`, `created_at` DESC), + KEY `idx_im_sensitive_hit_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Sensitive-word match events'; + +CREATE TABLE IF NOT EXISTS `im_policy_decision_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `policy_type` varchar(32) NOT NULL COMMENT 'security or user_policy_group', + `policy_version` bigint unsigned NOT NULL DEFAULT 0, + `policy_group_id` bigint NOT NULL DEFAULT 0, + `scene` varchar(64) NOT NULL COMMENT 'friend_request, qrcode_resolve, private_message and so on', + `action` varchar(24) NOT NULL COMMENT 'deny, challenge or audit_only', + `reason_code` varchar(96) NOT NULL COMMENT 'For example FRIEND_DISABLED or MESSAGE_CURFEW_ACTIVE', + `user_id` bigint NOT NULL DEFAULT 0, + `target_user_id` bigint DEFAULT NULL, + `topic` varchar(64) NOT NULL DEFAULT '', + `request_id` varchar(64) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_policy_decision_user_time` (`user_id`, `created_at` DESC, `id` DESC), + KEY `idx_im_policy_decision_reason_time` (`reason_code`, `created_at` DESC, `id` DESC), + KEY `idx_im_policy_decision_scene_time` (`scene`, `created_at` DESC, `id` DESC), + KEY `idx_im_policy_decision_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Runtime policy deny/challenge decisions'; + +-- ============================================================ +-- 5. Group event history and daily IP/account statistics. +-- im_group_events_outbox remains the delivery table; this log is the queryable +-- business history after the operation result is known. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_group_event_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `topic` varchar(64) NOT NULL, + `group_id` bigint DEFAULT NULL, + `event_type` varchar(64) NOT NULL, + `actor_user_id` bigint NOT NULL DEFAULT 0, + `target_user_id` bigint DEFAULT NULL, + `result` varchar(16) NOT NULL DEFAULT 'success', + `reason` varchar(500) NOT NULL DEFAULT '', + `request_id` varchar(64) NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_event_log_event` (`event_id`), + KEY `idx_im_group_event_topic_time` (`topic`, `created_at` DESC, `id` DESC), + KEY `idx_im_group_event_type_time` (`event_type`, `created_at` DESC, `id` DESC), + KEY `idx_im_group_event_actor_time` (`actor_user_id`, `created_at` DESC, `id` DESC), + KEY `idx_im_group_event_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Queryable group event audit history'; + +CREATE TABLE IF NOT EXISTS `im_ip_account_stats_daily` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `stat_date` date NOT NULL, + `ip` varchar(64) NOT NULL, + `region` varchar(128) NOT NULL DEFAULT '', + `account_count` int unsigned NOT NULL DEFAULT 0, + `user_count` int unsigned NOT NULL DEFAULT 0, + `login_success_count` int unsigned NOT NULL DEFAULT 0, + `login_failure_count` int unsigned NOT NULL DEFAULT 0, + `first_seen_at` datetime(3) DEFAULT NULL, + `last_seen_at` datetime(3) DEFAULT NULL, + `risk_level` varchar(16) NOT NULL DEFAULT 'low', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `sample_user_ids` json DEFAULT NULL COMMENT 'Bounded sample only, not an unlimited array', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_ip_account_stats_day_ip` (`stat_date`, `ip`), + KEY `idx_im_ip_account_stats_ip_day` (`ip`, `stat_date` DESC), + KEY `idx_im_ip_account_stats_risk_day` (`risk_level`, `stat_date` DESC, `id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Daily IP and account aggregation'; + +-- ============================================================ +-- 6. Compatible enhancements to existing tables. +-- Every ALTER is guarded by information_schema and can be rerun. +-- ============================================================ + +-- Message management queries need topic/time and sender/time indexes. +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_messages' + AND INDEX_NAME = 'idx_im_messages_topic_created') = 0, + 'ALTER TABLE `im_messages` ADD INDEX `idx_im_messages_topic_created` (`topic`, `createdat`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_messages' + AND INDEX_NAME = 'idx_im_messages_from_created') = 0, + 'ALTER TABLE `im_messages` ADD INDEX `idx_im_messages_from_created` (`from`, `createdat`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- Extend the existing Tinode device record for device and login-history views. +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'firstseen') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `firstseen` DATETIME(3) DEFAULT NULL AFTER `platform`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'lastip') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `lastip` VARCHAR(64) NOT NULL DEFAULT '''' AFTER `lastseen`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'useragent') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `useragent` VARCHAR(512) NOT NULL DEFAULT '''' AFTER `lastip`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'appversion') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `appversion` VARCHAR(32) NOT NULL DEFAULT '''' AFTER `useragent`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'status') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `status` VARCHAR(16) NOT NULL DEFAULT ''active'' AFTER `appversion`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- Feedback handling fields required by the Admin processing workflow. +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'assigned_to') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `assigned_to` BIGINT DEFAULT NULL AFTER `status`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'handled_by') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `handled_by` BIGINT DEFAULT NULL AFTER `assigned_to`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'handled_at') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `handled_at` DATETIME(3) DEFAULT NULL AFTER `handled_by`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'resolution') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `resolution` VARCHAR(1000) NOT NULL DEFAULT '''' AFTER `handled_at`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'admin_remark') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `admin_remark` VARCHAR(1000) NOT NULL DEFAULT '''' AFTER `resolution`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND INDEX_NAME = 'idx_im_feedback_status_time') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD INDEX `idx_im_feedback_status_time` (`status`, `created_at`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_audit_logs' + AND INDEX_NAME = 'idx_im_audit_logs_ip_time') = 0, + 'ALTER TABLE `im_audit_logs` ADD INDEX `idx_im_audit_logs_ip_time` (`ip`, `created_at`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- Post-deployment checks (read-only): +-- SELECT policy_type, policy_version, apply_status, applied_at +-- FROM im_runtime_policy_snapshots ORDER BY policy_type, policy_version DESC; +-- SELECT reason_code, scene, COUNT(*) AS deny_count +-- FROM im_policy_decision_logs +-- WHERE created_at >= CURRENT_DATE +-- GROUP BY reason_code, scene; +-- SELECT stat_date, ip, account_count, login_failure_count, risk_level +-- FROM im_ip_account_stats_daily ORDER BY stat_date DESC, login_failure_count DESC; diff --git a/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql b/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql new file mode 100644 index 0000000..cb0d033 --- /dev/null +++ b/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql @@ -0,0 +1,1618 @@ +-- IM tenant business database initialization script. +-- Source baseline: local MySQL database `tinode` on 2026-09-13. +-- Purpose: create a clean business database for a new IM tenant. +-- +-- Usage: +-- 1. Replace placeholders before execution: +-- __TENANT_DB__ -> new tenant business database, for example `tinode_acme`. +-- __TENANT_ID__ -> hotgo.im_tenant.id assigned to this tenant. +-- __TENANT_CODE__ -> public enterprise code, for example `acme`. +-- __TENANT_NAME__ -> tenant display name. +-- 2. Execute this SQL with a MySQL account that can CREATE DATABASE and write hotgo.im_tenant. +-- 3. Keep business tables empty; do not copy users, topics, messages, files, Kafka logs, or audit data from `tinode`. +-- +-- Notes: +-- - This script intentionally seeds only stable metadata/default configuration. +-- - It does not copy default tenant's test users, groups, messages, login logs, or outbox rows. +-- - The IM Server must enable tenant_db_routing=true and use hotgo as tenant_registry_db when registry and tenant DB are separated. + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE DATABASE IF NOT EXISTS `__TENANT_DB__` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE `__TENANT_DB__`; + +-- ============================================================ +-- 1. Tenant business schema. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `im_abuse_reasons` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `target_type` varchar(16) NOT NULL, + `reason_code` varchar(64) NOT NULL, + `label` varchar(128) NOT NULL, + `requires_description` tinyint NOT NULL DEFAULT '0', + `allows_evidence` tinyint NOT NULL DEFAULT '1', + `sort_order` int NOT NULL DEFAULT '0', + `enabled` tinyint NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_abuse_reasons_type_code` (`target_type`,`reason_code`), + KEY `idx_im_abuse_reasons_type_order` (`target_type`,`enabled`,`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_abuse_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `reporter_id` bigint NOT NULL, + `target_type` varchar(16) NOT NULL, + `target_user_id` bigint NOT NULL DEFAULT '0', + `target_topic` varchar(32) NOT NULL DEFAULT '', + `target_seq` bigint NOT NULL DEFAULT '0', + `reason_code` varchar(64) NOT NULL, + `description` text NOT NULL, + `evidence_file_ids` json DEFAULT NULL, + `snapshot` json DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_abuse_reports_report` (`report_id`), + UNIQUE KEY `uk_im_abuse_reports_idempotency` (`reporter_id`,`idempotency_key`), + KEY `idx_im_abuse_reports_target` (`target_type`,`target_topic`,`target_seq`,`target_user_id`), + KEY `idx_im_abuse_reports_reporter` (`reporter_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_action_tokens` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `token_id` varchar(64) NOT NULL, + `action` varchar(64) NOT NULL, + `target` varchar(128) NOT NULL DEFAULT '', + `token_hash` char(64) NOT NULL, + `state` smallint NOT NULL, + `expires_at` datetime(3) NOT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_action_tokens_token` (`token_id`), + KEY `idx_im_action_tokens_user_action` (`user_id`,`action`,`target`,`state`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_app_releases` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `platform` varchar(32) NOT NULL, + `channel` varchar(32) NOT NULL DEFAULT 'stable', + `latest_version` varchar(64) NOT NULL, + `min_supported_version` varchar(64) NOT NULL, + `update_type` varchar(16) NOT NULL DEFAULT 'none', + `release_notes` text NOT NULL, + `store_url` varchar(512) NOT NULL DEFAULT '', + `package_url` varchar(512) NOT NULL DEFAULT '', + `checksum` varchar(128) NOT NULL DEFAULT '', + `remind_after_seconds` int NOT NULL DEFAULT '0', + `published_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_app_releases_platform_channel` (`platform`,`channel`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_appeals` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `appeal_id` varchar(64) NOT NULL, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `appeal_type` varchar(64) NOT NULL, + `reason` varchar(512) NOT NULL, + `target_action` varchar(32) NOT NULL DEFAULT '', + `target_time` datetime(3) DEFAULT NULL, + `attachment_file_ids` json DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `version` int NOT NULL DEFAULT '1', + `idempotency_key` varchar(128) NOT NULL, + `submitted_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_appeals_appeal` (`appeal_id`), + UNIQUE KEY `uk_im_attendance_appeals_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_attendance_appeals_record` (`record_id`), + KEY `idx_im_attendance_appeals_user_status` (`user_id`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_checkins` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `checkin_id` varchar(64) NOT NULL, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `record_date` date NOT NULL, + `action` varchar(32) NOT NULL, + `rule_id` varchar(64) NOT NULL, + `shift_id` varchar(64) NOT NULL, + `client_time` datetime(3) NOT NULL, + `server_time` datetime(3) NOT NULL, + `result` varchar(32) NOT NULL, + `location` json DEFAULT NULL, + `wifi` json DEFAULT NULL, + `device` json DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_checkins_checkin` (`checkin_id`), + UNIQUE KEY `uk_im_attendance_checkins_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_attendance_checkins_record` (`record_id`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_records` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `record_date` date NOT NULL, + `rule_id` varchar(64) NOT NULL, + `shift_id` varchar(64) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'not_checked', + `exceptions` json DEFAULT NULL, + `appeal_status` varchar(32) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_records_record` (`record_id`), + UNIQUE KEY `uk_im_attendance_records_user_date_shift` (`user_id`,`record_date`,`shift_id`), + KEY `idx_im_attendance_records_user_id` (`user_id`,`id` DESC), + KEY `idx_im_attendance_records_date_status` (`record_date`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_rules` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `rule_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `workday` tinyint NOT NULL DEFAULT '1', + `location_required` tinyint NOT NULL DEFAULT '0', + `wifi_required` tinyint NOT NULL DEFAULT '0', + `allow_outside_checkin` tinyint NOT NULL DEFAULT '1', + `appeal_allowed` tinyint NOT NULL DEFAULT '1', + `effective_from` date DEFAULT NULL, + `effective_to` date DEFAULT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_rules_rule` (`rule_id`), + KEY `idx_im_attendance_rules_state` (`state`,`effective_from`,`effective_to`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_shifts` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `shift_id` varchar(64) NOT NULL, + `rule_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `work_start` time NOT NULL, + `work_end` time NOT NULL, + `checkin_start` time NOT NULL, + `checkout_end` time NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `sort_order` int NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_shifts_shift` (`shift_id`), + KEY `idx_im_attendance_shifts_rule` (`rule_id`,`state`,`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_audit_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `actor_uid` bigint NOT NULL DEFAULT '0', + `actor_role` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `action` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `target_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `target_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `reason` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `request_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `ip` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `user_agent` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_audit_logs_actor` (`actor_uid`,`id` DESC), + KEY `idx_im_audit_logs_action` (`action`,`id` DESC), + KEY `idx_im_audit_logs_target` (`target_type`,`target_id`,`id` DESC), + KEY `idx_im_audit_logs_request` (`request_id`), + KEY `idx_im_audit_logs_created` (`created_at`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM租户审计日志'; + +CREATE TABLE IF NOT EXISTS `im_auth` ( + `id` int NOT NULL AUTO_INCREMENT, + `userid` bigint NOT NULL, + `authlvl` int NOT NULL, + `secret` varchar(255) NOT NULL, + `expires` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `auth_userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_business_events_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(64) NOT NULL, + `target_type` varchar(16) NOT NULL, + `target` varchar(128) NOT NULL, + `actor_user_id` bigint NOT NULL DEFAULT '0', + `event_type` varchar(64) NOT NULL, + `payload` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `delivered_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_business_events_outbox_event` (`event_id`), + KEY `idx_im_business_events_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_business_events_outbox_target` (`target_type`,`target`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_call_records` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `call_id` varchar(64) NOT NULL, + `topic` char(25) NOT NULL, + `seq_id` bigint NOT NULL, + `caller_id` bigint NOT NULL, + `callee_id` bigint NOT NULL, + `call_type` varchar(16) NOT NULL, + `status` varchar(32) NOT NULL, + `end_reason` varchar(64) NOT NULL DEFAULT '', + `duration_ms` bigint NOT NULL DEFAULT '0', + `started_at` datetime(3) NOT NULL, + `accepted_at` datetime(3) DEFAULT NULL, + `finished_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_call_records_call` (`call_id`), + UNIQUE KEY `uk_im_call_records_topic_seq` (`topic`,`seq_id`), + KEY `idx_im_call_records_caller` (`caller_id`,`id` DESC), + KEY `idx_im_call_records_callee` (`callee_id`,`id` DESC), + KEY `idx_im_call_records_status` (`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_conversation_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `topic` varchar(128) NOT NULL, + `pinned` tinyint NOT NULL DEFAULT '0', + `muted` tinyint NOT NULL DEFAULT '0', + `remark` varchar(128) NOT NULL DEFAULT '', + `message_retention` varchar(32) NOT NULL DEFAULT 'default', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_conversation_settings_user_topic` (`user_id`,`topic`), + KEY `idx_im_conversation_settings_user_pinned` (`user_id`,`pinned`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_credentials` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `method` varchar(16) NOT NULL, + `value` varchar(128) NOT NULL, + `synthetic` varchar(192) NOT NULL, + `userid` bigint NOT NULL, + `resp` varchar(255) DEFAULT NULL, + `done` tinyint NOT NULL DEFAULT '0', + `retries` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_daily_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `author_id` bigint NOT NULL, + `report_date` date NOT NULL, + `content` text NOT NULL, + `attachments` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'draft', + `read_state` varchar(16) NOT NULL DEFAULT 'unread', + `version` int NOT NULL DEFAULT '1', + `submitted_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_daily_reports_report` (`report_id`), + UNIQUE KEY `uk_im_daily_reports_author_date` (`author_id`,`report_date`), + KEY `idx_im_daily_reports_author_id` (`author_id`,`id` DESC), + KEY `idx_im_daily_reports_date_status` (`report_date`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_dellog` ( + `id` int NOT NULL AUTO_INCREMENT, + `topic` char(25) NOT NULL, + `deletedfor` bigint NOT NULL DEFAULT '0', + `delid` int NOT NULL, + `low` int NOT NULL, + `hi` int NOT NULL, + PRIMARY KEY (`id`), + KEY `dellog_topic_delid_deletedfor` (`topic`,`delid`,`deletedfor`), + KEY `dellog_topic_deletedfor_low_hi` (`topic`,`deletedfor`,`low`,`hi`), + KEY `dellog_deletedfor` (`deletedfor`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_device_push_tokens` ( + `id` bigint NOT NULL, + `device_id` varchar(128) NOT NULL, + `user_id` bigint NOT NULL, + `provider` varchar(32) NOT NULL, + `token` varchar(512) NOT NULL, + `app_version` varchar(64) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_device_push_tokens_provider_token` (`provider`,`token`), + UNIQUE KEY `uk_im_device_push_tokens_device` (`device_id`), + KEY `idx_im_device_push_tokens_user` (`user_id`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_devices` ( + `id` int NOT NULL AUTO_INCREMENT, + `userid` bigint NOT NULL, + `hash` char(16) NOT NULL, + `deviceid` text NOT NULL, + `platform` varchar(32) DEFAULT NULL, + `lastseen` datetime NOT NULL, + `lang` varchar(8) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_favorites` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `favorite_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `item_type` varchar(16) NOT NULL, + `source_topic` char(25) NOT NULL, + `source_seq` bigint NOT NULL, + `snapshot` json DEFAULT NULL, + `attachment_refs` json DEFAULT NULL, + `availability` varchar(16) NOT NULL DEFAULT 'available', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `collected_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_favorites_favorite` (`favorite_id`), + UNIQUE KEY `uk_im_favorites_source` (`user_id`,`source_topic`,`source_seq`), + UNIQUE KEY `uk_im_favorites_idempotency` (`user_id`,(nullif(`idempotency_key`,_utf8mb4''))), + KEY `idx_im_favorites_inbox` (`user_id`,`id` DESC), + KEY `idx_im_favorites_type` (`user_id`,`item_type`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_feedback_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `categories` json DEFAULT NULL, + `max_length` int NOT NULL DEFAULT '1000', + `attachment_policy` json DEFAULT NULL, + `allow_contact` tinyint NOT NULL DEFAULT '1', + `diagnostics_notice` text NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_feedback_submissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `feedback_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `category` varchar(64) NOT NULL, + `content` text NOT NULL, + `contact` varchar(128) NOT NULL DEFAULT '', + `attachment_file_ids` json DEFAULT NULL, + `client_context` json DEFAULT NULL, + `include_diagnostics` tinyint NOT NULL DEFAULT '0', + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_feedback_submissions_feedback` (`feedback_id`), + UNIQUE KEY `uk_im_feedback_submissions_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_feedback_submissions_user` (`user_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_file_review_tasks` ( + `id` bigint NOT NULL, + `task_id` varchar(64) NOT NULL, + `file_id` bigint NOT NULL, + `user_id` bigint NOT NULL DEFAULT '0', + `task_type` varchar(32) NOT NULL, + `provider` varchar(64) NOT NULL, + `storage_provider` varchar(32) NOT NULL DEFAULT '', + `object_name` varchar(2048) NOT NULL DEFAULT '', + `mime_type` varchar(255) NOT NULL DEFAULT '', + `file_size` bigint NOT NULL DEFAULT '0', + `provider_task_id` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `risk_level` varchar(32) NOT NULL DEFAULT '', + `risk_labels` json DEFAULT NULL, + `result_json` json DEFAULT NULL, + `error_code` varchar(64) NOT NULL DEFAULT '', + `error_message` varchar(512) NOT NULL DEFAULT '', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_file_review_tasks_task` (`task_id`), + UNIQUE KEY `uk_im_file_review_tasks_file_provider` (`file_id`,`task_type`,`provider`), + KEY `idx_im_file_review_tasks_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_file_review_tasks_file` (`file_id`), + KEY `idx_im_file_review_tasks_provider_task` (`provider`,`provider_task_id`), + KEY `idx_im_file_review_tasks_object` (`storage_provider`,`object_name`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_filemsglinks` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `fileid` bigint NOT NULL, + `msgid` int DEFAULT NULL, + `topic` char(25) DEFAULT NULL, + `userid` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fileid` (`fileid`), + KEY `msgid` (`msgid`), + KEY `topic` (`topic`), + KEY `userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_fileuploads` ( + `id` bigint NOT NULL, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `userid` bigint DEFAULT NULL, + `status` int NOT NULL, + `mimetype` varchar(255) NOT NULL, + `size` bigint NOT NULL, + `etag` varchar(128) DEFAULT NULL, + `location` varchar(2048) NOT NULL, + PRIMARY KEY (`id`), + KEY `fileuploads_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_friend_requests` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `request_id` varchar(64) NOT NULL, + `applicant_id` bigint NOT NULL, + `target_id` bigint NOT NULL, + `message` varchar(200) NOT NULL DEFAULT '', + `source` varchar(32) NOT NULL DEFAULT '', + `source_topic` varchar(64) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `expires_at` datetime(3) NOT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_friend_requests_request` (`request_id`), + UNIQUE KEY `uk_im_friend_requests_idempotency` (`applicant_id`,`idempotency_key`), + KEY `idx_im_friend_requests_pair` (`applicant_id`,`target_id`,`status`,`created_at`), + KEY `idx_im_friend_requests_inbox` (`target_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_friendships` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `peer_id` bigint NOT NULL, + `status` varchar(16) NOT NULL, + `remark` varchar(64) NOT NULL DEFAULT '', + `topic` char(25) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_friendships_pair` (`user_id`,`peer_id`), + KEY `idx_im_friendships_peer` (`peer_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_admin_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `log_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `operator_id` bigint NOT NULL, + `target_user_id` bigint DEFAULT NULL, + `action` varchar(64) NOT NULL, + `object_type` varchar(32) NOT NULL DEFAULT '', + `object_id` varchar(64) NOT NULL DEFAULT '', + `before_data` json DEFAULT NULL, + `after_data` json DEFAULT NULL, + `request_id` varchar(128) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_admin_logs_log` (`log_id`), + KEY `idx_im_group_admin_logs_topic_time` (`topic`,`id` DESC), + KEY `idx_im_group_admin_logs_operator` (`operator_id`,`id` DESC), + KEY `idx_im_group_admin_logs_object` (`object_type`,`object_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_announcement_reads` ( + `id` bigint NOT NULL, + `announcement_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `read_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_announcement_reads_announcement_user` (`announcement_id`,`user_id`), + KEY `idx_im_group_announcement_reads_topic_user` (`topic`,`user_id`,`read_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_announcements` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `announcement_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `author_user_id` bigint NOT NULL, + `content` text NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'current', + `version` int NOT NULL DEFAULT '1', + `published_at` datetime(3) NOT NULL, + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_announcements_announcement` (`announcement_id`), + UNIQUE KEY `uk_im_group_announcements_idempotency` (`topic`,`author_user_id`,`idempotency_key`), + KEY `idx_im_group_announcements_topic_status` (`topic`,`status`,`version` DESC,`id` DESC), + KEY `idx_im_group_announcements_topic_time` (`topic`,`published_at` DESC,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_events_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `actor_user_id` bigint NOT NULL DEFAULT '0', + `event_type` varchar(64) NOT NULL, + `payload` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `delivered_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_events_outbox_event` (`event_id`), + KEY `idx_im_group_events_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_group_events_outbox_topic` (`topic`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_files` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `group_file_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `file_id` varchar(64) NOT NULL, + `source_topic` varchar(32) NOT NULL DEFAULT '', + `source_seq` bigint NOT NULL DEFAULT '0', + `uploader_id` bigint NOT NULL, + `file_name` varchar(255) NOT NULL DEFAULT '', + `mime_type` varchar(128) NOT NULL DEFAULT '', + `file_type` varchar(32) NOT NULL DEFAULT 'file', + `file_size` bigint NOT NULL DEFAULT '0', + `status` varchar(16) NOT NULL DEFAULT 'active', + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_files_group_file` (`group_file_id`), + UNIQUE KEY `uk_im_group_files_topic_file` (`topic`,`file_id`), + UNIQUE KEY `uk_im_group_files_idempotency` (`topic`,`uploader_id`,`idempotency_key`), + KEY `idx_im_group_files_topic_type` (`topic`,`status`,`file_type`,`id` DESC), + KEY `idx_im_group_files_topic_name` (`topic`,`status`,`file_name`), + KEY `idx_im_group_files_uploader` (`uploader_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_idempotency` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `method` varchar(8) NOT NULL, + `route` varchar(256) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `body_hash` char(64) NOT NULL, + `result_code` int NOT NULL DEFAULT '0', + `result_body` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_idempotency_request` (`user_id`,`method`,`route`,`idempotency_key`), + KEY `idx_im_group_idempotency_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_invitations` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `invitation_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `inviter_user_id` bigint NOT NULL, + `target_user_id` bigint NOT NULL, + `message` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `result` varchar(32) NOT NULL DEFAULT '', + `join_request_id` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `expires_at` datetime(3) NOT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_invitations_invitation` (`invitation_id`), + UNIQUE KEY `uk_im_group_invitations_idempotency` (`inviter_user_id`,`idempotency_key`), + KEY `idx_im_group_invitations_target` (`target_user_id`,`status`,`expires_at`), + KEY `idx_im_group_invitations_topic_status` (`topic`,`status`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_join_requests` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `request_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `applicant_user_id` bigint NOT NULL, + `inviter_user_id` bigint DEFAULT NULL, + `source` varchar(32) NOT NULL DEFAULT 'manual', + `qr_token_hash` char(64) NOT NULL DEFAULT '', + `message` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `reject_reason` varchar(256) NOT NULL DEFAULT '', + `handled_by` bigint DEFAULT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `expires_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_join_requests_request` (`request_id`), + UNIQUE KEY `uk_im_group_join_requests_idempotency` (`applicant_user_id`,`idempotency_key`), + KEY `idx_im_group_join_requests_topic_status` (`topic`,`status`,`id`), + KEY `idx_im_group_join_requests_applicant` (`applicant_user_id`,`topic`,`status`,`id`), + KEY `idx_im_group_join_requests_expire` (`status`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_members` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `role` varchar(16) NOT NULL DEFAULT 'member', + `member_status` varchar(16) NOT NULL DEFAULT 'active', + `nickname` varchar(64) NOT NULL DEFAULT '', + `pinned` tinyint NOT NULL DEFAULT '0', + `mute_notifications` tinyint NOT NULL DEFAULT '0', + `join_source` varchar(32) NOT NULL DEFAULT 'manual', + `joined_by` bigint DEFAULT NULL, + `joined_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `left_at` datetime(3) DEFAULT NULL, + `removed_by` bigint DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_members_topic_user` (`topic`,`user_id`), + KEY `idx_im_group_members_user_status` (`user_id`,`member_status`,`updated_at`), + KEY `idx_im_group_members_topic_role` (`topic`,`role`,`member_status`,`id`), + KEY `idx_im_group_members_topic_status` (`topic`,`member_status`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_messages` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `topic` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '群 Topic,如 grp_xxx', + `group_id` bigint NOT NULL COMMENT '业务群 ID,便于分区、治理和统计', + `seqid` bigint NOT NULL COMMENT '群内消息序号,由 Redis 原子发号', + `from_user` bigint NOT NULL COMMENT '发送人用户 ID', + `client_msg_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户端幂等消息 ID', + `msg_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked', + `content` json DEFAULT NULL COMMENT '消息正文或 Drafty 内容', + `head` json DEFAULT NULL COMMENT '消息头,包含 @、引用、客户端能力等', + `mentioned_user_ids` json DEFAULT NULL COMMENT '@ 用户列表,便于提醒和检索', + `mention_all` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否 @ 全体', + `reply_seqid` bigint DEFAULT NULL COMMENT '回复的群消息 seq', + `root_seqid` bigint DEFAULT NULL COMMENT '线程或合并上下文根消息 seq', + `file_count` int NOT NULL DEFAULT '0' COMMENT '附件数量', + `content_size` int NOT NULL DEFAULT '0' COMMENT '正文 JSON 序列化后的字节数', + `has_risk` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否命中风控或内容安全', + `audit_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject', + `version` int NOT NULL DEFAULT '1' COMMENT '编辑版本', + `recalled_at` datetime(3) DEFAULT NULL, + `recalled_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `expires_at` datetime(3) DEFAULT NULL COMMENT '可选保留期,便于过期归档或清理', + `created_at` datetime(3) NOT NULL, + `updated_at` datetime(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_messages_group_seq` (`group_id`,`seqid`), + UNIQUE KEY `uk_im_group_messages_topic_seq` (`topic`,`seqid`), + UNIQUE KEY `uk_im_group_messages_topic_sender_client_mid` (`topic`,`from_user`,`client_msg_id`), + KEY `idx_im_group_messages_group_created` (`group_id`,`created_at`), + KEY `idx_im_group_messages_group_status_seq` (`group_id`,`status`,`seqid`), + KEY `idx_im_group_messages_topic_created` (`topic`,`created_at`), + KEY `idx_im_group_messages_topic_from_seq` (`topic`,`from_user`,`seqid`), + KEY `idx_im_group_messages_audit` (`audit_status`,`created_at`), + KEY `idx_im_group_messages_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `im_group_mutes` ( + `id` bigint NOT NULL, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `muted_by` bigint NOT NULL, + `muted_at` datetime(3) NOT NULL, + `expires_at` datetime(3) DEFAULT NULL, + `reason` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'active', + `revoked_by` bigint DEFAULT NULL, + `revoked_at` datetime(3) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_mutes_topic_user` (`topic`,`user_id`), + KEY `idx_im_group_mutes_topic_status` (`topic`,`status`,`expires_at`), + KEY `idx_im_group_mutes_user_status` (`user_id`,`status`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_qrcodes` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `token_id` varchar(64) NOT NULL, + `token_hash` char(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `created_by` bigint NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'active', + `expires_at` datetime(3) NOT NULL, + `revoked_by` bigint DEFAULT NULL, + `revoked_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_qrcodes_token` (`token_id`), + UNIQUE KEY `uk_im_group_qrcodes_hash` (`token_hash`), + KEY `idx_im_group_qrcodes_topic_status` (`topic`,`status`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_settings` ( + `id` bigint NOT NULL, + `topic` varchar(32) NOT NULL, + `owner_user_id` bigint NOT NULL, + `name` varchar(128) NOT NULL DEFAULT '', + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `description` varchar(512) NOT NULL DEFAULT '', + `group_status` varchar(16) NOT NULL DEFAULT 'active', + `join_approval` tinyint NOT NULL DEFAULT '0', + `mute_all` tinyint NOT NULL DEFAULT '0', + `member_upload_files` tinyint NOT NULL DEFAULT '1', + `member_private_chat` tinyint NOT NULL DEFAULT '1', + `member_invite` tinyint NOT NULL DEFAULT '1', + `max_member_count` int NOT NULL DEFAULT '0' COMMENT '0 means use tenant default or unlimited', + `max_admin_count` int NOT NULL DEFAULT '10', + `member_count` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `created_by` bigint NOT NULL, + `dissolved_by` bigint DEFAULT NULL, + `dissolved_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_settings_topic` (`topic`), + KEY `idx_im_group_settings_owner` (`owner_user_id`,`group_status`), + KEY `idx_im_group_settings_status_updated` (`group_status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_http_idempotency` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `method` varchar(8) NOT NULL, + `route` varchar(512) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `body_hash` char(64) NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'processing', + `response_status` int NOT NULL DEFAULT '0', + `response_body` mediumblob, + `expires_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_http_idempotency_scope` (`user_id`,`method`,`route`,`idempotency_key`), + KEY `idx_im_http_idempotency_expiry` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_consume_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `consumer_group` varchar(128) NOT NULL, + `source_topic` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL DEFAULT '', + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'processing', + `retry_count` int NOT NULL DEFAULT '0', + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `processing_deadline` datetime(3) DEFAULT NULL, + `succeeded_at` datetime(3) DEFAULT NULL, + `dead_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_consume_log_event_group` (`event_id`,`consumer_group`), + KEY `idx_im_kafka_consume_log_status` (`status`,`processing_deadline`), + KEY `idx_im_kafka_consume_log_biz` (`biz_type`,`biz_id`), + KEY `idx_im_kafka_consume_log_topic` (`source_topic`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_dead_letters` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `dead_id` varchar(96) NOT NULL, + `event_id` varchar(96) NOT NULL, + `parent_event_id` varchar(96) NOT NULL DEFAULT '', + `source_topic` varchar(128) NOT NULL, + `consumer_group` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL DEFAULT '', + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `envelope` json NOT NULL, + `retry_count` int NOT NULL DEFAULT '0', + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `status` varchar(24) NOT NULL DEFAULT 'dead', + `replay_count` int NOT NULL DEFAULT '0', + `resolved_by` varchar(64) NOT NULL DEFAULT '', + `resolved_at` datetime(3) DEFAULT NULL, + `resolve_note` varchar(1000) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_dead_letters_dead` (`dead_id`), + KEY `idx_im_kafka_dead_letters_event` (`event_id`), + KEY `idx_im_kafka_dead_letters_status` (`status`,`created_at`), + KEY `idx_im_kafka_dead_letters_biz` (`biz_type`,`biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `topic` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL, + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `key_value` varchar(256) NOT NULL DEFAULT '', + `envelope` json NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `sent_at` datetime(3) DEFAULT NULL, + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_outbox_event` (`event_id`), + KEY `idx_im_kafka_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_kafka_outbox_biz` (`biz_type`,`biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kvmeta` ( + `id` bigint NOT NULL, + `key` varchar(64) NOT NULL, + `createdat` datetime(3) DEFAULT NULL, + `value` text, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kvmeta_key` (`key`), + KEY `kvmeta_createdat_key` (`createdat`,`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_login_failure_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint DEFAULT NULL, + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `failure_type` varchar(32) NOT NULL, + `reason` varchar(96) NOT NULL DEFAULT '', + `detail` varchar(500) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `request_id` varchar(64) NOT NULL DEFAULT '', + `attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_login_failure_account_time` (`account_hash`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_user_time` (`user_id`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_ip_time` (`ip`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_type_time` (`failure_type`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_mailbox_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `enabled` tinyint NOT NULL DEFAULT '1', + `allow_anonymous` tinyint NOT NULL DEFAULT '1', + `required_fields` json DEFAULT NULL, + `max_length` int NOT NULL DEFAULT '5000', + `attachment_policy` json DEFAULT NULL, + `notice_title` varchar(128) NOT NULL DEFAULT '', + `notice_content` text NOT NULL, + `notice_updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_mailbox_submissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `submission_id` varchar(64) NOT NULL, + `sender_id` bigint NOT NULL, + `subject` varchar(200) NOT NULL, + `content` text NOT NULL, + `anonymous` tinyint NOT NULL DEFAULT '0', + `attachment_file_ids` json DEFAULT NULL, + `notice_version` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `submitted_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_mailbox_submissions_submission` (`submission_id`), + UNIQUE KEY `uk_im_mailbox_submissions_idempotency` (`sender_id`,`idempotency_key`), + KEY `idx_im_mailbox_submissions_sender` (`sender_id`,`id` DESC), + KEY `idx_im_mailbox_submissions_status` (`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meeting_note_files` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `note_file_id` varchar(64) NOT NULL, + `meeting_id` varchar(64) NOT NULL, + `file_id` varchar(64) NOT NULL, + `uploader_id` bigint NOT NULL, + `file_name` varchar(255) NOT NULL DEFAULT '', + `mime_type` varchar(128) NOT NULL DEFAULT '', + `file_size` bigint NOT NULL DEFAULT '0', + `file_etag` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'active', + `preview_status` varchar(16) NOT NULL DEFAULT 'ready', + `preview_mode` varchar(16) NOT NULL DEFAULT 'native', + `preview_file_id` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_meeting_note_files_id` (`note_file_id`), + UNIQUE KEY `uk_im_meeting_note_files_idempotency` (`meeting_id`,`uploader_id`,`idempotency_key`), + KEY `idx_im_meeting_note_files_file` (`meeting_id`,`file_id`,`status`), + KEY `idx_im_meeting_note_files_meeting` (`meeting_id`,`status`,`id` DESC), + KEY `idx_im_meeting_note_files_uploader` (`uploader_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meeting_participants` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `meeting_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `response` varchar(16) NOT NULL DEFAULT 'pending', + `response_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_meeting_participants_user` (`meeting_id`,`user_id`), + KEY `idx_im_meeting_participants_user_time` (`user_id`,`meeting_id`), + KEY `idx_im_meeting_participants_response` (`meeting_id`,`response`), + CONSTRAINT `fk_im_meeting_participants_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `im_meetings` (`meeting_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meetings` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `meeting_id` varchar(64) NOT NULL, + `organizer_id` bigint NOT NULL, + `recorder_user_id` bigint NOT NULL DEFAULT '0', + `title` varchar(200) NOT NULL, + `start_at` datetime(3) NOT NULL, + `end_at` datetime(3) NOT NULL, + `location` varchar(256) NOT NULL DEFAULT '', + `state` varchar(16) NOT NULL DEFAULT 'scheduled', + `reminder_minutes` json DEFAULT NULL, + `idempotency_key` varchar(128) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `canceled_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_meetings_meeting` (`meeting_id`), + UNIQUE KEY `uk_im_meetings_idempotency` (`organizer_id`,`idempotency_key`), + KEY `idx_im_meetings_organizer` (`organizer_id`,`start_at`), + KEY `idx_im_meetings_state_time` (`state`,`start_at`), + KEY `idx_im_meetings_recorder` (`recorder_user_id`,`end_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_message_admin_actions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `action_id` varchar(96) NOT NULL, + `command_id` varchar(96) NOT NULL, + `conversation_type` varchar(16) NOT NULL, + `topic` varchar(64) NOT NULL, + `seqid` bigint NOT NULL, + `sender_user_id` bigint DEFAULT NULL, + `action` varchar(16) NOT NULL, + `action_status` varchar(16) NOT NULL DEFAULT 'pending', + `reason` varchar(500) NOT NULL DEFAULT '', + `actor_admin_id` bigint NOT NULL DEFAULT '0', + `request_id` varchar(64) NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `failure_reason` varchar(96) NOT NULL DEFAULT '', + `failure_message` varchar(1000) NOT NULL DEFAULT '', + `processed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_admin_action_id` (`action_id`), + UNIQUE KEY `uk_im_message_admin_command` (`command_id`), + KEY `idx_im_message_admin_target` (`conversation_type`,`topic`,`seqid`,`id` DESC), + KEY `idx_im_message_admin_status` (`action_status`,`created_at`,`id`), + KEY `idx_im_message_admin_actor` (`actor_admin_id`,`created_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_message_revisions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `topic` char(25) NOT NULL, + `seqid` bigint NOT NULL, + `version` int NOT NULL, + `actor` bigint NOT NULL, + `head` json DEFAULT NULL, + `content` json DEFAULT NULL, + `reason` varchar(256) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_revisions_version` (`topic`,`seqid`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_messages` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `delid` int DEFAULT '0', + `seqid` bigint NOT NULL, + `topic` char(25) NOT NULL, + `from` bigint NOT NULL, + `client_msg_id` varchar(64) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `recalled_at` datetime(3) DEFAULT NULL, + `recalled_by` bigint DEFAULT NULL, + `head` json DEFAULT NULL, + `content` json DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_monthly_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `author_id` bigint NOT NULL, + `report_month` char(7) NOT NULL, + `work_summary` text NOT NULL, + `next_plan` text NOT NULL, + `attachments` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'draft', + `read_state` varchar(16) NOT NULL DEFAULT 'unread', + `version` int NOT NULL DEFAULT '1', + `submitted_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_monthly_reports_report` (`report_id`), + UNIQUE KEY `uk_im_monthly_reports_author_month` (`author_id`,`report_month`), + KEY `idx_im_monthly_reports_author_id` (`author_id`,`id` DESC), + KEY `idx_im_monthly_reports_month_status` (`report_month`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_departments` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `department_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `parent_id` varchar(64) NOT NULL DEFAULT 'root', + `sort_order` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `state` varchar(16) NOT NULL DEFAULT 'active', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_org_departments_department` (`department_id`), + KEY `idx_im_org_departments_parent` (`parent_id`,`state`,`sort_order`,`id`), + KEY `idx_im_org_departments_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_members` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `department_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `display_name` varchar(128) NOT NULL DEFAULT '', + `position` varchar(128) NOT NULL DEFAULT '', + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `mobile` varchar(32) NOT NULL DEFAULT '', + `email` varchar(128) NOT NULL DEFAULT '', + `employment_state` varchar(16) NOT NULL DEFAULT 'active', + `contactable` tinyint NOT NULL DEFAULT '1', + `sort_order` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_org_members_department_user` (`department_id`,`user_id`), + KEY `idx_im_org_members_department` (`department_id`,`employment_state`,`sort_order`,`id`), + KEY `idx_im_org_members_user` (`user_id`), + KEY `idx_im_org_members_display_name` (`display_name`), + CONSTRAINT `fk_im_org_members_department` FOREIGN KEY (`department_id`) REFERENCES `im_org_departments` (`department_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_visibility_overrides` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `visibility_scope` varchar(16) NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `remark` varchar(256) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_org_visibility_user` (`user_id`), + KEY `idx_im_org_visibility_scope` (`visibility_scope`,`state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_realname_appeals` ( + `id` bigint NOT NULL, + `appeal_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `reason` varchar(500) NOT NULL DEFAULT '', + `attachments_json` json DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `review_comment` varchar(500) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_realname_appeals_appeal` (`appeal_id`), + KEY `idx_im_realname_appeals_user` (`user_id`,`created_at`), + KEY `idx_im_realname_appeals_status` (`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_realname_verifications` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `submission_id` varchar(64) NOT NULL, + `status` varchar(16) NOT NULL, + `name_cipher` text NOT NULL, + `id_type` varchar(32) NOT NULL, + `id_number_cipher` text NOT NULL, + `id_number_hash` char(64) NOT NULL, + `masked_name` varchar(64) NOT NULL, + `masked_id_number` varchar(64) NOT NULL, + `review_reason` varchar(256) DEFAULT NULL, + `verified_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_realname_submission` (`submission_id`), + KEY `idx_im_realname_user_latest` (`user_id`,`created_at`), + KEY `idx_im_realname_id_hash` (`id_number_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_registration_invite_uses` ( + `id` bigint NOT NULL, + `invite_code_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `request_id` varchar(64) NOT NULL, + `used_at` datetime(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_registration_invite_use_user` (`user_id`), + KEY `idx_im_registration_invite_use_request` (`request_id`), + KEY `idx_im_registration_invite_use_code` (`invite_code_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_risk_challenges` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `challenge_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `action` varchar(64) NOT NULL, + `level` varchar(16) NOT NULL, + `notice_code` varchar(64) NOT NULL, + `notice_params` json DEFAULT NULL, + `state` varchar(16) NOT NULL DEFAULT 'pending', + `expires_at` datetime(3) NOT NULL, + `decision` varchar(16) NOT NULL DEFAULT '', + `action_token_id` varchar(64) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_risk_challenges_challenge` (`challenge_id`), + KEY `idx_im_risk_challenges_user_state` (`user_id`,`state`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_subscriptions` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `userid` bigint NOT NULL, + `topic` char(25) NOT NULL, + `delid` int DEFAULT '0', + `recvseqid` bigint DEFAULT '0', + `readseqid` bigint DEFAULT '0', + `modewant` char(8) DEFAULT NULL, + `modegiven` char(8) DEFAULT NULL, + `private` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `subscriptions_topic` (`topic`), + KEY `subscriptions_deletedat` (`deletedat`), + KEY `subscriptions_userid_topic_deletedat` (`userid`,`topic`,`deletedat`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_system_notifications` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `event_id` varchar(64) NOT NULL, + `event_type` varchar(64) NOT NULL, + `title` varchar(128) NOT NULL, + `summary` varchar(512) NOT NULL, + `action` json DEFAULT NULL, + `read_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_system_notifications_event` (`user_id`,`event_id`), + KEY `idx_im_system_notifications_inbox` (`user_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topic_drafts` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `topic` varchar(128) NOT NULL, + `content` text, + `entities_json` json DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_topic_drafts_user_topic` (`user_id`,`topic`), + KEY `idx_im_topic_drafts_user_updated` (`user_id`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topics` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `state` smallint NOT NULL DEFAULT '0', + `stateat` datetime(3) DEFAULT NULL, + `touchedat` datetime(3) DEFAULT NULL, + `name` char(25) NOT NULL, + `usebt` tinyint DEFAULT '0', + `owner` bigint NOT NULL DEFAULT '0', + `access` json DEFAULT NULL, + `seqid` bigint NOT NULL DEFAULT '0', + `delid` int DEFAULT '0', + `subcnt` int DEFAULT '0', + `public` json DEFAULT NULL, + `trusted` json DEFAULT NULL, + `tags` json DEFAULT NULL, + `aux` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `topics_owner` (`owner`), + KEY `topics_state_stateat` (`state`,`stateat`), + KEY `topics_name_state_seqid` (`name`,`state`,`seqid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topictags` ( + `id` int NOT NULL AUTO_INCREMENT, + `topic` char(25) NOT NULL, + `tag` varchar(96) NOT NULL, + PRIMARY KEY (`id`), + KEY `topictags_tag` (`tag`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_blocks` ( + `id` bigint NOT NULL, + `blocker_id` bigint NOT NULL, + `blocked_id` bigint NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_blocks_pair` (`blocker_id`,`blocked_id`), + KEY `idx_im_user_blocks_blocked` (`blocked_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_ip_whitelists` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `ip_cidr` varchar(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR', + `status` tinyint NOT NULL DEFAULT '1' COMMENT '1 enabled, 2 disabled', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT '0', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT '0', + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_ip_whitelist` (`user_id`,`ip_cidr`), + KEY `idx_im_user_ip_whitelist_user` (`user_id`,`status`), + KEY `idx_im_user_ip_whitelist_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Per-user login IP whitelist'; + +CREATE TABLE IF NOT EXISTS `im_user_login_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `username` varchar(64) NOT NULL DEFAULT '', + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `login_type` varchar(24) NOT NULL, + `event_type` varchar(24) NOT NULL DEFAULT 'login', + `session_id` varchar(96) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'low', + `request_id` varchar(64) NOT NULL DEFAULT '', + `login_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `logout_at` datetime(3) DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_user_login_user_time` (`user_id`,`login_at` DESC,`id` DESC), + KEY `idx_im_user_login_ip_time` (`ip`,`login_at` DESC,`id` DESC), + KEY `idx_im_user_login_device_time` (`device_id`(64),`login_at` DESC), + KEY `idx_im_user_login_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_privacy_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `add_me_methods` json DEFAULT NULL, + `profile_visibility` varchar(32) NOT NULL DEFAULT 'friends', + `last_seen_visibility` varchar(32) NOT NULL DEFAULT 'friends', + `read_receipts` tinyint NOT NULL DEFAULT '1', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_privacy_settings_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_profiles` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `nickname` varchar(64) NOT NULL DEFAULT '', + `birth_date` date DEFAULT NULL, + `signature` varchar(256) NOT NULL DEFAULT '', + `gender` varchar(16) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `email` varchar(128) NOT NULL DEFAULT '', + `public_account` varchar(64) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_profiles_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_security` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `auth_version` int NOT NULL DEFAULT '1', + `password_updated_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_security_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_users` ( + `id` bigint NOT NULL, + `account_no` int unsigned NOT NULL DEFAULT '0' COMMENT '用户编号', + `username` varchar(32) NOT NULL, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `state` smallint NOT NULL DEFAULT '0', + `stateat` datetime(3) DEFAULT NULL, + `access` json DEFAULT NULL, + `lastseen` datetime DEFAULT NULL, + `useragent` varchar(255) DEFAULT '', + `phone` varchar(32) DEFAULT NULL, + `public` json DEFAULT NULL, + `trusted` json DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `users_username` (`username`), + UNIQUE KEY `uk_im_users_account_no` (`account_no`), + UNIQUE KEY `users_phone` (`phone`), + KEY `users_state_stateat` (`state`,`stateat`), + KEY `users_lastseen_updatedat` (`lastseen`,`updatedat`), + KEY `idx_im_users_account_no` (`account_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_verification_codes` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `challenge_id` varchar(64) NOT NULL, + `scene` varchar(32) NOT NULL, + `action` varchar(64) NOT NULL DEFAULT '', + `phone_hash` char(64) NOT NULL, + `phone_mask` varchar(32) NOT NULL, + `target_user` bigint DEFAULT NULL, + `code_hash` char(64) NOT NULL, + `verification_token_hash` char(64) DEFAULT NULL, + `state` smallint NOT NULL, + `attempts` int NOT NULL DEFAULT '0', + `max_attempts` int NOT NULL, + `expires_at` datetime(3) NOT NULL, + `resend_after` datetime(3) NOT NULL, + `verified_at` datetime(3) DEFAULT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_verification_challenge` (`challenge_id`), + KEY `idx_im_verification_scene_phone_state` (`scene`,`phone_hash`,`state`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_workbench_apps` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `code` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `icon_file_id` varchar(64) NOT NULL DEFAULT '', + `route` varchar(128) NOT NULL, + `sort_order` int NOT NULL DEFAULT '0', + `unread_key` varchar(64) NOT NULL DEFAULT '', + `enabled` tinyint NOT NULL DEFAULT '1', + `permission` varchar(64) NOT NULL DEFAULT 'enabled', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_workbench_apps_code` (`code`), + KEY `idx_im_workbench_apps_enabled_order` (`enabled`,`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_youth_mode_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `purpose` varchar(64) NOT NULL, + `password_hash` varbinary(255) NOT NULL, + `enabled` tinyint NOT NULL DEFAULT '0', + `policy_version` int NOT NULL DEFAULT '1', + `restrictions` json DEFAULT NULL, + `enabled_at` datetime(3) DEFAULT NULL, + `failed_attempts` int NOT NULL DEFAULT '0', + `locked_until` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_youth_mode_settings_user_purpose` (`user_id`,`purpose`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 2. Tenant business seed data. +-- ============================================================ + +-- Tinode schema version expected by the current MySQL adapter. +INSERT INTO `im_kvmeta` (`id`, `key`, `createdat`, `value`) +VALUES (1, 'version', CURRENT_TIMESTAMP(3), 123) +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); + +-- Workbench default apps. These are product navigation entries, not tenant business data. +INSERT INTO `im_workbench_apps` (`code`, `name`, `route`, `sort_order`, `unread_key`, `enabled`, `permission`) +VALUES + ('attendance', '打卡', '/attendance', 10, 'attendance', 1, 'enabled'), + ('monthly_reports', '月报', '/reports/monthly', 20, 'monthly_reports', 1, 'enabled'), + ('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), + ('organization', '部门框架', '/organization', 40, 'organization', 1, 'enabled'), + ('executive_mailbox', '举报信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled'), + ('meetings', '会议通知', '/meetings', 60, 'meetings', 1, 'enabled'), + ('meeting_notes', '会议笔记', '/meeting-notes', 70, '', 1, 'enabled') +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `route` = VALUES(`route`), + `sort_order` = VALUES(`sort_order`), + `unread_key` = VALUES(`unread_key`), + `enabled` = VALUES(`enabled`), + `permission` = VALUES(`permission`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default attendance settings. Tenants can edit or replace them later. +INSERT INTO `im_attendance_rules` (`rule_id`, `name`, `workday`, `location_required`, `wifi_required`, `allow_outside_checkin`, `appeal_allowed`) +VALUES ('att_rule_default', '默认考勤规则', 1, 0, 0, 1, 1) +ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `im_attendance_shifts` (`shift_id`, `rule_id`, `name`, `work_start`, `work_end`, `checkin_start`, `checkout_end`, `sort_order`) +VALUES ('shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:30:00', '20:00:00', 10) +ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default mailbox configuration. +INSERT INTO `im_mailbox_config` (`id`, `enabled`, `allow_anonymous`, `required_fields`, `max_length`, `attachment_policy`, `notice_title`, `notice_content`) +VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') +ON DUPLICATE KEY UPDATE + `enabled` = VALUES(`enabled`), + `allow_anonymous` = VALUES(`allow_anonymous`), + `required_fields` = VALUES(`required_fields`), + `max_length` = VALUES(`max_length`), + `attachment_policy` = VALUES(`attachment_policy`), + `notice_title` = VALUES(`notice_title`), + `notice_content` = VALUES(`notice_content`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default abuse report reasons. +INSERT INTO `im_abuse_reasons` (`target_type`, `reason_code`, `label`, `requires_description`, `allows_evidence`, `sort_order`) +VALUES + ('user', 'fraud', '诈骗欺诈', 0, 1, 10), + ('user', 'harassment', '骚扰辱骂', 0, 1, 20), + ('user', 'illegal', '违法违规', 1, 1, 30), + ('group', 'fraud', '诈骗欺诈', 0, 1, 10), + ('group', 'spam', '垃圾广告', 0, 1, 20), + ('group', 'illegal', '违法违规', 1, 1, 30), + ('message', 'fraud', '诈骗欺诈', 0, 1, 10), + ('message', 'spam', '垃圾广告', 0, 1, 20), + ('message', 'harassment', '骚扰辱骂', 0, 1, 30), + ('message', 'illegal', '违法违规', 1, 1, 40) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `requires_description` = VALUES(`requires_description`), + `allows_evidence` = VALUES(`allows_evidence`), + `sort_order` = VALUES(`sort_order`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default feedback configuration. +INSERT INTO `im_feedback_config` (`id`, `categories`, `max_length`, `attachment_policy`, `allow_contact`, `diagnostics_notice`) +VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。') +ON DUPLICATE KEY UPDATE + `categories` = VALUES(`categories`), + `max_length` = VALUES(`max_length`), + `attachment_policy` = VALUES(`attachment_policy`), + `allow_contact` = VALUES(`allow_contact`), + `diagnostics_notice` = VALUES(`diagnostics_notice`), + `updated_at` = CURRENT_TIMESTAMP(3); + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- 3. Control database registry seed. +-- ============================================================ +-- Keep this section in the same change set as tenant DB initialization. +-- If the tenant id is generated by Admin Server, replace __TENANT_ID__ with the generated id. + +INSERT INTO `hotgo`.`im_tenant` (`id`, `code`, `name`, `tenant_desc`, `db_name`, `db_dsn`, `state`, `created_by`, `updated_by`) +VALUES (__TENANT_ID__, '__TENANT_CODE__', '__TENANT_NAME__', NULL, '__TENANT_DB__', '', 1, 0, 0) +ON DUPLICATE KEY UPDATE + `code` = VALUES(`code`), + `name` = VALUES(`name`), + `db_name` = VALUES(`db_name`), + `state` = VALUES(`state`), + `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `hotgo`.`im_tenant_config` (`tenant_id`, `max_users`, `max_groups`, `max_group_members`, `created_by`, `updated_by`) +VALUES (__TENANT_ID__, 0, 0, 0, 0, 0) +ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); diff --git a/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md b/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md new file mode 100644 index 0000000..db8f507 --- /dev/null +++ b/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md @@ -0,0 +1,570 @@ +# 服务端消息加密与内容审核改造文档 + +> 文档状态:待开发 +> 编制日期:2026-09-13 +> 适用项目:`im_manager`(Admin Server)、`im_server`(IM Server) +> 本文档是后续数据库设计、后端开发、联调、迁移和验收的共同依据。 + +## 1. 建设目标 + +本次改造同时建设两项能力: + +1. 内容审核:IM Server 在保存或修改内容前,根据商户策略执行本地敏感词检查和阿里云文本内容审核。 +2. 消息加密存储:审核通过的消息在 IM Server 内部使用 AES-256-GCM 加密,国内 IM 业务库只保存密文;经授权的后台查询由 IM Server 解密后返回明文。 + +本次不做以下内容: + +- 不做客户端 AES 二次加密。 +- 不做端到端加密(E2EE)。 +- 不改变现有 HTTPS/WSS 传输协议。 +- 不允许 Admin Server 直接读取 IM 数据库并自行解密。 +- 不在日志、Kafka 事件、死信或监控标签中保存消息明文。 + +## 2. 安全边界和部署前提 + +### 2.1 部署分工 + +| 区域 | 系统 | 保存数据 | +| --- | --- | --- | +| 国外 | HotGo / Admin Server | 商户审核策略、阿里云配置、字典根密钥、商户消息密钥 | +| 国内 | IM Server | 用户、会话、消息密文、审核结果和处置记录 | + +该方案主要防护目标是:任意一侧数据库单独泄露时,不能直接恢复消息明文。 + +已知边界:HotGo 数据库、IM 数据库和完整代码同时泄露不在本方案的主要防护范围内。 + +### 2.2 传输安全 + +- 客户端调用必须使用 `https://` 和 `wss://`。 +- 阿里云审核请求必须使用 HTTPS。 +- Admin Server 与 IM Server 的内部配置和查询接口必须使用 HTTPS,并保留现有签名鉴权;跨公网部署时建议叠加 mTLS 或固定出口 IP 白名单。 +- TLS 只解决传输保护,AES-256-GCM 解决消息落库后的静态保护。 + +## 3. 总体架构 + +```text +客户端 + | HTTPS/WSS明文业务数据(TLS通道内不可见) + v +IM Server + |-- 实时权限、宵禁、用户状态等校验 + |-- 获取商户审核策略 + |-- 本地敏感词补充检查 + |-- 阿里云 TextModerationPlus 审核 + |-- AES-256-GCM 加密 + `-- 密文写入国内IM库 + +Admin Server(国外HotGo) + |-- 管理阿里云配置 + |-- 管理商户审核策略 + |-- 管理商户消息密钥 + |-- 通过内部接口下发配置/密钥 + `-- 通过IM内部查询接口查看解密后消息 +``` + +## 4. 内容审核设计 + +### 4.1 审核范围 + +| 场景 | 阿里云 Service | 审核时机 | +| --- | --- | --- | +| 用户昵称 | `nickname_detection_pro` | 更新用户资料前 | +| 群昵称 | `nickname_detection_pro` | 更新群成员资料前 | +| 群名称 | `nickname_detection_pro` | 创建群或修改群资料前 | +| 用户签名 | `nickname_detection_pro` | 更新用户资料前 | +| 单聊文本 | `chat_detection_pro` | 消息入库及广播前 | +| 群聊文本 | `chat_detection_pro` | 消息入库及广播前 | +| 编辑后消息 | `chat_detection_pro` | 新版本入库前 | +| 群公告 | `chat_detection_pro` | 发布或修改前 | + +图片、视频和文件继续走文件审核链路,不属于本文本审核接口范围。 + +### 4.2 商户范围 + +- 第一阶段只为 `tenant.code=default` 的上架默认商户开启阿里云内容审核。 +- 其他团队商户默认关闭,不调用阿里云,不产生审核费用。 +- 数据模型保留按商户开启的能力,但只有平台管理员可变更。 +- 商户端管理员不得看到阿里云 AK/SK,不得修改平台级审核服务配置。 + +### 4.3 后台配置项 + +#### 4.3.1 阿里云服务配置 + +复用 `im_external_config` 的 `service=moderation`,由平台管理员维护: + +| 配置 | 说明 | +| --- | --- | +| `provider` | 固定为 `aliyun_green` | +| `endpoint` | 默认 `green-cip.cn-shanghai.aliyuncs.com` | +| `enabled` | 平台总开关 | +| `config_json.region_id` | 默认 `cn-shanghai` | +| `config_json.nickname_service` | 默认 `nickname_detection_pro` | +| `config_json.chat_service` | 默认 `chat_detection_pro` | +| `config_json.timeout_ms` | 单次请求超时,建议 1000~2000 ms | +| `config_json.failure_mode` | `allow` 或 `block` | +| `secret_json.access_key_id` | 只写不回显 | +| `secret_json.access_key_secret` | 只写不回显 | + +“检查配置”必须真实调用阿里云测试文本,不能只检查字段非空。返回仅包含成功与否、请求耗时、RequestID 和脱敏错误。 + +#### 4.3.2 商户审核策略 + +可在 `im_security_policy.sensitive_word_policy` 中保存,避免新增重复策略表。JSON 结构固定为: + +```json +{ + "enabled": true, + "aliyun_enabled": true, + "local_words_enabled": true, + "scenes": { + "user_nickname": true, + "user_signature": true, + "group_name": true, + "group_nickname": true, + "private_message": true, + "group_message": true, + "group_announcement": true + }, + "risk_actions": { + "high": "block", + "medium": "block", + "low": "audit_only", + "none": "allow" + }, + "failure_mode": "allow", + "block_message": "内容不符合平台规范,请修改后重试" +} +``` + +策略发布后使用现有 `im.config.event` 发布配置事件,IM Server 按商户清理本地缓存。 + +### 4.4 文本提取 + +审核输入不能直接对任意 JSON 执行字符串化,应按 Drafty 或消息类型提取用户可见文本: + +- 普通文本:提取完整文本。 +- Drafty 富文本:提取 `txt` 及按钮、链接等用户可见标题。 +- 图片/文件/语音:审核文件名、说明和附带文本,文件本体走文件审核。 +- 系统消息、音视频信令和已签名服务端消息不调用文本审核。 +- 空文本直接放行。 + +### 4.5 阿里云结果处置 + +IM Server 统一映射为: + +| 内部结果 | 动作 | +| --- | --- | +| `allow` | 继续加密、入库和广播 | +| `block` | 不入库、不广播,返回统一业务错误 | +| `audit_only` | 允许发送,但写入审核命中日志 | +| `retryable_error` | 根据 `failure_mode` 放行或拦截 | + +不只依赖 `RiskLevel/Label` 一个字段,需保留与验证阿里云原始结果中的扩展判断,针对引流、诈骗等样本建立回归测试集。 + +### 4.6 超时与降级 + +- 阿里云请求不得无限等待。 +- 建议默认 `failure_mode=allow`,阿里云异常时允许发送并记录告警,防止第三方故障造成 IM 全局不可用。 +- 连续失败达到阈值后进入短时熔断;熔断期间按失败策略处理。 +- 阿里云调用不得在数据库事务中执行。 + +## 5. 消息加密设计 + +### 5.1 算法 + +- 算法:AES-256-GCM。 +- DEK 长度:32 字节。 +- Nonce:每次加密使用 `crypto/rand` 生成的独立 12 字节随机值。 +- GCM 标签:使用 Go 标准库默认 16 字节认证标签。 +- 明文编码:将原 `content` JSON 规范化序列化为 UTF-8 字节。 +- 不允许固定 Nonce,不允许 AES-ECB,不使用未认证的 AES-CBC/CTR。 + +### 5.2 AAD + +每条消息加密时绑定以下 AAD: + +```text +tenant_id | topic | seqid | from | client_msg_id | key_version | content +``` + +`content` 是字面标识,用于与未来可能加密的 `head` 区分。AAD 按固定字段顺序和长度前缀编码,禁止使用可产生拼接歧义的简单字符串。 + +### 5.3 密钥层级 + +```text +HotGo字典根密钥(KEK) + -> 加密每个商户、每个版本的DEK + -> DEK加密该商户消息 +``` + +根密钥使用 HotGo `hg_sys_dict_data` 保存,但必须满足: + +1. 使用独立的内部字典类型,不出现在通用字典管理页。 +2. 后端字典列表、选项和导出接口必须对该类型做服务端拒绝,不能仅依赖菜单隐藏。 +3. 根密钥为 32 字节强随机值的 Base64 编码,不使用口令、UUID 或可读文本。 +4. 根密钥只由 Admin Server 密钥服务读取,不通过通用配置接口返回。 +5. 根密钥不写日志,不在后台页面回显,不进入 Kafka 消息。 + +### 5.4 商户密钥管理表 + +在 HotGo 库新增 `im_message_encryption_keys`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | bigint | 主键 | +| `tenant_id` | bigint | 商户 ID | +| `key_version` | int | 密钥版本,从 1 递增 | +| `algorithm` | varchar(32) | 固定 `AES-256-GCM` | +| `encrypted_dek` | blob | KEK 加密后的 DEK | +| `dek_nonce` | varbinary(12) | 加密 DEK 时的随机 Nonce | +| `status` | varchar(16) | `pending` / `active` / `retired` / `disabled` | +| `created_at` | datetime | 创建时间 | +| `activated_at` | datetime | 启用时间 | +| `retired_at` | datetime | 退役时间 | +| `created_by` | bigint | 操作人 | +| `remark` | varchar(255) | 备注 | + +约束: + +- 唯一键:`tenant_id + key_version`。 +- 每个商户同时只能有一把 `active` 密钥。 +- `retired` 密钥仍可用于解密历史消息,不得再用于新消息加密。 +- `disabled` 只用于安全事故应急,禁用前必须完成历史消息转密钥。 + +### 5.5 IM 消息表改造 + +`im_messages` 和 `im_message_revisions` 新增: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `content_ciphertext` | longblob | 消息密文,包含 GCM 认证标签 | +| `content_nonce` | varbinary(12) | 本条消息随机 Nonce | +| `key_version` | int | 商户 DEK 版本 | +| `encryption_alg` | varchar(32) | `AES-256-GCM` | +| `encrypted` | tinyint | `0` 明文兼容,`1` 密文 | + +迁移完成前保留现有 `content JSON`,规则如下: + +- 新写入:`content=NULL`、`encrypted=1`,只保存密文。 +- 旧数据:`content IS NOT NULL`、`encrypted=0`,按原逻辑读取。 +- 解密成功后还原为原 `any` JSON 结构,不修改对客户端的协议。 +- `head` 第一阶段不加密,但禁止在 `head` 中放置消息正文、联系方式等敏感明文。 + +`im_group_messages` 是群消息派生查询表,不得另存一份明文。实现时二选一: + +1. 推荐:仅保存索引、状态和统计字段,通过 `topic + seqid` 关联 `im_messages` 解密。 +2. 兼容:同步增加密文字段,但加密输入和密文必须与主消息表保持一致。 + +不允许 `im_messages` 已加密而 `im_group_messages.content` 仍保留明文。 + +### 5.6 加密和解密时机 + +#### 新消息 + +```text +校验消息 + -> 提取审核文本 + -> 内容审核 + -> 分配seqid/组装AAD + -> 序列化content + -> 使用当前active DEK加密 + -> 事务写入密文和消息元数据 + -> 向在线订阅者广播原内存明文 +``` + +#### 历史消息和离线同步 + +```text +从数据库读取密文 + -> 按tenant_id + key_version获取DEK + -> 重建AAD + -> GCM验签并解密 + -> JSON反序列化 + -> 按原Tinode协议返回 +``` + +任何验签失败均必须当作数据篡改或密钥错误,禁止返回部分内容或尝试忽略错误。 + +### 5.7 密钥获取和缓存 + +- IM Server 不得每发送一条消息都跨境请求 HotGo。 +- IM Server 按 `tenant_id + key_version` 在内存中缓存已解密 DEK。 +- 新消息使用当前 `active` 密钥,历史消息按记录中的 `key_version` 取密钥。 +- 密钥变更发布 `message.encryption.key.updated` 配置事件,IM Server 按商户刷新。 +- HotGo 短时不可用时继续使用内存密钥。 +- IM Server 重启后如无法取得当前密钥,该商户禁止写入新消息,不得降级为明文落库。 + +### 5.8 密钥轮换 + +1. Admin Server 生成新 DEK,使用 KEK 加密后以 `pending` 状态入库。 +2. 通知 IM Server 拉取并验证新密钥。 +3. 验证成功后,在事务中将旧密钥设为 `retired`,新密钥设为 `active`。 +4. 新消息立即使用新版本,旧消息仍按原版本解密。 +5. 历史转密钥作为单独低速任务执行,不阻塞即时通信。 + +## 6. Admin Server 改造 + +### 6.1 内容审核页面 + +将现有“审核插件”配置补齐为可用功能: + +- 阿里云基础配置。 +- 真实连通性检查。 +- 商户审核开关和场景开关。 +- 风险等级与处置动作映射。 +- 异常降级策略。 +- 配置发布版本和生效时间。 + +非默认商户显示“未开启内容审核”,不显示阿里云密钥编辑项。 + +### 6.2 消息加密管理页面 + +增加平台级“消息加密”页面,只允许最高权限平台管理员使用: + +- 查看商户加密状态、当前密钥版本和最后轮换时间。 +- 初始化商户密钥。 +- 启用/暂停商户消息加密。 +- 轮换密钥。 +- 查看历史迁移进度。 +- 密钥明文、根密钥和 `encrypted_dek` 均不展示。 + +### 6.3 内部接口 + +建议增加: + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/internal/im-config/tenants/{code}/moderation-policy` | IM Server 拉取商户审核策略 | +| GET | `/internal/im-config/moderation/provider` | IM Server 拉取阿里云配置 | +| GET | `/internal/im-config/tenants/{code}/message-keys/active` | 获取当前消息 DEK | +| GET | `/internal/im-config/tenants/{code}/message-keys/{version}` | 获取指定版本 DEK | +| POST | `/internal/im-config/tenants/{code}/message-keys/ack` | IM Server 确认新密钥可用 | + +密钥接口返回的是服务端使用的 DEK,不允许从浏览器或通用 Admin API 调用。接口必须校验服务身份、时间窗、签名、请求 ID 和重放。 + +### 6.4 后台消息查询 + +- Admin Server 继续调用 IM Server internal admin-query,不直连 IM 业务库解密。 +- IM Server 在商户数据权限通过后批量解密当页消息。 +- 每次查看明文记录管理员 ID、商户、IP、查询条件、返回数量、时间和 TraceID。 +- 列表只解密当页,禁止一次加载全部历史消息。 + +## 7. IM Server 改造 + +### 7.1 新增内部模块 + +```text +server/moderation/ + provider.go 审核Provider接口 + policy.go 商户策略和缓存 + text.go 文本提取与结果映射 + aliyun/text.go TextModerationPlus实现 + +server/messagecrypto/ + cipher.go AES-256-GCM加解密 + key_provider.go 密钥获取接口 + key_cache.go tenant/version内存缓存 + serializer.go content规范序列化 +``` + +不将加密和阿里云调用直接写进 MySQL adapter;adapter 只保存已加密的存储模型和解密所需元数据。 + +### 7.2 消息发送接入点 + +在 `Topic.handlePubBroadcast` 中,于 `saveAndBroadcastMessage` 之前执行: + +1. 过滤 WebRTC/系统信令等不审核内容。 +2. 提取文本。 +3. 根据商户策略执行审核。 +4. 拦截时通过 Tinode ctrl 回复业务错误,不进入保存和广播。 +5. 放行时进入加密保存。 + +进程内广播使用本次请求已有明文,不为广播执行“加密后立即解密”。 + +### 7.3 资料修改接入点 + +用户昵称/签名、群名称/公告、群昵称的 HTTP 和 Tinode meta 修改通道都必须接入同一 moderation service,不能只修改某一个 HTTP API 而留下协议绕过路径。 + +### 7.4 存储层改造 + +- `types.Message` 和 `types.MessageRevision` 增加密文及密钥元数据字段。 +- MySQL 的普通库和共享库/多商户 adapter 必须同时改造。 +- 新建库 schema、启动 ensure 迁移和手工 SQL 脚本必须保持一致。 +- Save、GetOne、GetAll、编辑、撤回、删除、历史修订、管理后台查询均需覆盖。 +- 解密层应在业务存储服务边界统一执行,避免上层遗漏。 + +### 7.5 缓存与多 Pod + +- 每个 Pod 保持本地密钥和审核策略缓存。 +- Kafka 配置事件使所有 Pod 同时失效。 +- 事件处理必须幂等,重复消息不得导致重复创建密钥。 +- 新旧密钥切换期间,所有 Pod 必须同时具备旧版本解密和新版本加密能力。 + +## 8. 审核和解密日志 + +在 IM 业务库增加 `im_content_moderation_logs`: + +| 字段 | 说明 | +| --- | --- | +| `id` | 主键 | +| `request_id` | 审核请求 ID | +| `scene` | 昵称、单聊、群聊等 | +| `topic` / `seqid` | 关联消息,拦截时 `seqid` 可为空 | +| `actor_id` | 内容提交人 | +| `provider` | `local` / `aliyun_green` | +| `provider_request_id` | 第三方 RequestID | +| `risk_level` | 统一风险等级 | +| `risk_labels` | 命中标签 JSON | +| `action` | `allow` / `block` / `audit_only` | +| `content_hash` | SHA-256 摘要,不保存明文 | +| `latency_ms` | 审核耗时 | +| `error_code` | 脱敏错误码 | +| `created_at` | 创建时间 | + +后台明文查看继续写入统一审计事件,必须区分“列表解密”、“详情解密”和“导出解密”。默认不提供明文批量导出;需要开放时必须增加独立权限和审批。 + +## 9. 关键词查询限制 + +消息加密后,MySQL 无法对密文执行原有 `LIKE '%keyword%'`。第一阶段采用: + +1. 后台查询必须先提供商户、时间范围,并尽量提供用户或群号。 +2. IM Server 先用明文元数据缩小候选集。 +3. 对有上限的候选集批量解密,再在内存中匹配关键词。 +4. 限制查询最大时间范围和候选消息数,超限返回明确提示。 + +不建议在第一阶段建设明文分词索引,否则会降低加密保护效果。若后续必须支持全库高性能检索,再单独评审 HMAC 盲索引方案。 + +## 10. 历史数据迁移 + +### 10.1 发布顺序 + +1. 发布 HotGo 表结构、根密钥和密钥管理能力。 +2. 为默认商户初始化第 1 版 DEK,但不开启加密。 +3. 发布 IM Server 双读兼容版本,验证密钥获取、加解密和内容审核。 +4. 开启新消息加密写入,保持旧数据明文可读。 +5. 启动历史数据分批迁移。 +6. 对比数量、抽样解密和 GCM 验签,完成后清空明文 `content`。 +7. 观察期结束后,再决定是否删除原明文字段。 + +### 10.2 迁移任务要求 + +- 按主键或 `topic + seqid` 分批,不长时锁表。 +- 每批使用独立事务,记录游标、成功数、失败数和最后错误。 +- 重复执行必须幂等,`encrypted=1` 的记录直接跳过。 +- 先写入密文并验证可解密,再将明文字段置空。 +- 失败记录保留明文并进入重试列表,不得丢失原数据。 +- 迁移速率可配置,并监控数据库负载和消息延迟。 + +## 11. 错误码和客户端兼容 + +客户端不参与加解密,不修改消息协议。新增业务错误建议: + +| 错误 | 说明 | 客户端处理 | +| --- | --- | --- | +| `CONTENT_REJECTED` | 内容审核拒绝 | 保留未发送文本并提示修改 | +| `CONTENT_REVIEW_UNAVAILABLE` | 审核不可用且策略要求拦截 | 提示稍后重试 | +| `MESSAGE_ENCRYPTION_UNAVAILABLE` | 未取得加密密钥 | 提示服务暂时不可用 | +| `MESSAGE_DECRYPT_FAILED` | 历史消息验签/解密失败 | 展示“消息暂时无法加载”,不展示乱码 | + +不向客户端返回阿里云原始错误、风险标签详情、密钥版本内部信息或解密堆栈。 + +## 12. 监控指标 + +### 12.1 内容审核 + +- 按商户/场景统计请求量、放行数、拦截数、降级数。 +- 阿里云耗时 P50/P95/P99。 +- 超时率、错误率、熔断状态。 +- 各风险等级和标签命中数。 + +### 12.2 消息加密 + +- 加密/解密成功数、失败数和耗时。 +- 各商户当前密钥版本。 +- 密钥获取成功率、缓存命中率和缓存最后刷新时间。 +- 明文历史记录剩余数、迁移速率和迁移失败数。 +- GCM 验签失败必须产生高优先级告警。 + +监控标签不得使用消息正文、密文、Nonce、DEK 或 KEK。 + +## 13. 验收标准 + +### 13.1 审核验收 + +- `default` 商户开启后,单聊、群聊、用户昵称、群名称和群昵称均实际调用对应阿里云 Service。 +- 其他商户关闭时不调用阿里云。 +- 拦截内容不进入 `im_messages` 和 `im_group_messages`,也不向订阅者广播。 +- 本地敏感词与阿里云开关可独立启停。 +- 阿里云超时严格按 `failure_mode` 处理。 +- 配置发布后所有 IM Pod 在规定时间内生效。 + +### 13.2 加密验收 + +- 开启商户的新消息在数据库中不存在可读正文。 +- 相同内容连续发送两次,Nonce 和密文必须不同。 +- 修改密文、Nonce、AAD 关联字段的任意一处后,解密必须失败。 +- 在线发送、离线同步、多端漫游、编辑、回复、撤回、删除保持原有业务行为。 +- Admin 有权限账号可查看解密明文,无权限账号被拒绝。 +- 查看明文必须形成完整审计记录。 +- 旧密钥退役后仍可解密旧版本消息,新消息只使用新版本。 +- HotGo 暂时断开时,已有内存密钥的 IM Pod 仍可工作;无密钥时绝不允许明文降级落库。 + +### 13.3 性能验收 + +- 纯 AES 加密不得成为消息链路主要瓶颈。 +- 密钥缓存命中时不发生跨境 HotGo 请求。 +- 审核关闭的商户不增加阿里云网络耗时。 +- 审核开启时应分别统计本地处理、阿里云调用和消息入库耗时。 +- 历史迁移期间即时消息发送和数据库负载不得超过上线阈值。 + +## 14. 开发分批建议 + +### 批次一:配置和密钥基础 + +- Admin 阿里云内容审核配置、商户策略、密钥表和密钥管理页。 +- 字典根密钥隔离和专用密钥服务。 +- Admin internal config/key API 及 Kafka 失效事件。 + +### 批次二:IM 文本审核 + +- 阿里云 `TextModerationPlus` Go 客户端。 +- 策略缓存、文本提取、消息和资料修改接入。 +- 审核日志、错误码、告警和回归测试。 + +### 批次三:新消息加密 + +- AES-256-GCM 模块、密钥缓存和存储模型。 +- 新消息加密写入和历史消息解密读取。 +- 单聊、群聊、修订、后台查询的一致性验证。 + +### 批次四:历史迁移和轮换 + +- 历史明文分批加密。 +- 迁移仪表盘、少量失败重试和数据对账。 +- 密钥轮换和历史转密钥测试。 +- 完成观察后清理明文。 + +## 15. 开发前必须确认的决策 + +| 决策 | 文档默认值 | +| --- | --- | +| 阿里云异常时放行还是拦截 | 放行并告警 | +| 是否审核系统消息 | 不审核 | +| 是否加密 `head` | 第一阶段不加密 | +| 第一阶段启用商户 | 仅 `code=default` | +| 是否允许无密钥时明文降级 | 不允许 | +| 后台是否允许明文导出 | 默认不允许 | +| 历史消息是否迁移 | 是,分批迁移 | +| 根密钥位置 | HotGo 内部字典数据,严禁通用接口暴露 | + +## 16. 现有实现对照 + +| 能力 | 现状 | 本次改造 | +| --- | --- | --- | +| 阿里云文本审核 | 文档已人工验证,IM Server 无 Go 调用实现 | 增加 TextModerationPlus 客户端和业务接入 | +| 本地敏感词 | Admin 已有 CRUD,IM Server 未消费 | 作为可选补充审核 | +| 审核配置 | `im_external_config` 已有 `moderation` 占位 | 增加真实检查、商户策略和发布 | +| 配置同步 | 已有 internal config 客户端、Kafka 配置事件和缓存失效 | 复用并扩展新的策略/密钥类型 | +| 消息存储 | `im_messages.content` 和修订表 `content` 为 JSON 明文 | 改为 AES-256-GCM 密文且双读迁移 | +| 群消息派生表 | `im_group_messages.content` 为 JSON 明文 | 去明文副本或同步密文化 | +| 后台消息查询 | IM internal-query 返回库内内容 | 增加权限校验、解密和查看审计 | diff --git a/docs/admin-adfahi-research/测试用例文档.md b/docs/admin-adfahi-research/测试用例文档.md new file mode 100644 index 0000000..92e1ec7 --- /dev/null +++ b/docs/admin-adfahi-research/测试用例文档.md @@ -0,0 +1,460 @@ +# 鸽达达管理后台改造测试用例 + +> 依据:`需求文档.md`、`Admin-Server改造文档.md`、`IM-Server改造文档.md`及当前 Admin Server、IM Server 代码。 +> 测试环境:`https://admin.118im.com/admin#/dashboard/console` +> 文档日期:2026-09-07 + +## 1. 测试目标与范围 + +本测试用于验证鸽达达管理后台改造后的页面操作、Admin Server 接口、IM Server 内部查询与命令执行是否一致,并重点验证以下闭环: + +1. 后台查询条件、表格字段、详情、导出及权限控制正确。 +2. Admin Server 能正确访问 IM Server,真实展示概览、消息和登录审计数据。 +3. 消息隐藏、删除经 Kafka 下发后由 IM Server 幂等执行并记录结果。 +4. 全局禁止加好友发布后,移动端所有加好友入口均受后端控制,不能绕过。 +5. 宵禁发布后,命中规则的单聊/私聊不能发送消息;配置群聊规则时群聊也必须受限。 +6. 登录成功、失败及退出均产生安全审计数据,且账号、口令和验证码等敏感信息不泄露。 + +### 1.1 本轮交付范围 + +| 页面/能力 | 本轮状态 | 测试结论口径 | +|---|---|---| +| 3.1 数据概览 | 已改造 | 本轮必测,需联调 Admin 与 IM | +| 3.3 用户分组 | Admin 已改造为用户策略分组 | 后台 CRUD 本轮必测;IM 策略消费待补齐 | +| 3.7 单聊消息 | 已改造 | 本轮必测,需验证 Kafka 处置闭环 | +| 3.8 群聊消息 | 已改造 | 本轮必测,需验证 Kafka 处置闭环 | +| 3.9 敏感词库 | Admin 管理端已改造 | 后台 CRUD 本轮必测;IM 运行时拦截待补齐 | +| 3.16 用户登录历史 | 已改造 | 本轮必测,数据来自 IM Server | +| 3.18 登录失败日志 | 已改造 | 本轮必测,包含加入 IP 黑名单、封禁用户 | +| 3.20 安全策略 | Admin 管理与发布已改造,IM 执行端未完成 | P0 验收目标;当前不得判定端到端通过 | +| 其余 3.2、3.4-3.6、3.10-3.15、3.17、3.19 | 原系统能力/后续改造 | 执行基础回归,不作为本轮新增功能全部交付的判定依据 | + +### 1.2 代码核查结论 + +本节是对当前代码的静态核查结论,不等同于测试环境已经执行通过: + +| 服务 | 已实现内容 | 当前缺口/测试关注点 | +|---|---|---| +| Admin Server | 数据概览、单聊/群聊消息查询与处置、用户登录历史、登录失败日志;用户策略分组、敏感词、安全策略和 IP 规则的管理页面、接口、权限与数据表 | 对 IM 的依赖异常、异步命令最终状态、租户隔离仍需按本文实测 | +| IM Server | 概览/趋势、单聊/群聊消息、登录成功/失败日志的 Internal Query;`message.hide`、`message.delete` 消费、幂等处置及动作落库;真实登录成功、失败和退出审计 | 未找到 `security.policy.updated`、用户策略分组、敏感词策略的运行时消费;未找到全局禁加好友、资料隐藏、二维码拦截和消息宵禁在 IM 请求链路的执行代码 | +| 联调结论 | 消息治理、概览和登录审计具备端到端测试基础 | 安全策略页面“发布成功”目前只能证明 Admin 保存并发出事件,不能证明移动端已生效;`FRIEND-*`、`CURFEW-*`、敏感词运行时用例预计失败或阻塞,须补齐 IM 实现后回归 | + +因此,测试报告必须分别记录“Admin 配置成功”“事件发布成功”“IM 接收成功”“移动端行为生效”四个结果,禁止合并为一个“发布成功”。 + +### 1.3 优先级与结果 + +| 标识 | 说明 | +|---|---| +| P0 | 核心链路或安全控制,失败时禁止上线 | +| P1 | 主要页面功能,失败时原则上阻断上线 | +| P2 | 辅助功能、兼容性或易用性问题 | +| 通过 | 实际结果与预期完全一致,接口、数据库及异步结果无异常 | +| 失败 | 页面、接口、数据库或 IM 最终行为任一不符合预期 | +| 阻塞 | 环境、账号、数据或依赖不可用,无法执行 | + +## 2. 测试准备 + +### 2.1 账号、权限与数据 + +准备以下数据,所有测试数据使用唯一前缀 `QA_YYYYMMDD_`: + +| 数据 | 用途 | +|---|---| +| 超级管理员 A | 验证全部菜单与操作权限 | +| 只读管理员 B | 仅授予列表、详情权限,验证写按钮隐藏和接口拒绝 | +| 租户管理员 C | 验证租户隔离,不得查看或操作其他租户数据 | +| 移动端用户 U1、U2、U3 | U1/U2 为普通用户,U3 用作好友或宵禁白名单 | +| 群组 G1 | U1、U2、U3 入群,用于群消息及群成员入口测试 | +| 消息样本 | 文本、图片、视频、语音、文件、名片、系统消息各至少 1 条 | +| 登录样本 | 密码成功、密码错误、账号不存在、账号禁用、退出各至少 1 条 | + +执行前确认: + +- Admin Server 的 IM Internal API 地址与共享密钥配置正确。 +- Admin Server 和 IM Server 的 Kafka broker、topic 和鉴权配置一致。 +- Admin 库已存在策略、敏感词、策略分组及消息命令相关表。 +- IM 库已存在 `im_user_login_logs`、`im_login_failure_logs`、`im_message_admin_actions`。 +- 浏览器清理旧缓存后重新登录,确认当前构建版本与待测提交一致。 + +### 2.2 通用检查 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| COM-001 | P0 | 使用超级管理员登录并展开 IM 管理菜单 | 已交付的 8 个功能域可见,页面可打开,无 404、白屏和控制台致命错误 | +| COM-002 | P0 | 使用只读管理员打开各页面 | 可查询和查看;新增、编辑、删除、发布、隐藏、封禁等无权限按钮不可见或禁用;直接请求写接口返回无权限 | +| COM-003 | P0 | 使用租户管理员查询列表、详情和导出 | 后端强制按当前租户过滤,修改 `tenantId` 不能越权读取或操作其他租户数据 | +| COM-004 | P1 | 对列表执行查询、重置、翻页、切换每页条数 | 查询条件生效;重置恢复默认值;总数和分页正确;每页超过 200 时接口明确报错 | +| COM-005 | P1 | 查询无匹配数据、模拟 IM 超时或不可用 | 空数据展示为空列表而非报错;依赖异常有明确提示,不把历史缓存伪装成实时成功 | +| COM-006 | P1 | 连续快速点击保存、发布、删除等按钮 | 提交期间按钮有加载/防重复状态,不产生重复记录或重复处置 | +| COM-007 | P1 | 检查新增写操作后的操作日志 | 操作者、租户、目标对象、动作、时间、结果和原因可追溯,不记录密码、验证码、Token 或完整敏感账号 | + +## 3. 页面测试用例 + +### 3.1 数据概览 + +当前按钮和字段:`刷新`,时间范围 `近7天/近14天/近30天`;指标包含总用户数、活跃用户、总群组数、总会话数、总消息数、今日消息;趋势表包含日期、新增用户、新增消息。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| DASH-001 | P0 | 打开数据概览 | 调用 `/imDashboard/overview` 和 `/imDashboard/trends`;六项指标展示真实数字,不显示 `undefined`、`NaN` 或静态占位值 | +| DASH-002 | P0 | 记录 IM 库统计结果后刷新页面 | 总用户、活跃用户、群组、会话、消息及今日消息与 IM Server 同口径统计一致 | +| DASH-003 | P1 | 分别选择近 7、14、30 天 | 趋势行数和日期区间正确,日期连续;切换后重新请求,新增用户和新增消息按日聚合 | +| DASH-004 | P1 | 在 IM 端新增用户并发送一条消息,再点刷新 | 对应总量和当日趋势增加;刷新按钮显示加载状态且不会重复并发请求 | +| DASH-005 | P1 | 检查跨天边界和无数据日期 | 按 `Asia/Shanghai` 自然日统计;无数据日期补 0,不遗漏日期 | +| DASH-006 | P0 | 使用租户管理员查看概览 | 只统计本租户数据;不能通过修改请求参数获得全局或其他租户数据 | + +### 3.2 用户管理 + +本页属于既有能力回归。重点回归查询、详情、状态修改、重置密码、IP 白名单、强制下线、注销、批量封禁、按 IP 封禁和导入。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| USER-001 | P1 | 按用户 ID、用户名、昵称、手机号、状态和时间查询 | 条件可组合,表格字段与详情一致,手机号等敏感字段按权限脱敏 | +| USER-002 | P1 | 新增或编辑用户,切换启用/禁用 | 校验必填项和唯一性;状态写入成功,禁用用户不能继续登录 | +| USER-003 | P1 | 执行重置密码、强制下线、注销 | 均需二次确认;成功后客户端会话失效;操作日志记录动作但不记录明文密码 | +| USER-004 | P1 | 执行批量封禁及按 IP 封禁 | 仅命中目标租户和目标用户;返回成功、跳过、失败数量,重复提交保持幂等 | + +### 3.3 用户分组 + +当前页面为“用户策略分组”,按钮包括新增/编辑、成员、启用/禁用、删除;成员弹窗支持添加成员、移除成员。表格重点字段:分组编码、分组名称、优先级、状态、版本、成员数、备注、更新时间。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| UPG-001 | P1 | 按租户、编码/名称关键字、状态查询并重置 | 条件生效;状态 `1=启用、2=禁用` 显示字典标签;成员数与成员关系表一致 | +| UPG-002 | P1 | 新增分组,填写合法编码、名称、优先级、策略 JSON、IP 白/黑名单 | 保存成功并生成版本;编码以字母开头且仅含字母、数字、下划线、中划线,长度 2-64 | +| UPG-003 | P1 | 输入数字开头、空名称、非法 JSON、超过 128 字名称 | 前后端拒绝保存并给出明确中文错误,数据库不产生脏数据 | +| UPG-004 | P1 | 编辑分组策略后保存 | 原记录更新,版本按实现规则变化;详情与列表一致;其他租户同编码记录不受影响 | +| UPG-005 | P1 | 点击状态开关切换启用/禁用 | 调用 `/imUserPolicyGroup/status`;页面状态即时刷新,非法状态值被后端拒绝 | +| UPG-006 | P1 | 打开成员列表,添加 U1,设置有效开始/结束时间 | 成员成功加入,成员数 +1;结束时间早于开始时间时拒绝保存 | +| UPG-007 | P1 | 重复添加同一用户,然后移除成员 | 不产生重复有效关系;移除后成员列表和成员数同步更新 | +| UPG-008 | P1 | 删除含成员的分组 | 显示二次确认;按业务约束拒绝删除或级联解除关系,不能遗留指向不存在分组的成员关系 | + +### 3.4 群组管理 + +本页属于既有能力回归,验证查询、详情、封禁/解封、解散、成员、文件和快速建群。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| GROUP-001 | P1 | 按群 ID、群号、群名、群主、状态查询 | 列表与 IM 群数据一致,分页及租户隔离正确 | +| GROUP-002 | P1 | 封禁 G1 后尝试群内发消息,再解封 | 封禁后发送被 IM Server 拒绝;解封后恢复;后台和客户端状态一致 | +| GROUP-003 | P1 | 查看成员、文件并删除一个群文件 | 只展示 G1 数据;删除需确认且客户端不可再访问已删除文件 | +| GROUP-004 | P1 | 快速创建群组并解散 | 成员、群主校验正确;解散后群不可继续使用,操作有审计记录 | + +### 3.5 群成员管理 + +本页属于既有能力回归,重点验证踢出、禁言、解除禁言,以及全局禁加好友时的群成员隐私联动。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| GMEM-001 | P1 | 查询 G1 成员并按用户筛选 | 角色、入群时间、禁言状态正确,只返回该群成员 | +| GMEM-002 | P1 | 对 U2 执行禁言、解除禁言、踢出 | 对应客户端行为立即生效;群主/管理员等受保护角色遵守业务限制 | +| GMEM-003 | P0 | 发布“全局禁止加好友”策略后,U1 在群成员列表点击 U2 | 不显示可识别的个人详情或个人名片,不显示加好友入口;抓包直接访问资料/加好友接口也被后端拒绝 | + +### 3.6 邀请码管理 + +本页属于既有能力回归。邀请码创建、删除、使用记录必须与全局好友开关联动。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| INV-001 | P1 | 创建邀请码并设置有效期、次数、状态 | 校验正确,创建成功后可查询,过期或耗尽后不可继续使用 | +| INV-002 | P1 | 查看使用记录并删除未使用邀请码 | 使用人、时间、状态正确;删除需确认,已删除邀请码失效 | +| INV-003 | P0 | 全局禁止加好友期间通过邀请码注册或自动加好友 | 注册能力按私域设置执行,但 `invite_auto_add` 入口必须被后端阻断,不能自动建立好友关系 | + +### 3.7 单聊消息 + +筛选项:租户、会话 Topic、发送用户 ID、接收用户 ID、内容类型、消息状态、内容关键词、发送时间。表格字段:Topic、消息序号、发送人、接收人、类型、内容摘要、状态、发送时间;按钮:详情、隐藏、删除、导出。 + +内容类型枚举:`text` 文本、`image` 图片、`video` 视频、`audio/voice` 语音、`file` 文件、`card` 名片、`system` 系统。状态枚举:`normal` 正常、`deleted` 后台删除、`recalled` 用户撤回、`blocked` 风控拦截、`hidden` 已隐藏。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| PM-001 | P0 | 打开页面并点击查询 | `/imPrivateMessage/list` 返回真实 IM 消息;单聊双方、Topic、Seq、类型、摘要、状态和时间与 IM 库一致 | +| PM-002 | P1 | 组合发送人、接收人、类型、状态、关键词和时间查询 | 所有条件正确下传并组合生效;非法类型、非法状态、每页大于 200 被后端拒绝 | +| PM-003 | P1 | 点击详情 | `/imPrivateMessage/view` 返回消息头、完整内容、元数据、回复序号、@用户等;空 Topic 或 `seqId<=0` 被拒绝 | +| PM-004 | P0 | 点击隐藏,输入处置原因并确认 | 原因必填且不超过 500 字;Admin 生成唯一 command/event,返回待处理状态,Kafka 发布 `message.hide` | +| PM-005 | P0 | 等待隐藏命令完成后刷新并在移动端查看 | Admin 命令状态变为成功;IM `im_message_admin_actions` 有一条成功记录;列表状态为 `hidden`,普通客户端不再展示该消息 | +| PM-006 | P0 | 点击删除,确认后等待执行 | Kafka 发布 `message.delete`;IM 幂等落库并清除相关缓存;列表状态为 `deleted`,详情/客户端不能再获得可展示原文 | +| PM-007 | P0 | 使用同一 command/event 重放隐藏或删除命令 | 不重复执行、不新增重复有效记录,最终状态保持一致,并可追踪重复消费结果 | +| PM-008 | P1 | Kafka 不可用时执行隐藏/删除 | 页面明确提示失败或命令进入失败/待重试状态,不显示为已成功;恢复后可按设计重试 | +| PM-009 | P1 | 按当前筛选条件导出 | 导出字段含 Topic、Seq、双方 ID、类型、摘要、状态、发送时间;数据范围与筛选及租户权限一致 | + +### 3.8 群聊消息 + +筛选项:租户、群 Topic、发送用户 ID、内容类型、消息状态、关键词、发送时间。表格除公共消息字段外还应展示群名称、群号;按钮与单聊一致。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| GM-001 | P0 | 按 G1 Topic 查询 | `/imGroupMessage/list` 仅返回群消息,群名称、群号、发送人、Seq、类型、摘要和状态正确 | +| GM-002 | P1 | 按发送人、类型、状态、关键词、时间组合查询并查看详情 | 条件准确,详情完整;不能用单聊 Topic 越界取得群消息 | +| GM-003 | P0 | 隐藏一条群消息并在多个群成员客户端刷新 | `message.hide` 执行成功;所有普通成员均不可见,后台状态和 IM 动作记录一致 | +| GM-004 | P0 | 删除一条群消息 | `message.delete` 执行成功;群消息缓存被清除,各客户端不能继续通过历史缓存看到原内容 | +| GM-005 | P1 | 导出群消息 | 导出包含群名、Topic、Seq、发送人、类型、摘要、状态、时间,范围与查询一致 | +| GM-006 | P0 | 重放相同命令、乱序投递隐藏与删除命令 | IM Server 幂等处理;删除是最终不可见状态,不因迟到的隐藏命令恢复内容 | + +### 3.9 敏感词库 + +按钮:新增、编辑、启用/禁用、删除、批量导入、分组管理。筛选字段:租户、分组、敏感词、匹配类型、命中动作、风险等级、状态。 + +匹配枚举:`exact` 精确、`contains` 包含、`regex` 正则、`pinyin` 拼音。动作枚举:`replace` 替换、`block` 拦截、`audit_only` 仅审计、`manual_review` 人工审核。风险等级:`low/medium/high`。场景:单聊、群聊、用户资料、群资料、反馈、举报。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| SW-001 | P1 | 新建敏感词分组 | 编码格式、名称 1-128 字和状态校验正确;同租户编码保持唯一 | +| SW-002 | P1 | 新增敏感词并逐项选择匹配方式、动作、风险、场景 | 保存后列表字典显示正确;敏感词长度 1-255;至少选择一个生效场景 | +| SW-003 | P1 | 动作选择“替换”但不填替换文本 | 前后端均拒绝并提示“替换动作必须填写替换文本” | +| SW-004 | P1 | 输入非法匹配类型、动作、风险、状态或无效正则 | 后端拒绝;无效正则不得发布到 IM 运行时 | +| SW-005 | P1 | 切换启用/禁用并发送命中消息 | 禁用后不执行该词规则;启用后按所选场景和动作处理,后台状态与 IM 行为一致 | +| SW-006 | P1 | 批量导入含空行、重复词和已存在词的数据 | 单次仅允许 1-5000 条;返回 `inserted` 和 `skipped`,重复数据不产生多条记录 | +| SW-007 | P1 | 删除敏感词后再次发送命中内容 | 删除需确认;运行时规则及时失效,且操作有审计记录 | + +### 3.10 文件管理 + +本页属于既有能力回归。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| FILE-001 | P1 | 按上传人、文件类型、大小、来源、审核状态、时间查询 | 表格元数据、下载地址、引用对象与存储记录一致,租户隔离正确 | +| FILE-002 | P1 | 预览、下载、删除文件 | 权限校验正确;删除需确认,引用中的已删除文件不能继续匿名访问 | +| FILE-003 | P1 | 执行审核通过/拒绝及批量处置 | 状态流转正确,拒绝原因必填,相关消息或群文件同步更新 | + +### 3.11 用户反馈 + +本页属于既有能力回归。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| FB-001 | P1 | 按反馈类型、状态、优先级、用户和时间查询 | 查询、分页、详情及附件展示正确 | +| FB-002 | P1 | 修改处理状态、优先级和回复内容 | 状态流转符合约束,处理人和处理时间自动记录 | +| FB-003 | P2 | 批量处理并导出 | 成功/失败数量准确,导出范围与当前筛选一致 | + +### 3.12 操作日志 + +本页属于既有能力回归,也是所有后台写操作的审计入口。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| OPLOG-001 | P0 | 完成策略发布、消息删除、用户封禁后查询日志 | 三类操作均可按模块、操作者、目标和时间检索,结果、原因、请求 ID 完整 | +| OPLOG-002 | P0 | 检查请求参数与响应摘要 | 密码、验证码、Token、共享密钥、完整手机号/邮箱等不以明文出现 | +| OPLOG-003 | P1 | 只读管理员尝试越权写接口 | 记录拒绝结果及必要上下文,不记录敏感正文,不改变业务数据 | + +### 3.13 用户账号审计 + +本页属于既有能力回归。动作至少覆盖修改资料、修改密码、注销、管理员改用户名/状态、重置密码、更新安全权限、强制下线、强制注销、调整策略分组、批量封禁、按 IP 封禁。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| UAUD-001 | P1 | 分别触发用户自助和管理员账号变更 | 动作枚举显示中文,操作者类型、目标用户、变更前后摘要和时间正确 | +| UAUD-002 | P0 | 执行重置密码并查看审计详情 | 仅记录“已重置”等安全摘要,绝不保存新旧明文密码 | +| UAUD-003 | P1 | 调整 U1 策略分组 | 产生 `assign_policy_group` 审计,旧分组、新分组及管理员可追溯 | + +### 3.14 群事件审计 + +本页属于既有能力回归。事件包括建群、加入/退出/踢出、禁言/解禁、转让群主、增删管理员、改群名、封禁/解封和解散。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| GAUD-001 | P1 | 在 G1 触发踢人、禁言、改群名 | 对应事件各产生一条审计,操作人、目标人、群、时间和原因正确 | +| GAUD-002 | P1 | 系统执行群封禁或解散 | 操作人显示系统或实际管理员,事件类型与最终群状态一致 | + +### 3.15 管理员登录历史 + +本页属于 HotGo 既有能力回归。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| ALOGIN-001 | P1 | 管理员成功登录、失败登录、退出后查询 | 账号、IP、浏览器、系统、结果和时间正确;失败原因不泄露密码 | +| ALOGIN-002 | P1 | 使用日期、账号、IP、状态筛选并导出 | 列表和导出范围一致,普通管理员不能查看超出权限的数据 | + +### 3.16 用户登录历史 + +筛选项:租户、用户 ID、脱敏账号、登录方式、事件类型、客户端平台、IP、登录时间。表格字段:ID、用户、脱敏账号、登录方式、事件、客户端、版本、IP、地区、风险等级、登录时间、退出时间。 + +登录方式:`password` 密码、`sms_code` 短信验证码、`sso` 单点登录、`qr_code` 二维码、`token_refresh` 令牌刷新。事件:`login` 登录、`refresh` 刷新会话、`logout` 主动退出、`kicked` 被踢下线、`session_expired` 会话过期。平台:iOS、Android、Web、桌面端、未知。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| ULOGIN-001 | P0 | U1 密码登录成功后在后台查询 | IM `im_user_login_logs` 新增成功记录;后台展示用户、脱敏账号、会话、设备、平台、IP、请求 ID 和登录时间 | +| ULOGIN-002 | P0 | U1 主动退出后刷新列表 | 对应会话 `logout_at` 被更新或产生退出事件;退出时间不早于登录时间 | +| ULOGIN-003 | P1 | 分别模拟刷新会话、被踢、会话过期 | 事件字典和发生时间正确,同一 session 可串联追踪 | +| ULOGIN-004 | P1 | 按全部筛选项组合查询 | Admin 参数正确透传到 IM Internal API;结果按登录时间倒序,分页不重复不漏项 | +| ULOGIN-005 | P0 | 检查列表、详情、Admin/IM 日志及数据库 | 只保存账号掩码和不可逆哈希;密码、短信验证码、Token、Cookie 不得出现 | +| ULOGIN-006 | P1 | 导出当前筛选结果 | 含 ID、用户、脱敏账号、事件、平台、IP、地区、风险、原因、时间;租户隔离和脱敏规则不因导出而放宽 | + +### 3.17 IP 账号统计 + +本页属于后续改造/回归能力。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| IPSTAT-001 | P1 | 按 IP、账号数、风险和时间查询 | 同一 IP 关联账号数、成功/失败次数和最近时间统计准确 | +| IPSTAT-002 | P1 | 查看关联用户并按 IP 封禁 | 关联列表受租户隔离;封禁需确认并记录命中用户数、成功数和失败数 | + +### 3.18 登录失败日志 + +筛选项:租户、用户 ID、脱敏账号、失败类型、平台、IP、尝试时间。表格字段:ID、用户、脱敏账号、失败类型、原因、IP、地区、客户端、版本、风险等级、风控动作、尝试时间;按钮:详情、加入 IP 黑名单、封禁用户、导出。 + +失败类型:`password_error` 密码错误、`code_error` 验证码错误、`account_not_found` 账号不存在、`account_disabled` 账号已禁用、`rate_limited` 访问频率受限、`risk_blocked` 风控拦截。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| LFAIL-001 | P0 | 使用错误密码登录 U1 | IM `im_login_failure_logs` 新增 `password_error`;后台展示脱敏账号、IP、设备、平台、风险及尝试时间 | +| LFAIL-002 | P0 | 分别触发验证码错误、账号不存在、账号禁用、限频和风控拦截 | 失败类型、原因码、详情、风险动作正确;未知账号场景不泄露账号是否真实存在 | +| LFAIL-003 | P1 | 点击详情 | 根据日志 ID 返回完整安全上下文;`id<=0` 或跨租户 ID 被拒绝 | +| LFAIL-004 | P0 | 点击“加入 IP 黑名单”并确认 | IP 必填;在安全策略 IP 黑名单生成规则,不重复插入相同有效规则,后续登录按场景被阻断 | +| LFAIL-005 | P0 | 对有关联用户的记录点击“封禁用户”,填写原因 | 用户 ID 和原因必填;用户状态变为禁用,已有会话按设计失效,账号审计和操作日志可追溯 | +| LFAIL-006 | P1 | 对账号不存在、无关联用户的记录点击封禁 | 按钮不可用或接口明确提示无法关联用户,不得误封其他用户 | +| LFAIL-007 | P1 | 导出失败日志 | 结果与筛选一致,账号继续脱敏,不包含提交的密码、验证码和完整鉴权数据 | + +### 3.19 私域设置 + +本页属于既有/后续能力回归,重点验证注册与验证码开关不绕过安全策略。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| TENANT-001 | P1 | 修改短信验证码、邀请码注册等开关并保存 | 保存后重新进入值保持一致,移动端登录/注册入口按租户配置变化 | +| TENANT-002 | P0 | 关闭短信验证码并尝试短信登录、忘记密码、验证码注册 | 后端拒绝被关闭的流程,不能仅靠隐藏移动端按钮 | +| TENANT-003 | P0 | 全局禁加好友期间通过注册流程触发自动加好友 | 注册成功与否按私域设置判断,但任何自动加好友均被安全策略统一拒绝 | + +### 3.20 安全策略 + +页面包含“策略版本”和“IP 黑白名单”两个页签。策略按钮:新建草稿、编辑/查看、发布、恢复默认;策略内含好友控制、消息宵禁和其他 JSON 策略。IP 规则按钮:新增、编辑、删除。 + +发布状态:`draft` 草稿、`published` 已发布、`archived` 已归档、`failed` 发布失败。IP 规则类型:白名单/黑名单;动作:`allow` 放行、`challenge` 二次验证、`block` 阻断、`audit_only` 仅审计;场景:登录、注册、发消息、上传文件;风险:低、中、高。 + +#### 3.20.1 策略版本与发布 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| SEC-001 | P0 | 点击新建草稿,填写策略名称并保存 | `/imSecurityPolicy/save` 创建 `draft`;只有草稿可编辑,已发布版本只读 | +| SEC-002 | P0 | 修改草稿但不发布,在移动端验证旧策略 | 草稿不影响 IM 运行时,移动端继续执行最后一个已发布版本 | +| SEC-003 | P0 | 点击发布并确认 | 发布成功后当前版本为 `published`,原已发布版本归档;IM Server 收到策略更新并按新版本执行 | +| SEC-004 | P0 | 模拟 Kafka/IM 策略应用失败 | 不得提示完全成功;版本进入失败或可重试状态,运行时继续使用上一个有效版本 | +| SEC-005 | P1 | 点击恢复默认 | 仅创建一个默认草稿,不直接发布、不改变当前生效策略 | +| SEC-006 | P1 | 输入非法 JSON、空策略名或超过 500 字发布原因 | 前后端拒绝提交并明确指出错误字段 | + +#### 3.20.2 全局禁止加好友 + +关闭“允许加好友”时,后端必须强制设置:隐藏个人信息、禁止二维码、禁止群成员入口,并封锁 `search`、`profile_card`、`group_member`、`qrcode`、`invite_auto_add`、`recommendation` 六类入口。该限制必须由后端执行,移动端隐藏按钮不能作为唯一控制。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| FRIEND-001 | P0 | 关闭“允许加好友”并观察表单 | 隐藏资料、禁止二维码、禁止群成员入口自动开启且不可取消;六类入口全部选中 | +| FRIEND-002 | P0 | 仅保存草稿,不发布;U1 从用户搜索添加 U2 | 仍按旧策略执行,证明草稿不直接生效 | +| FRIEND-003 | P0 | 发布禁加好友策略;U1 从用户搜索添加 U2 | 页面不展示添加入口或提示“当前时段暂不允许添加好友”;直接调用加好友接口同样被拒绝 | +| FRIEND-004 | P0 | U1 打开 U2 个人名片并尝试添加 | 个人资料按策略隐藏,不展示可用个人名片及加好友入口;直接请求资料/好友接口不可绕过 | +| FRIEND-005 | P0 | U1 从 G1 群成员列表点击 U2 | 不显示 U2 可识别的个人信息、个人名片和加好友入口 | +| FRIEND-006 | P0 | U1 扫描 U2 二维码并构造二维码加好友请求 | 二维码入口不可用,后端拒绝建立好友关系 | +| FRIEND-007 | P0 | 使用邀请码注册并触发自动添加,再查看推荐联系人 | `invite_auto_add` 和 `recommendation` 均被阻断,不产生好友关系 | +| FRIEND-008 | P0 | 修改客户端请求绕过 UI、伪造入口类型或不传入口类型 | 后端按全局开关统一拒绝,不能因缺失/伪造入口字段放行 | +| FRIEND-009 | P0 | 将 U3 配置为例外用户(若本版本支持)并重复六入口测试 | 仅 U3 按明确白名单规则放行,U1/U2 仍被阻断;未实现例外时页面和接口不得暗示支持 | +| FRIEND-010 | P0 | 重新开启并发布,再从六入口验证 | 限制解除,资料展示及加好友恢复为常规权限;已被拒绝的历史请求不会自动补建好友关系 | + +#### 3.20.3 消息宵禁 + +规则字段:规则编码、名称、日期类型、作用范围、开始/结束时间、时区、状态、提示语、白名单用户 ID;支持每天、工作日、周末、自定义日期,范围为单聊/私聊或群聊。默认时区为 `Asia/Shanghai`,支持跨午夜时间段。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| CURFEW-001 | P0 | 开启宵禁但不添加规则并保存 | 后端拒绝;开启时至少存在一条规则,且至少一条启用的单聊/私聊规则 | +| CURFEW-002 | P0 | 新增工作日 18:00-09:00、范围单聊、状态启用的规则并发布 | 保存和发布成功,规则编码合法且唯一,IM Server 使用 `Asia/Shanghai` 解释跨午夜区间 | +| CURFEW-003 | P0 | 命中宵禁时段,U1 向 U2 发送文本、图片、语音、文件和名片 | 所有单聊/私聊消息均由后端拒绝,不写入可投递消息;客户端显示配置的宵禁提示语 | +| CURFEW-004 | P0 | 在开始时刻前 1 秒、开始时刻、结束时刻前 1 秒、结束时刻发送 | 边界按统一规则执行;开始时刻进入限制,结束时刻解除,Admin 与 IM 时间口径一致 | +| CURFEW-005 | P0 | 分别验证每天、工作日、周末、自定义日期 | 仅命中日期生效;自定义日期为空或格式非法时拒绝保存 | +| CURFEW-006 | P0 | 将 U3 加入白名单,在命中时段由 U3 与 U1 发消息 | 白名单方向规则符合产品定义且保持一致;非白名单 U1/U2 仍被拒绝 | +| CURFEW-007 | P0 | 增加群聊范围规则并发布,在 G1 发消息 | 命中时群消息被后端拒绝;只有单聊规则时群聊不受影响 | +| CURFEW-008 | P0 | 禁用规则或关闭宵禁并发布 | 消息恢复发送;旧策略缓存及时失效,不需重启 IM Server | +| CURFEW-009 | P0 | 仅保存新的时间规则但不发布 | IM Server 继续执行最后已发布规则,不能提前生效 | +| CURFEW-010 | P0 | 客户端修改本地时间、时区或直接调用发消息接口 | 后端以策略时区和服务器可信时间判定,无法绕过宵禁 | + +#### 3.20.4 IP 黑白名单 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| IPRULE-001 | P1 | 新增黑名单规则,填写单 IP、阻断、登录场景、高风险 | 保存成功,列表字段及字典显示正确;登录请求从该 IP 发起时被阻断 | +| IPRULE-002 | P1 | 新增 CIDR 白名单并选择多个场景 | CIDR 校验正确,命中范围内 IP 按白名单优先级执行,其他场景不受影响 | +| IPRULE-003 | P1 | 输入非法 IP/CIDR、空场景、非法动作/风险/状态 | 后端拒绝且不产生记录 | +| IPRULE-004 | P1 | 编辑规则后重新触发对应场景 | 新规则生效,旧匹配缓存失效;详情和列表一致 | +| IPRULE-005 | P1 | 删除规则并确认 | 记录删除且运行时规则失效;取消确认不产生变化 | +| IPRULE-006 | P0 | 从登录失败日志加入同一 IP 黑名单 | 安全策略页可查询到规则;重复操作不产生冲突规则,审计来源可追踪 | + +## 4. Admin Server 与 IM Server 联调核验 + +### 4.1 接口映射 + +| 后台操作 | Admin Server | IM Server Internal / Kafka | +|---|---|---| +| 概览指标 | `GET /imDashboard/overview` | `GET /internal/admin-query/tenants/{tenant}/dashboard/overview` | +| 趋势数据 | `GET /imDashboard/trends` | `GET /internal/admin-query/tenants/{tenant}/dashboard/trends` | +| 单聊列表 | `GET /imPrivateMessage/list` | `GET /internal/admin-query/tenants/{tenant}/private-messages` | +| 群聊列表 | `GET /imGroupMessage/list` | `GET /internal/admin-query/tenants/{tenant}/group-messages` | +| 消息详情 | `GET /imPrivateMessage/view` 或 `/imGroupMessage/view` | `GET /internal/admin-query/tenants/{tenant}/messages/{topic}/{seq}` | +| 隐藏/删除消息 | `POST .../hide`、`POST .../delete` | Kafka `message.hide`、`message.delete` | +| 用户登录历史 | `GET /imUserLoginHistory/list` | `GET /internal/admin-query/tenants/{tenant}/user-login-logs` | +| 登录失败列表/详情 | `GET /imLoginFailureLog/list|view` | `GET /internal/admin-query/tenants/{tenant}/login-failure-logs[/{id}]` | + +### 4.2 数据库抽查 + +以下 SQL 仅用于测试环境核验,查询条件必须带测试租户或测试数据标识: + +```sql +-- IM 登录成功/退出审计 +SELECT id, tenant_id, user_id, account_mask, login_type, event_type, + session_id, platform, ip, risk_level, login_at, logout_at +FROM im_user_login_logs +WHERE tenant_id = ? AND user_id = ? +ORDER BY id DESC LIMIT 20; + +-- IM 登录失败审计 +SELECT id, tenant_id, user_id, account_mask, failure_type, reason, + ip, platform, risk_level, risk_action, attempt_at +FROM im_login_failure_logs +WHERE tenant_id = ? AND ip = ? +ORDER BY id DESC LIMIT 20; + +-- IM 消息后台处置结果 +SELECT tenant_id, command_id, event_id, action, topic, seq_id, + status, reason, created_at, updated_at +FROM im_message_admin_actions +WHERE tenant_id = ? AND topic = ? AND seq_id = ? +ORDER BY id DESC; +``` + +核验要求:同一幂等键不得产生多条有效执行;失败必须保留错误状态和可追踪信息;表中不得出现密码、验证码、Token 或完整认证请求。 + +## 5. 非功能与异常测试 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| NFT-001 | P1 | 列表准备 10 万级数据并执行常用筛选 | 首屏和翻页在项目约定时间内返回,无全表扫描导致的持续高负载 | +| NFT-002 | P1 | 并发 20 次概览、消息、登录审计查询 | 服务稳定,连接池无耗尽,租户数据不串读 | +| NFT-003 | P0 | 伪造 Internal API 请求、错误共享密钥或重放过期请求 | IM Server 拒绝未授权请求并记录安全日志,不返回业务数据 | +| NFT-004 | P0 | 在 Topic、关键词、原因、备注中输入 SQL/XSS 字符串 | 使用参数化查询;页面转义展示,不执行脚本,不污染导出文件 | +| NFT-005 | P1 | Admin 与 IM 短时网络中断后恢复 | 查询可恢复;异步命令状态可追踪,不丢命令、不重复执行 | +| NFT-006 | P1 | 检查 PC 常用分辨率及窄屏 | 搜索区、表格、弹窗和操作列无重叠;横向滚动可用,按钮文本不被截断为不可辨识状态 | + +## 6. 上线验收门槛 + +满足以下条件方可通过本轮验收: + +1. 所有 P0 用例通过,尤其是 `FRIEND-*`、`CURFEW-*`、消息处置、登录审计和租户隔离。 +2. P1 无阻断主流程的问题;遗留问题有负责人、修复版本和回归日期。 +3. Admin Server、IM Server、Kafka 和两侧数据库结果一致,不以页面提示代替最终状态核验。 +4. 全局禁加好友和宵禁均通过“直接调用后端接口”的绕过测试。 +5. 数据库、应用日志、操作日志和导出文件均通过敏感信息检查。 +6. 其余未纳入本轮改造的页面必须完成基本回归,但不应据此宣称 20 个页面已全部完成重构。 + +## 7. 执行记录 + +| 字段 | 内容 | +|---|---| +| Admin Server 版本/提交 | `16cc456`(功能提交:`bb87d01`) | +| IM Server 版本/提交 | `b92154b`(功能提交:`6b20a63`) | +| 前端版本/构建时间 | | +| 测试环境 | `https://admin.118im.com` | +| 测试租户 | | +| 测试人员 | | +| 执行日期 | | +| P0 通过/失败/阻塞 | | +| P1 通过/失败/阻塞 | | +| 缺陷链接 | | +| 最终结论 | 通过 / 有条件通过 / 不通过 | diff --git a/docs/admin-adfahi-research/测试问题记录-2026-09-07.md b/docs/admin-adfahi-research/测试问题记录-2026-09-07.md new file mode 100644 index 0000000..e75a6b2 --- /dev/null +++ b/docs/admin-adfahi-research/测试问题记录-2026-09-07.md @@ -0,0 +1,353 @@ +# 鸽达达管理后台测试问题记录 + +> 测试环境:`https://admin.118im.com/admin#/dashboard/console` +> 执行日期:2026-09-07 +> 参考用例:`测试用例文档.md` +> 测试方式:浏览器黑盒测试、HTTP 只读探测、测试库只读核验、当前代码静态核查 + +## 1. 执行摘要 + +| 项目 | 结果 | 说明 | +|---|---|---| +| 测试站点可达性 | 通过 | `/admin/` 返回 HTTP 200,响应约 0.2-0.5 秒 | +| 前端功能部署 | 通过 | 部署包包含 `imDashboard`、`imPrivateMessage`、`imGroupMessage`、`imUserLoginHistory`、`imLoginFailureLog`、`imSecurityPolicy`、`imSensitiveWord`、`imUserPolicyGroup` | +| Admin 新接口路由 | 通过 | 8 个列表/概览接口均已注册;无登录态请求统一返回业务码 `61`“登录身份已失效” | +| 浏览器页面操作 | 已恢复 | 已接管用户登录标签页,完成 8 个新增菜单及查询/刷新按钮的只读复测 | +| Admin 测试库结构 | 已修复 | 已在指定测试库 `hotgo` 创建 7 张策略相关表、8 个页面菜单及按钮权限 | +| IM 测试库结构 | 通过 | `tinode` 已存在 3 张新增审计/处置表 | +| IM 登录审计数据 | 部分通过 | IM Internal API 已恢复,成功登录历史返回 77 条;失败日志返回成功空列表,仍缺少失败场景验收数据 | +| Kafka Admin 命令 | 风险 | 13 条历史命令中 12 条长期待处理、1 条死信,需检查消费链路 | +| 消息隐藏/删除 | 未执行 | `im_message_admin_actions` 为 0;尚未准备专用消息数据,且 Kafka 命令链路存在长期积压风险 | +| 好友总开关/宵禁 | 未通过实现核查 | Admin 可配置和发布,但当前 IM Server 未找到运行时消费及拦截代码 | + +当前结论:**不通过。页面菜单和 Admin 本地策略表已可用,但 IM Internal 查询接口不可用,真实概览、消息和登录日志无法展示;IM 运行时策略执行也尚未完成。** + +## 2. 问题清单 + +### BUG-001 Admin 测试库缺少策略相关表 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 数据库部署/环境配置 | +| 状态 | 已修复并完成只读页面复测 | +| 关联用例 | `UPG-*`、`SW-*`、`SEC-*`、`FRIEND-*`、`CURFEW-*`、`IPRULE-*` | + +**操作步骤** + +1. 连接用户指定测试数据库 `64.118.137.182:53306`。 +2. 查询 `information_schema.tables` 中 `hotgo` 库的本轮新增表。 + +**实际结果** + +以下表均不存在: + +- `im_security_policy` +- `im_message_curfew_rules` +- `im_security_ip_rules` +- `im_sensitive_word_groups` +- `im_sensitive_words` +- `im_user_policy_groups` +- `im_user_policy_group_members` + +**预期结果** + +测试环境 Admin Server 所连接的数据库应包含全部新增表,并已执行初始化数据。 + +**影响** + +若线上 Admin Server 确实连接此 `hotgo` 库,用户策略分组、敏感词、安全策略、宵禁和 IP 规则页面会查询失败或无法保存。如果 Admin Server 实际连接其他数据库,则说明当前提供的测试库地址与部署配置不一致,需要明确真实连接库后重新核验。 + +**建议** + +核对 Admin Server 生产配置中的数据库 DSN;确认无误后执行已提交的 Admin 建表 SQL,并对表结构、索引和初始化数据做一次部署校验。 + +**处理记录(2026-09-07)** + +已在 `hotgo` 执行 `docs/admin-adfahi-research/sql/Admin-Server改造.sql`。7 张表均创建成功,`im_security_policy` 已初始化 1 条全局默认已发布策略,其余新增业务表初始为空。页面复测中,安全策略正确展示该默认策略,敏感词和用户分组正确展示空列表;写操作尚未执行。 + +### BUG-002 测试库缺少 8 个新增页面菜单记录 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 菜单/权限初始化 | +| 状态 | 已修复并完成超级管理员复测 | +| 关联用例 | `COM-001`、`COM-002` | + +**实际结果** + +在 `hotgo.hg_admin_menu` 中未查询到以下菜单: + +- `imDashboardIndex` +- `imPrivateMessageIndex` +- `imGroupMessageIndex` +- `imUserLoginHistoryIndex` +- `imLoginFailureLogIndex` +- `imUserPolicyGroupIndex` +- `imSensitiveWordIndex` +- `imSecurityPolicyIndex` + +部署前端包已经包含对应页面模块,后端接口也已注册,说明代码已部署但数据库菜单初始化可能未执行。 + +**预期结果** + +超级管理员登录后可在 IM 管理下看到 8 个页面;功能权限记录完整,普通角色按授权显示按钮。 + +**建议** + +确认部署库后执行: + +- `server/storage/data/generate/im_adfahi_batch2_menu.sql` +- `server/storage/data/generate/im_adfahi_policy_menu.sql` + +执行后分别用超级管理员、只读管理员和租户管理员回归菜单及按钮权限。 + +**处理记录(2026-09-07)** + +已执行 `im_adfahi_policy_menu.sql` 和 `im_adfahi_batch2_menu.sql`。8 个页面菜单均启用并挂载到 `imManager`(ID 2431)下;页面与按钮权限合计 43 条。用户需重新登录或刷新权限缓存后复测菜单显示。 + +**页面复测(2026-09-07)** + +超级管理员展开“IM管理”后,数据概览、用户分组、单聊消息、群聊消息、用户登录历史、登录失败日志、敏感词库、安全策略 8 个页面菜单均可见并可打开。`COM-001` 的菜单可见性通过;只读管理员和租户管理员权限仍需使用对应账号复测。 + +### BUG-003 Kafka Admin 命令存在长期 Pending 和 Dead 记录 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | Kafka/异步命令链路 | +| 状态 | 待处理 | +| 关联用例 | `PM-004` 至 `PM-008`、`GM-003` 至 `GM-006`、`NFT-005` | + +**实际结果** + +`hotgo.im_admin_commands` 共 13 条记录: + +| 事件类型 | 状态 | 数量 | +|---|---:|---:| +| `user.password.reset` | `pending` | 12 | +| `user.password.reset` | `dead` | 1 | + +最新死信记录时间为 2026-08-28,原因 `RESOURCE_NOT_FOUND`,消息 `not found`。其余 Pending 记录同样创建于 2026-08-28,长期未进入成功或失败终态。 + +**预期结果** + +命令应在约定时间内进入 `succeeded`、`failed` 或 `dead` 终态;Pending 不应长期积压。死信应可查看、重放、忽略或解决。 + +**影响** + +消息隐藏/删除、用户治理等 Kafka 命令可能出现后台提示已提交但 IM 不执行,属于治理能力失效风险。 + +**建议** + +检查 Admin outbox worker、Kafka topic、consumer group、IM consumer 启动配置及结果回传 topic;处理历史积压后再执行消息隐藏/删除端到端测试。 + +### BUG-004 IM Server 尚未执行安全策略、敏感词和用户策略分组 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 功能未完成 | +| 状态 | 待开发 | +| 关联用例 | `SW-005`、`SW-007`、`FRIEND-*`、`CURFEW-*`、`IPRULE-*` | + +**代码核查结果** + +Admin Server 已实现策略数据保存、发布和 `security.policy.updated` 等事件写出;当前 IM Server 仅发现 `message.hide`、`message.delete` 等 Admin 命令消费,未发现: + +- `security.policy.updated` 事件消费与策略缓存刷新。 +- 全局禁止加好友在用户搜索、个人名片、群成员、二维码、邀请自动添加、推荐联系人六类入口的后端校验。 +- 禁加好友时个人资料、个人名片的隐藏处理。 +- 消息发送路径中的宵禁日期、时区、跨午夜、范围和白名单判定。 +- 敏感词及用户策略分组在 IM 运行时的加载与执行。 + +**预期结果** + +后台发布后,IM Server 接收事件、加载对应版本,并在实际移动端请求链路强制执行;直接调用接口也不能绕过。 + +**影响** + +后台可以显示“发布成功”,但移动端行为不会变化。全局禁止加好友和宵禁属于明确要求,未完成时禁止上线。 + +### DATA-001 IM 测试数据不足,无法覆盖失败日志和消息处置 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P1 | +| 类型 | 测试数据 | +| 状态 | 待准备 | +| 关联用例 | `PM-*`、`GM-*`、`LFAIL-*` | + +**实际结果** + +- `tinode.im_user_login_logs`:43 条。 +- `tinode.im_login_failure_logs`:0 条。 +- `tinode.im_message_admin_actions`:0 条。 + +登录日志分布以 `password/login` 和 `token_refresh/login` 为主,尚不足以覆盖主动退出、被踢、会话过期等事件。账号掩码和 metadata 的只读抽查未发现手机号全明文或密码、验证码、Token 键值。 + +**建议** + +准备专用测试账号,分别触发密码错误、验证码错误、账号不存在、账号禁用、限频和风控拦截;准备单聊和群聊消息后执行隐藏、删除及幂等重放。 + +### BUG-005 IM Internal 查询接口返回 resource not found + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 服务部署/路由联调 | +| 状态 | 已修复,接口复测通过 | +| 关联用例 | `DASH-001`、`DASH-002`、`PM-001`、`GM-001`、`ULOGIN-001`、`ULOGIN-004`、`LFAIL-001` | + +**操作步骤** + +1. 使用超级管理员进入“IM管理”。 +2. 打开数据概览并点击“刷新”。 +3. 分别进入单聊消息、群聊消息、用户登录历史、登录失败日志并点击“查询”。 + +**实际结果** + +- 数据概览六项指标全部显示 0,趋势表显示“无数据”,页面提示 `resource not found`。 +- 单聊消息、群聊消息、用户登录历史、登录失败日志均显示 0 条,查询后提示 `resource not found`。 +- 浏览器控制台在每次查询后记录同名接口错误。 +- 数据库已有 `tinode.im_user_login_logs` 43 条,因此用户登录历史页面的 0 条不是正常空数据。 + +**预期结果** + +Admin Server 应能调用 IM Server Internal API,概览、消息及登录日志返回真实数据;确实无数据的接口应返回成功的空列表,不能返回资源不存在。 + +**根因定位(2026-09-07)** + +- 当前 IM Server 源码已注册 `/internal/admin-query/`,并已实现 dashboard overview/trends、private-messages、group-messages、user-login-logs、login-failure-logs 路由,不是五个查询分别缺少实现。 +- `authorizeInternalAPI` 在 `internal_api.enabled=false` 时固定返回 HTTP 404、业务码 `30004`、消息 `resource not found`;该响应与页面错误完全一致。 +- 对测试环境 `https://api.118im.com/internal/admin-query/tenants/probe/dashboard/overview` 进行未签名只读探测,实际返回 HTTP 404、业务码 `30004`、消息 `resource not found`。若 Internal API 已启用,同一未签名请求应返回 HTTP 401、消息 `internal signature required`。 +- Gitea Actions 第 66 次部署(run 99,提交 `b92154b`)日志显示工作流调用 `deploy.sh` 时已经设置 `INTERNAL_API_ENABLED=true`,且部署成功切换到 green 实例;但部署后的公开实例仍表现为 disabled。 + +由此确认:提交 `c68c5ac` 将 Docker 配置模板中原本固定启用的 Internal API 改成了额外环境开关,但现有蓝绿部署并不会把这些新增变量注入容器,导致容器继承镜像默认值 `false`。这是新增启动配置造成的部署回归,不应要求部署机再增加一组开关配置。 + +**建议** + +已将 `docker/tinode/config.template` 恢复为改造前的启动方式:Internal API 固定开启、签名窗口固定 300 秒、调用方固定为 `hotgo-admin`,继续复用原有共享密钥配置;同时移除部署工作流中新增的开关、窗口和服务名变量。部署机无须增加 Compose 配置。 + +重新构建并部署 IM Server 后,先执行未签名探测:预期由当前的 HTTP 404 `resource not found` 变为 HTTP 401 `internal signature required`;再使用正确签名验证 overview、trends、private/group message、login history、login failure 六类接口,最后回归后台页面。 + +**接口复测(2026-09-07,IM `35da302`)** + +- Gitea Actions run 100 部署成功,线上未签名探测由 HTTP 404 变为预期的 HTTP 401 `internal signature required`。 +- 正确签名请求全部返回 HTTP 200、业务码 `0`:概览返回总用户 211、活跃用户 12、群组 95、会话 197、消息 2182、今日消息 167;7 日趋势返回 7 行。 +- 单聊列表返回 1528 条,群聊列表返回 654 条;分页、关键词筛选及首条消息详情均通过。 +- 用户登录历史返回 77 条;登录方式、事件、脱敏账号及组合筛选均通过。 +- 登录失败日志返回 HTTP 200 的空列表,总数 0,属于当前无测试数据,不再是接口错误。 +- 不存在租户正确返回 HTTP 404、业务码 `30010`、`tenant unavailable`。 +- Admin Server 的 IM 签名客户端和字段转换相关 Go 测试通过。 + +### BUG-006 新页面调用的字典 Store API 与部署版本不兼容 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P1 | +| 类型 | 前端兼容性 | +| 状态 | 已发布,静态包复测通过,待登录态页面点击确认 | +| 关联用例 | `UPG-001`、`SW-002`、`SEC-001`、`COM-004` | + +**实际结果** + +- 用户分组页面加载及点击“查询”时抛出 `TypeError: D.getOptions is not a function`。 +- 敏感词库页面抛出 3 次 `c.getOptions is not a function`。 +- 安全策略页面抛出 `q.getOptions is not a function`。 +- 安全策略表格仍能显示 1 条默认策略,敏感词和用户分组仍能显示空列表,但字典筛选项和后续编辑表单存在失效风险。 + +**预期结果** + +页面应通过当前项目字典 Store 的公开方法加载选项,控制台无异常;状态、类型、动作、风险等级等枚举应正确展示和筛选。 + +**建议** + +已对照当前 `useDictStore` 实际 API,将用户分组、敏感词、安全策略页面统一改为 `dict.getOption()`;额外排查并修复 IM 文件策略同类调用,需过滤选项时使用 `dict.getOptionUnRef()`。源码扫描已无业务页面调用 `dict.getOptions()`,ESLint 和生产构建通过。重新发布 Admin 前端后回归筛选、编辑弹窗和字典标签。 + +**发布复测(2026-09-07,Admin `7839dce`)** + +- Gitea Actions run 103 发布成功,线上入口包已切换到 `index-Ch8uxbvK.js`。 +- 用户分组、敏感词、安全策略、文件策略四个线上动态页面包均可下载,且均不再包含 `getOptions` 调用,确认不是旧分包或 CDN 缓存。 +- 十条相关 Admin 路由的未登录请求均返回业务码 `61`,鉴权边界正常。 +- 本轮无法取得内置浏览器已登录标签页的会话控制,筛选下拉、编辑弹窗和写操作仍需登录态页面点击确认,不能仅凭静态包核对标记为完整通过。 + +### BLOCK-001 浏览器安全检查阻塞自动化页面测试 + +| 字段 | 内容 | +|---|---| +| 严重级别 | 环境阻塞,非产品缺陷 | +| 状态 | 已解除 | +| 关联用例 | 所有需要登录态和页面点击的用例 | + +已识别到 Codex 内置浏览器中的测试环境标签页,页面标题为“主控台”,URL 和登录状态由用户确认。但自动化接管域名时,Codex 浏览器安全检查返回“permission request could not complete”,因此未读取或复用登录 Token,也未绕过安全控制。 + +恢复浏览器授权后,应从 `COM-001` 开始继续,并优先执行只读查询。涉及删除消息、删除规则等操作时仅使用专用测试数据。 + +**复测记录(2026-09-07,建表后)** + +再次读取内置浏览器状态,测试标签页仍为“主控台”且 URL 正确;调用浏览器接管接口时仍返回 `Auto-review could not complete for https://admin.118im.com`。本次复测未绕过安全控制,页面用例继续标记为阻塞。 + +**解除记录(2026-09-07)** + +再次连接后浏览器授权恢复,已成功接管同一登录标签页并完成菜单展开、页面切换、查询/刷新和表格读取。该项不再阻塞后续自动化测试。 + +## 3. 已执行用例 + +| 执行 ID | 对应测试 | 结果 | 实际结果 | +|---|---|---|---| +| RUN-001 | 站点可达性 | 通过 | `/admin/` HTTP 200,HTML 和静态资源可加载 | +| RUN-002 | 前端部署完整性 | 通过 | 8 个新增页面模块均存在于部署 JS 包 | +| RUN-003 | 未登录接口鉴权 | 通过 | 8 条接口统一返回业务码 `61`,未泄露业务数据 | +| RUN-004 | COM-001 菜单检查 | 通过 | 超级管理员可见并可打开 8 个新增页面;其他角色权限待测 | +| RUN-005 | Admin 新表检查 | 失败 | 7 张策略相关表缺失 | +| RUN-006 | IM 新表检查 | 通过 | 3 张表均存在 | +| RUN-007 | ULOGIN-001 数据检查 | 失败 | 数据库有 43 条登录记录,后台查询为 0 条并提示 `resource not found` | +| RUN-008 | ULOGIN-005 敏感字段抽查 | 通过 | 当前 SQL 规则未发现可疑明文记录 | +| RUN-009 | LFAIL-001 至 LFAIL-007 | 部分阻塞 | 页面可打开和查询,但接口返回 `resource not found`;失败日志表为 0 条,缺少动作数据 | +| RUN-010 | PM/GM 消息处置 | 阻塞 | 动作表为 0 条,Kafka 另有长期积压风险 | +| RUN-011 | FRIEND/CURFEW | 失败(实现核查) | IM 运行时执行代码未完成 | +| RUN-012 | BUG-001 建表复测 | 通过 | 7 张 Admin 策略表均存在,默认安全策略 1 条 | +| RUN-013 | BUG-002 菜单复测 | 通过 | 8 个页面菜单、43 条页面/按钮权限存在且状态启用 | +| RUN-014 | BLOCK-001 浏览器接管复测 | 阻塞 | 建表后重试仍被 Codex 域名安全检查拒绝,未执行页面点击 | +| RUN-015 | BLOCK-001 浏览器接管再次复测 | 通过 | 授权恢复,已完成登录态页面操作 | +| RUN-016 | DASH-001、DASH-002 | 失败 | 刷新后六项指标均为 0,趋势无数据,接口返回 `resource not found` | +| RUN-017 | UPG-001 | 失败 | 表格正确显示 0 条,但查询触发 `getOptions is not a function` | +| RUN-018 | PM-001 | 失败 | 单聊消息显示 0 条,查询接口返回 `resource not found` | +| RUN-019 | GM-001 | 失败 | 群聊消息显示 0 条,查询接口返回 `resource not found` | +| RUN-020 | ULOGIN-001、ULOGIN-004 | 失败 | 数据库 43 条,页面为 0 条,查询接口返回 `resource not found` | +| RUN-021 | LFAIL-001 页面查询 | 失败 | 页面可操作,但查询接口返回 `resource not found`;暂无失败登录测试数据 | +| RUN-022 | SW 页面只读查询 | 部分通过 | 正确返回 0 条;控制台存在 3 个字典 `getOptions` 异常 | +| RUN-023 | SEC 页面只读查询 | 部分通过 | 正确展示默认已发布策略 1 条;控制台存在字典 `getOptions` 异常 | +| RUN-024 | BUG-005 Internal API 启用复测 | 通过 | 未签名请求返回预期的 HTTP 401,不再返回 `resource not found` | +| RUN-025 | DASH-001、DASH-002 接口复测 | 通过 | 概览返回 211 用户、95 群组、2182 消息;7 日趋势返回 7 行 | +| RUN-026 | PM-001、GM-001 接口复测 | 通过 | 单聊 1528 条、群聊 654 条;分页、关键词和详情查询通过 | +| RUN-027 | ULOGIN 接口复测 | 通过 | 登录历史 77 条;登录方式、事件、脱敏账号及组合筛选通过 | +| RUN-028 | LFAIL-001 接口复测 | 部分通过 | 接口成功返回空列表;仍需准备失败登录数据验证详情及处置按钮 | +| RUN-029 | BUG-006 代码复测 | 通过 | 4 个页面同类字典调用已修复,ESLint 和生产构建通过;待发布后页面复测 | +| RUN-030 | BUG-006 发布包复测 | 部分通过 | run 103 发布成功,4 个线上动态页面包无 `getOptions`;待登录态页面点击确认 | + +## 4. 后续复测顺序 + +1. 回归 BUG-005 对应后台页面,确认概览卡片和四个列表正确显示接口数据。 +2. 发布 BUG-006 前端修复并回归筛选、编辑弹窗和字典标签。 +3. 修复 Kafka Pending/Dead 链路,验证命令可进入终态。 +4. 补齐 IM Server 安全策略、好友入口、宵禁和敏感词运行时实现。 +5. 使用对应角色账号回归租户隔离、按钮权限、筛选、详情、分页和导出。 +6. 使用专用数据执行新增、编辑、发布、隐藏、删除、IP 黑名单和封禁用户。 +7. 使用移动端完成好友六入口、直接接口绕过、宵禁边界及白名单验收。 + +## 5. 复测记录 + +| 日期 | 版本 | 问题编号 | 复测结果 | 证据/备注 | 测试人 | +|---|---|---|---|---|---| +| 2026-09-07 | Admin `16cc456` | BUG-001 | 通过 | 7 张表存在,默认策略 1 条 | Codex | +| 2026-09-07 | Admin `16cc456` | BUG-002 | 通过 | 8 个菜单、43 条权限启用 | Codex | +| 2026-09-07 | Admin `16cc456` | BLOCK-001 | 阻塞 | 浏览器域名安全检查仍不可用 | Codex | +| 2026-09-07 | 测试环境部署版本 | BLOCK-001 | 通过 | 浏览器授权恢复并完成页面点击 | Codex | +| 2026-09-07 | 测试环境部署版本 | BUG-005 | 失败 | 5 类 IM 数据页面和概览统一返回 `resource not found` | Codex | +| 2026-09-07 | 测试环境部署版本 | BUG-006 | 失败 | 用户分组、敏感词、安全策略存在字典 Store API 异常 | Codex | +| 2026-09-07 | IM `35da302` / run 100 | BUG-005 | 通过 | 六类签名查询均为 HTTP 200;分页、筛选和消息详情通过 | Codex | +| 2026-09-07 | Admin 待发布版本 | BUG-006 | 代码通过 | 修复 4 个页面的字典 Store API 调用,生产构建通过 | Codex | +| 2026-09-07 | Admin `7839dce` / run 103 | BUG-006 | 部分通过 | 线上入口及 4 个动态页面包已更新,无错误方法调用;待登录态页面确认 | Codex | diff --git a/docs/admin-adfahi-research/需求文档.md b/docs/admin-adfahi-research/需求文档.md new file mode 100644 index 0000000..cd70c8e --- /dev/null +++ b/docs/admin-adfahi-research/需求文档.md @@ -0,0 +1,1462 @@ +# 鸽达达管理后台复刻需求文档 + +调研对象:https://admin.adfahi.cn +调研时间:2026-09-04 +当前登录账号视角:`heiyue` +目标:在本项目中复刻一套功能与页面结构一致的 IM 管理后台。 + +> 说明:本次通过内置浏览器逐项点击左侧菜单,进入页面后对有查询按钮的页面执行了“查询/查 询”,再采集当前可视区域截图、可见字段、表格列、操作按钮,并下载前端 bundle 反查接口路径。浏览器只读执行环境屏蔽了 `fetch/XMLHttpRequest`,因此未能直接读取接口 JSON body;本文档中的返回字段以页面查询后的渲染结果和前端接口定义反推。 + +## 0. 本次修订说明 + +根据补充要求,已做以下修订: + +- 截图从原先整页长图改为“当前可视区域截图”,减少大面积空白,更接近实际浏览器视图。 +- 每个列表页进入后均点击查询按钮;`群成员管理` 因群号必填,使用群组列表中已有群号 `68127154` 查询,已展示成员数据。 +- 单聊消息、群聊消息、文件管理、日志审计、用户登录、IP 统计、登录失败日志等页面查询后均展示了数据。 +- 邀请码管理已查询到数据;敏感词库、用户反馈当前查询结果为空,需求文档中按“需支持空态”记录。 +- 文档已在每个 `3.x` 页面章节内补充接口字段、查询参数、返回字段、按钮功能、表格字段、枚举、业务动作、危险操作确认、导入导出、分页和空态说明。 +- 新截图保存在 `docs/admin-adfahi-research/screenshots-queried/`。 + +## 1. 总体信息架构 + +左侧菜单分为 5 个一级模块,当前账号可见 20 个页面: + +| 一级模块 | 页面 | 前端路由 | +| --- | --- | --- | +| 数据概览 | 数据概览 | `/dashboard` | +| 用户与群组 | 用户管理 | `/users` | +| 用户与群组 | 用户分组 | `/user-policy-groups` | +| 用户与群组 | 群组管理 | `/groups` | +| 用户与群组 | 群成员管理 | `/group-members` | +| 用户与群组 | 邀请码管理 | `/invite-codes` | +| 内容审核 | 单聊消息 | `/private-messages` | +| 内容审核 | 群聊消息 | `/group-messages` | +| 内容审核 | 敏感词库 | `/sensitive-words` | +| 内容审核 | 文件管理 | `/file-records` | +| 内容审核 | 用户反馈 | `/feedbacks` | +| 日志审计 | 操作日志 | `/audit-logs` | +| 日志审计 | 用户账号审计 | `/user-account-audit` | +| 日志审计 | 群事件审计 | `/group-events` | +| 日志审计 | 管理员登录 | `/admin-login-history` | +| 日志审计 | 用户登录 | `/user-login-history` | +| 日志审计 | IP 账号统计 | `/ip-user-stats` | +| 日志审计 | 登录失败日志 | `/login-failure-logs` | +| 系统设置 | 私域设置 | `/tenant-settings` | +| 系统设置 | 安全策略 | `/security-settings` | + +菜单总览截图: + +![菜单总览](screenshots-queried/00-menu-overview.png) + +## 2. 全局布局与交互 + +后台采用典型管理系统布局: + +- 左侧固定菜单,支持一级菜单展开/收起。 +- 顶部显示当前账号头像、用户名、账户操作入口。 +- 主区域顶部有多标签页导航,打开过的页面会以 tab 形式保留,可关闭。 +- 列表页统一具备查询区、操作按钮区、表格、分页。 +- 详情、编辑、重置密码、IP 白名单、处理反馈、分组设置等操作预计以弹窗完成。 +- 危险操作需要二次确认,包括封禁、强制下线、强制注销、解散群、删除文件、删除敏感词、解锁账号等。 + +建议在本项目中按 HotGo 规范实现:后端 GoFrame 分层,前端 Vue 3 + Naive UI,枚举/字典由后端统一维护,前端不硬编码字典文本。 + +## 3. 页面需求 + +### 3.1 数据概览 + +路由:`/dashboard` +接口:`GET /admin/stats` + +页面展示统计卡片和趋势表: + +- 总用户数:16 +- 活跃用户:0 +- 总群组数:6 +- 总会话数:11 +- 总消息数:220 +- 今日消息:0 +- 用户增长趋势:日期、数量 +- 消息增长趋势:日期、数量 + +字段与数据口径: + +| 字段 | 接口字段建议 | 含义 | 展示/统计规则 | +| --- | --- | --- | --- | +| 总用户数 | `total_users` | 当前租户用户总量 | 建议统计未物理删除用户,可包含正常、封禁、注销 | +| 活跃用户 | `active_users` | 近期活跃用户 | 建议按当天或近 7 天登录/发消息口径统计 | +| 总群组数 | `total_groups` | 当前租户群组总量 | 建议包含正常和封禁群,已解散群是否计入需配置 | +| 总会话数 | `total_conversations` | 单聊和群聊会话总数 | 按会话去重统计 | +| 总消息数 | `total_messages` | 历史消息总量 | 包含文本、图片、视频、语音、文件、名片、系统消息 | +| 今日消息 | `today_messages` | 当日新增消息数 | 按服务器时区从 00:00 统计 | +| 用户增长趋势 | `user_growth[]` | 最近 7 天新增用户 | 元素包含 `date`、`count` | +| 消息增长趋势 | `message_growth[]` | 最近 7 天新增消息 | 元素包含 `date`、`count` | + +按钮与交互: + +- 本页无查询按钮,进入页面自动调用统计接口。 +- 统计卡片为空时数字显示 `0`,趋势表仍展示日期行,数量为 `0`。 +- 页面需支持后续扩展为折线图,但必须保留日期和数量明细。 + +截图: + +![数据概览](screenshots-queried/01-dashboard.png) + +### 3.2 用户管理 + +路由:`/users` +核心接口: + +- `GET /admin/users` +- `POST /admin/users` +- `GET /admin/users/{id}` +- `PATCH /admin/users/{id}/status` +- `POST /admin/users/{id}/reset_password` +- `POST /admin/users/{id}/ip-whitelist` +- `POST /admin/users/{id}/kick` +- `POST /admin/users/{id}/deactivate` +- `POST /admin/users/batch` +- `POST /admin/users/batch-ban` +- `POST /admin/users/ban-by-ip` +- `POST /admin/users/import-xlsx` + +查询条件: + +- 用户编号 +- 用户分组 +- 状态 +- 关键词:用户名、昵称、手机号 + +顶部操作: + +- 创建用户 +- 批量创建 +- 导入 Excel +- 下载模板 + +表格列: + +- 编号 +- 头像 +- 用户名 +- 昵称 +- 手机号 +- 注册方式 +- 状态 +- 用户分组 +- 最近登录 +- 创建时间 +- 操作 + +行操作: + +- 详情 +- 重置密码 +- IP 白名单 +- 调整分组 +- 封禁 +- 强制下线 +- 强制注销 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按条件筛选用户 | `GET /admin/users` | 点击后页码回到第 1 页;支持编号、分组、状态、关键词组合查询 | +| 重 置 | 清空筛选条件 | `GET /admin/users` | 清空编号、用户分组、状态、关键词后重新查询 | +| 创建用户 | 后台新增单个用户 | `POST /admin/users` | 弹窗填写昵称、手机号、密码、用户分组;平台账号还需选择租户;密码受安全策略限制 | +| 批量创建 | 批量生成用户 | `POST /admin/users/batch` | 支持批量数量、默认密码、默认分组;完成后展示成功/失败并支持下载结果 | +| 导入Excel | Excel 批量导入用户 | `POST /admin/users/import-xlsx` | 上传 `.xlsx`;模板列为昵称、手机号、密码;返回成功数、失败数、失败原因 | +| 下载模板 | 下载导入模板 | 静态生成或导出接口 | 模板示例字段:昵称、手机号、密码 | +| 详 情 | 查看用户详情 | `GET /admin/users/{id}` | 展示基础资料、状态、分组、最近登录、IP 白名单、安全权限等 | +| 重置密码 | 管理员重置密码 | `POST /admin/users/{id}/reset_password` | 弹窗输入新密码或生成密码;成功后写用户账号审计 | +| IP白名单 | 设置用户登录白名单 | `POST /admin/users/{id}/ip-whitelist` | 多行 IP/CIDR;用户白名单不覆盖全局登录 IP 黑名单 | +| 调整分组 | 修改用户策略组 | 用户分组分配接口 | 立即影响好友、建群、APP/Web 登录等权限 | +| 封 禁 / 解封 | 切换用户状态 | `PATCH /admin/users/{id}/status` | 封禁后撤销在线会话并禁止登录;需二次确认 | +| 强制下线 | 踢出在线终端 | `POST /admin/users/{id}/kick` | 不改变用户状态,仅撤销当前登录态 | +| 强制注销 | 注销用户账号 | `POST /admin/users/{id}/deactivate` | 高危操作,需二次确认;注销后按规则清理用户数据 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 编号 | `user_no` | 对外展示的用户编号 | 数字,支持精确查询 | +| 头像 | `avatar` | 用户头像 URL | 显示缩略图;无头像显示默认头像;支持点击查看 | +| 用户名 | `username` | 用户登录名/IM 账号 | 样例:`hy73074162` | +| 昵称 | `nickname` | 用户显示名 | 允许为空,空值显示 `--` | +| 手机号 | `mobile` / `phone` | 绑定手机号 | 建议脱敏展示 | +| 注册方式 | `register_type` | 用户来源 | 后台创建、手机号注册、邀请码注册 | +| 状态 | `status` | 账号状态 | 正常绿色、封禁红色、注销灰色 | +| 用户分组 | `policy_group_name` | 所属策略组 | 样例:默认用户组 | +| 最近登录 | `last_login_at` | 最近一次登录时间 | 无记录显示 `--` | +| 创建时间 | `created_at` | 用户创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 用户状态:`active` 正常、`disabled` 已封禁、`deleted` 已注销。 +- 注册方式:`admin` 后台创建、`mobile` 手机号注册、`invite_code` 邀请码注册。 +- 批量封禁结果:`success` 成功、`failed` 失败,失败项需返回 `username` 和 `reason`。 + +截图: + +![用户管理](screenshots-queried/02-user-management.png) + +### 3.3 用户分组 + +路由:`/user-policy-groups` +核心接口: + +- `GET /admin/user-policy-groups` +- `POST /admin/user-policy-groups` +- `PUT /admin/user-policy-groups/{id}` +- `DELETE /admin/user-policy-groups/{id}` +- `GET /admin/user-policy-groups/{id}/users` +- `GET/POST /admin/user-policy-groups/ip-whitelist` + +顶部操作: + +- 添加分组 +- IP 白名单 + +表格列: + +- 分组名称 +- 用户数 +- 允许加好友 +- 允许建群 +- 直接加好友 +- 好友上限 +- APP 登录 +- Web 登录 +- IP 白名单 +- 状态 +- 权重 +- 操作 + +编辑分组字段: + +- 分组名称 +- 默认用户组 +- 启用状态 +- 允许加好友 +- 允许建群 +- 直接添加好友 +- 允许 APP 登录 +- 允许 Web 登录 +- 启用 IP 白名单 +- 好友数量上限,0 表示不限 +- 排序权重 + +IP 白名单字段: + +- APP 登录白名单,每行一个 IPv4、IPv6 或 CIDR +- Web 登录白名单,每行一个 IPv4、IPv6 或 CIDR + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 添加分组 | 新建用户策略组 | `POST /admin/user-policy-groups` | 弹窗填写分组名称、开关权限、好友上限、排序权重 | +| IP 白名单 | 设置租户渠道白名单 | `GET/POST /admin/user-policy-groups/ip-whitelist` | 分 APP 登录白名单和 Web 登录白名单,每行一个 IP/CIDR | +| 编辑 | 修改策略组 | `PUT /admin/user-policy-groups/{id}` | 默认分组部分字段需禁用,避免系统无默认组 | +| 删除 | 删除策略组 | `DELETE /admin/user-policy-groups/{id}` | 默认用户组不可删除;分组下有用户时应阻止或要求迁移 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 分组名称 | `name` | 策略组名称 | 默认组显示“默认”标记 | +| 用户数 | `user_count` | 分组下用户数量 | 可扩展点击查看用户列表 | +| 允许加好友 | `allow_add_friend` | 是否允许主动加好友 | 允许/禁止 | +| 允许建群 | `allow_create_group` | 是否允许创建群 | 允许/禁止 | +| 直接加好友 | `direct_add_friend` | 加好友是否免确认 | 开启显示开启,关闭显示 `--` | +| 好友上限 | `max_friend_count` | 最大好友数量 | `0` 显示不限 | +| APP 登录 | `allow_app_login` | 是否允许 APP 登录 | 允许/禁止 | +| Web 登录 | `allow_web_login` | 是否允许 Web 登录 | 允许/禁止 | +| IP 白名单 | `ip_whitelist_enabled` | 是否启用分组白名单 | 已启用/未启用 | +| 状态 | `enabled` | 分组是否生效 | 启用/停用 | +| 权重 | `sort_order` | 排序或匹配优先级 | 数字 | + +枚举说明: + +- 布尔权限统一为 `true/false`,前端按字段语义显示为允许/禁止、启用/停用、开启/关闭。 +- 好友上限 `0` 代表不限。 +- IP 白名单启用后,APP/Web 登录需命中对应白名单;全局黑名单优先级高于白名单。 + +截图: + +![用户分组](screenshots-queried/03-user-groups.png) + +### 3.4 群组管理 + +路由:`/groups` +核心接口: + +- `GET /admin/groups` +- `GET /admin/groups/{id}` +- `POST /admin/groups/{id}/ban` +- `POST /admin/groups/{id}/unban` +- `DELETE /admin/groups/{id}` +- `GET /admin/groups/{id}/members` +- `GET /admin/groups/{id}/files` +- `DELETE /admin/groups/{id}/files/{fileId}` +- `POST /admin/groups/quick-create` + +查询条件: + +- 群号 +- 关键词:群名称 +- 状态 + +表格列: + +- 群组编号 +- 群号 +- 群组名称 +- 成员数 +- 群主昵称 +- 群主用户名 +- 状态 +- 创建时间 +- 操作 + +行操作: + +- 详情 +- 群文件 +- 封禁 +- 解散 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按群号、群名、状态筛选 | `GET /admin/groups` | 点击后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/groups` | 清空群号、关键词、状态后重新查询 | +| 详 情 | 查看群详情 | `GET /admin/groups/{id}` | 展示群号、群名、群主、成员数、状态、创建时间 | +| 群文件 | 查看群文件 | `GET /admin/groups/{id}/files` | 弹窗列表支持类型筛选、下载、删除 | +| 封 禁 / 解封 | 切换群状态 | `POST /admin/groups/{id}/ban`、`POST /admin/groups/{id}/unban` | 封禁后群内不可发送消息;需记录群事件审计 | +| 解 散 | 解散群聊 | `DELETE /admin/groups/{id}` | 高危操作,需二次确认;清理成员关系,保留历史记录 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 群组编号 | `id` | 群内部编号 | 数字 | +| 群号 | `group_no` / `group_code` | 用户端可搜索群号 | 支持精确查询 | +| 群组名称 | `name` | 群显示名称 | 样例:冯、冯氏家族 | +| 成员数 | `member_count` | 当前成员数量 | 数字 | +| 群主昵称 | `owner_nickname` | 群主昵称 | 空值显示 `--` | +| 群主用户名 | `owner_username` | 群主用户名 | 样例:`hy69089908` | +| 状态 | `status` | 群状态 | 正常/封禁/已解散 | +| 创建时间 | `created_at` | 群创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 群组状态:`normal` 正常、`banned` 已封禁、`dissolved` 已解散。 +- 群文件类型:图片、视频、音频、文档、其他。 + +截图: + +![群组管理](screenshots-queried/04-group-management.png) + +### 3.5 群成员管理 + +路由:`/group-members` +接口依赖: + +- `GET /admin/groups/{id}/members` +- `POST /admin/groups/{id}/members/kick` +- `POST /admin/groups/{id}/members/mute` +- `POST /admin/groups/{id}/members/unmute` + +查询条件: + +- 群号,必填 +- 角色 +- 关键词:用户名、昵称 + +表格列: + +- 编号 +- 用户名 +- 昵称 +- 群昵称 +- 角色 +- 禁言状态 +- 加入时间 +- 操作 + +顶部操作: + +- 查询 +- 重置 +- 刷新 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按群号查询成员 | `GET /admin/groups/{id}/members` | 群号必填;需先按群号解析群 ID | +| 重 置 | 清空群号、角色、关键词 | - | 清空后表格回到空态 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/groups/{id}/members` | 不清空已输入群号 | +| 禁 言 / 解除禁言 | 设置成员禁言状态 | `POST /admin/groups/{id}/members/mute`、`POST /admin/groups/{id}/members/unmute` | 需填写禁言时长/原因;写群事件审计 | +| 踢 出 | 将成员移出群 | `POST /admin/groups/{id}/members/kick` | 需二次确认;群主不可直接踢出 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 编号 | `user_no` | 成员用户编号 | 样例:3 | +| 用户名 | `username` | 成员用户名 | 样例:`hy69089908` | +| 昵称 | `nickname` | 成员昵称 | 样例:F | +| 群昵称 | `group_nickname` | 群内昵称 | 空值显示 `--` | +| 角色 | `role` | 群角色 | 群主/管理员/成员 | +| 禁言状态 | `mute_status` | 是否禁言 | 正常/已禁言 | +| 加入时间 | `joined_at` | 入群时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 群成员角色:`owner` 群主、`admin` 管理员、`member` 成员。 +- 禁言状态:`normal` 正常、`muted` 已禁言。 +- 本次查询样例使用群号 `68127154`,查询到成员 `hy69089908 / F`,角色为群主。 + +截图: + +![群成员管理](screenshots-queried/05-group-members.png) + +### 3.6 邀请码管理 + +路由:`/invite-codes` +核心接口: + +- `GET /admin/invite-codes` +- `POST /admin/invite-codes` +- `DELETE /admin/invite-codes/{id}` +- `GET /admin/invite-codes/{id}/usages` + +查询条件: + +- 状态 +- 邀请码,6 位数字 + +顶部操作: + +- 生成邀请码 + +业务说明: + +- 每次生成 1 个邀请码,可多次使用。 +- 可指定企业与用户。 +- 可勾选不限制时间,永久有效。 +- 可开启自动加好友,使用该码注册的用户直接成为指定用户好友。 +- 可查看使用该邀请码注册的用户列表。 + +表格列: + +- 邀请码 +- 指定用户 +- 自动加好友 +- 使用人数 +- 创建人 +- 状态 +- 过期时间 +- 创建时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按状态/邀请码筛选 | `GET /admin/invite-codes` | 邀请码为 6 位数字 | +| 重 置 | 清空状态和邀请码 | `GET /admin/invite-codes` | 回到全部列表 | +| 生成邀请码 | 创建邀请码 | `POST /admin/invite-codes` | 可指定用户、有效期、是否自动加好友;生成 6 位数字码 | +| 查 看 | 查看使用记录 | `GET /admin/invite-codes/{id}/usages` | 展示使用该码注册的用户列表 | +| 删 除 | 删除/停用邀请码 | `DELETE /admin/invite-codes/{id}` | 已使用邀请码建议标记停用,不物理删除 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 邀请码 | `code` | 6 位数字邀请码 | 样例:323441 | +| 指定用户 | `target_username` / `target_user_no` | 注册后绑定或自动加好友目标 | 样例:往事随风 (#2271) | +| 自动加好友 | `auto_add_friend` | 是否自动成为指定用户好友 | 是/否 | +| 使用人数 | `used_count` | 已使用注册人数 | 可点击查看详情 | +| 创建人 | `creator_name` / `creator_id` | 创建者 | 当前样例显示编号 16 | +| 状态 | `status` | 邀请码状态 | 启用/停用/过期 | +| 过期时间 | `expired_at` | 有效期截止时间 | 永久显示“不限制”或“永久” | +| 创建时间 | `created_at` | 创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 邀请码状态:`active` 启用、`disabled` 停用、`expired` 过期。 +- 自动加好友:`true` 是、`false` 否。 + +截图: + +![邀请码管理](screenshots-queried/06-invite-codes.png) + +### 3.7 单聊消息 + +路由:`/private-messages` +核心接口: + +- `GET /admin/messages/search` +- `GET /admin/messages/{id}` + +查询条件: + +- 消息类型:全部、文本、图片、视频、语音、文件、名片、系统 +- 发送人用户名 +- 接收人用户名 +- 关键词 +- 开始时间 +- 结束时间 + +表格列: + +- 发送人 +- 接收人 +- 类型 +- 内容 +- 状态 +- 发送时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按消息条件检索 | `GET /admin/messages/search` | 查询参数含 `conversation_type=private` | +| 重 置 | 清空筛选条件 | `GET /admin/messages/search` | 清空消息类型、发送人、接收人、关键词、时间 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/messages/search` | 不改变页码和筛选条件 | +| 删 除 | 删除或隐藏消息 | 消息删除接口 | 需二次确认;写内容审核/操作日志 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 发送人 | `sender_nickname`、`sender_username` | 发送方用户 | 两行展示昵称和用户名 | +| 接收人 | `receiver_nickname`、`receiver_username` | 接收方用户 | 两行展示昵称和用户名 | +| 类型 | `message_type` | 消息类型 | 文本/图片/视频/语音/文件/名片/系统 | +| 内容 | `content` | 消息摘要 | 文本直接展示,媒体显示预览/下载 | +| 状态 | `status` | 消息状态 | 正常/已删除/已撤回/拦截 | +| 发送时间 | `sent_at` | 消息发送时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 消息类型:`text` 文本、`image` 图片、`video` 视频、`voice/audio` 语音、`file` 文件、`card` 名片、`system` 系统。 +- 消息状态:`normal` 正常、`deleted` 后台删除、`recalled` 用户撤回、`blocked` 风控拦截。 +- 查询后样例:`F / hy69089908` 给 `冯仁飞 / hy91144536` 发送文本 `哈哈哈哈`。 + +截图: + +![单聊消息](screenshots-queried/07-direct-messages.png) + +### 3.8 群聊消息 + +路由:`/group-messages` +核心接口: + +- `GET /admin/messages/search` +- `GET /admin/messages/{id}` + +查询条件: + +- 消息类型:全部、文本、图片、视频、语音、文件、名片、系统 +- 发送人用户名 +- 关键词 +- 开始时间 +- 结束时间 + +表格列: + +- 发送人 +- 群名称 +- 类型 +- 内容 +- 状态 +- 发送时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按群聊消息条件检索 | `GET /admin/messages/search` | 查询参数含 `conversation_type=group` | +| 重 置 | 清空筛选条件 | `GET /admin/messages/search` | 清空消息类型、发送人、关键词、时间 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/messages/search` | 不改变页码和筛选条件 | +| 删 除 | 删除或隐藏群消息 | 消息删除接口 | 需二次确认;建议写群事件/内容审核日志 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 发送人 | `sender_nickname`、`sender_username` | 发送方用户 | 两行展示 | +| 群名称 | `group_name`、`group_no` | 消息所属群 | 两行展示群名和群号 | +| 类型 | `message_type` | 消息类型 | 文本/图片/视频/语音/文件/名片/系统 | +| 内容 | `content` | 消息摘要 | 文本展示内容,媒体展示预览/下载 | +| 状态 | `status` | 消息状态 | 正常/已删除/已撤回/拦截 | +| 发送时间 | `sent_at` | 发送时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 消息类型和消息状态同单聊消息。 +- 查询后样例:`F / hy69089908` 在群 `冯 / 68127154` 发送文本 `哈哈哈哈`。 + +截图: + +![群聊消息](screenshots-queried/08-group-messages.png) + +### 3.9 敏感词库 + +路由:`/sensitive-words` +核心接口: + +- `GET /admin/sensitive-words` +- `POST /admin/sensitive-words` +- `PUT /admin/sensitive-words/{id}` +- `DELETE /admin/sensitive-words/{id}` +- `POST /admin/sensitive-words/import` +- `GET /admin/sensitive-words/export` + +查询条件: + +- 状态 +- 等级 +- 关键词:敏感词 + +顶部操作: + +- 新增敏感词 +- 批量导入 +- 文件导入 +- 批量导出 + +表格列: + +- ID +- 敏感词 +- 等级 +- 替换词 +- 状态 +- 创建时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按状态、等级、关键词筛选 | `GET /admin/sensitive-words` | 点击后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/sensitive-words` | 回到全部词库 | +| 新增敏感词 | 新增单个词条 | `POST /admin/sensitive-words` | 弹窗填写敏感词、等级、替换词、状态 | +| 批量导入 | 粘贴多行导入 | `POST /admin/sensitive-words/import` | 返回成功、重复、失败数量 | +| 文件导入 | 上传文件导入 | `POST /admin/sensitive-words/import` | 支持 `.txt`、`.csv`、`.xlsx` | +| 批量导出 | 导出当前筛选词库 | `GET /admin/sensitive-words/export` | 导出 CSV/XLSX | +| 编辑 | 编辑词条 | `PUT /admin/sensitive-words/{id}` | 修改后刷新敏感词缓存 | +| 删除 | 删除词条 | `DELETE /admin/sensitive-words/{id}` | 需二次确认,建议软删除 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| ID | `id` | 敏感词编号 | 数字 | +| 敏感词 | `word` | 待匹配词条 | 必填,建议唯一 | +| 等级 | `level` | 风险等级 | 低/中/高 | +| 替换词 | `replace_word` | 命中替代文本 | 空值显示 `--` | +| 状态 | `status` | 是否生效 | 启用/停用 | +| 创建时间 | `created_at` | 创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 敏感词等级:`low` 低、`medium` 中、`high` 高。 +- 敏感词状态:`active` 启用、`disabled` 停用。 +- 处理动作建议扩展:`replace` 替换、`block` 阻断、`alert` 告警。 +- 当前页面查询结果为空,需支持“暂无数据”空态。 + +截图: + +![敏感词库](screenshots-queried/09-sensitive-words.png) + +### 3.10 文件管理 + +路由:`/file-records` +接口: + +- 复用 `GET /admin/messages/search` +- 文件删除可复用消息/群文件删除接口 + +查询条件: + +- 文件类型:全部、图片、视频、音频、文档、其他 +- 发送人用户名 +- 文件名 +- 开始时间 +- 结束时间 + +表格列: + +- 发送人 +- 接收方 +- 类型 +- 内容 +- 文件名 +- 文件大小 +- URL +- 状态 +- 发送时间 +- 操作 + +行操作: + +- 预览 +- 下载 +- 删除 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按文件条件检索 | `GET /admin/messages/search` | 仅筛选包含文件 URL 的消息 | +| 重 置 | 清空筛选条件 | `GET /admin/messages/search` | 清空类型、发送人、文件名、时间 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/messages/search` | 不改变当前页 | +| 预览 | 查看图片/视频/音频 | 文件 URL | 只对可预览类型显示 | +| 下载 | 下载文件 | 文件 URL | URL 需鉴权或短期签名,避免泄露 | +| 删 除 | 删除文件记录/隐藏文件消息 | 消息或群文件删除接口 | 需二次确认;OSS 文件是否物理删除需配置 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 发送人 | `sender_nickname`、`sender_username` | 文件发送方 | 两行展示 | +| 接收方 | `receiver_name` / `group_name` | 单聊接收人或群 | 两行展示名称和编号 | +| 类型 | `file_type` / `message_type` | 文件类型 | 图片/视频/音频/文档/其他 | +| 内容 | `content` | 文件消息摘要 | 显示预览、下载快捷入口 | +| 文件名 | `file_name` | 原始文件名 | 空值显示 `--` | +| 文件大小 | `file_size` | 文件字节数 | 格式化为 B/KB/MB/GB | +| URL | `file_url` | 文件地址 | 表格省略展示,详情里显示完整 URL | +| 状态 | `status` | 文件消息状态 | 正常/已删除 | +| 发送时间 | `sent_at` | 上传或发送时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 文件分类:图片、视频、音频、文档、其他。 +- 图片扩展名:`jpg`、`jpeg`、`png`、`gif`、`bmp`、`webp`、`svg`。 +- 视频扩展名:`mp4`、`avi`、`mov`、`mkv`、`wmv`、`flv`、`webm`。 +- 音频扩展名:`mp3`、`wav`、`flac`、`aac`、`ogg`、`wma`。 +- 文档扩展名:`doc`、`docx`、`xls`、`xlsx`、`csv`、`ppt`、`pptx`、`pdf`、`txt`、`md`。 + +截图: + +![文件管理](screenshots-queried/10-files.png) + +### 3.11 用户反馈 + +路由:`/feedbacks` +核心接口: + +- `GET /admin/feedbacks` +- `PUT /admin/feedbacks/{id}` +- `POST /admin/feedbacks/{id}/priority` +- `POST /admin/feedbacks/batch-handle` +- `GET /admin/feedbacks/export` + +查询条件: + +- 用户 ID +- 状态 +- 优先级 +- 关键词:内容、联系方式 + +顶部操作: + +- 批量处理 +- 导出 CSV + +表格列: + +- 用户 +- 优先级 +- 内容 +- 状态 +- 处理人 +- 创建时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按用户、状态、优先级、关键词筛选 | `GET /admin/feedbacks` | 关键词匹配内容或联系方式 | +| 重 置 | 清空筛选条件 | `GET /admin/feedbacks` | 回到全部列表 | +| 批量处理 | 批量更新反馈处理状态 | `POST /admin/feedbacks/batch-handle` | 对选中反馈统一设置状态和处理备注 | +| 导出 CSV | 导出当前筛选结果 | `GET /admin/feedbacks/export` | 包含用户、联系方式、内容、状态、处理信息 | +| 处理 | 单条处理反馈 | `PUT /admin/feedbacks/{id}` | 填写处理状态、处理备注,记录处理人和处理时间 | +| 调整优先级 | 快速修改优先级 | `POST /admin/feedbacks/{id}/priority` | 修改后刷新列表 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 用户 | `user_id`、`username`、`nickname` | 反馈用户 | 支持按用户 ID 筛选 | +| 优先级 | `priority` | 处理优先级 | 低/中/高,颜色区分 | +| 内容 | `content` | 反馈正文 | 长文本省略,详情查看完整 | +| 状态 | `status` | 处理状态 | 待处理/处理中/已处理/已关闭 | +| 处理人 | `handler_name` | 最后处理管理员 | 未处理显示 `--` | +| 创建时间 | `created_at` | 反馈提交时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 优先级:`low` 低、`medium` 中、`high` 高。 +- 反馈状态:`pending` 待处理、`processing` 处理中、`resolved` 已处理、`closed` 已关闭。 +- 当前页面查询结果为空,需支持“暂无数据”空态。 + +截图: + +![用户反馈](screenshots-queried/11-feedback.png) + +### 3.12 操作日志 + +路由:`/audit-logs` +接口:`GET /admin/audit/logs` + +查询条件: + +- 动作 +- 目标类型 +- 目标用户 +- 目标群 +- 开始时间 +- 结束时间 + +顶部操作: + +- 查询 +- 重置 +- 导出 CSV + +表格列: + +- 管理员 +- 动作 +- 目标类型 +- 目标 +- 查询条件 +- IP +- 时间 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按动作、目标类型、目标用户、目标群、时间范围筛选 | `GET /admin/audit/logs` | 查询后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/audit/logs` | 回到全部日志 | +| 导出 CSV | 导出当前筛选日志 | 导出接口 | 用于审计留存 | +| 详情 | 查看操作详情 | `GET /admin/audit/logs` 或详情接口 | 展示请求参数、变更内容、管理员信息 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 管理员 | `admin_username` | 执行动作的后台账号 | 样例:heiyue | +| 动作 | `action` | 操作类型 | 创建用户、重置密码、封禁等 | +| 目标类型 | `target_type` | 操作对象类型 | 用户、群、消息、文件、设置 | +| 目标 | `target_summary` | 目标摘要 | 用户显示昵称和用户名 | +| 查询条件 | `query_condition` | 操作时使用的查询条件 | 无则显示 `--` | +| IP | `ip` | 管理员操作 IP | IPv4/IPv6 | +| 时间 | `created_at` | 操作发生时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 动作:创建用户、重置密码、修改用户名、更新状态、调整分组、强制下线、强制注销、批量封禁、按 IP 封禁、封禁群、解封群、解散群、删除消息、删除文件、保存设置等。 +- 目标类型:`user` 用户、`group` 群、`message` 消息、`file` 文件、`setting` 设置。 +- 查询后样例:`heiyue` 重置用户密码,目标为 `往事随风 / hy17716319`,IP 为 `141.164.107.33`。 + +截图: + +![操作日志](screenshots-queried/12-operation-logs.png) + +### 3.13 用户账号审计 + +路由:`/user-account-audit` +接口:`GET /admin/audit/user-account-logs` + +查询条件: + +- 用户编号 +- 用户名 +- 操作者 +- 操作 +- IP +- 时间范围 + +操作类型包括: + +- 修改昵称、修改头像、修改性别、修改签名、修改生日、修改密码 +- 主动注销 +- 管理员修改用户名、管理员更新状态、管理员重置密码、管理员更新安全权限 +- 管理员强制下线、管理员强制注销、管理员调整分组、管理员批量封禁、管理员按 IP 封禁 + +表格列: + +- 操作时间 +- 目标用户 +- 操作者 +- 操作类型 +- 变更摘要 +- IP / 归属地 +- 设备/客户端 + +按钮功能说明: + +- 查询:按用户编号、用户名、操作者、操作、IP、时间范围筛选用户账号审计日志。 +- 重置:清空全部筛选条件并重新查询。 +- 本页不提供写操作,只用于审计追踪。 + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 操作时间 | `created_at` | 账号变更时间 | 倒序排列 | +| 目标用户 | `target_username`、`target_user_no` | 被操作用户 | 两行展示用户名和编号 | +| 操作者 | `actor_type`、`actor_username` | 管理员或用户本人 | 标签区分管理员/用户本人 | +| 操作类型 | `action` | 账号审计动作 | 字典化为中文 | +| 变更摘要 | `details` / `before_data` / `after_data` | 变更内容 | 长内容 tooltip 展示 | +| IP / 归属地 | `ip`、`ip_location` | 操作来源 | IP 和归属地分行展示 | +| 设备/客户端 | `device_id` / `user_agent` | 客户端信息 | 长文本省略,tooltip 查看 | + +操作类型枚举: + +| 值 | 中文 | +| --- | --- | +| `change_nickname` | 修改昵称 | +| `change_avatar` | 修改头像 | +| `change_gender` | 修改性别 | +| `change_signature` | 修改签名 | +| `change_birthday` | 修改生日 | +| `change_password` | 修改密码 | +| `deactivate_account` | 主动注销 | +| `update_username` | 管理员修改用户名 | +| `update_status` | 管理员更新状态 | +| `reset_password` | 管理员重置密码 | +| `update_security_perm` | 管理员更新安全权限 | +| `kick_user` | 管理员强制下线 | +| `force_deactivate` | 管理员强制注销 | +| `assign_policy_group` | 管理员调整分组 | +| `batch_ban_users` | 管理员批量封禁 | +| `ban_users_by_ip` | 管理员按 IP 封禁 | + +截图: + +![用户账号审计](screenshots-queried/13-user-account-audit.png) + +### 3.14 群事件审计 + +路由:`/group-events` +接口:`GET /admin/groups/events` + +查询条件: + +- 群号 +- 操作人 +- 目标用户 +- 事件类型 +- 开始时间 +- 结束时间 + +表格列: + +- 群名称 +- 事件类型 +- 操作人 +- 目标用户 +- 可见范围 +- 旧值 +- 新值 +- 时间 + +按钮功能说明: + +- 查询:按群号、操作人、目标用户、事件类型、时间范围筛选。 +- 重置:清空筛选项重新查询。 +- 本页不提供写操作,数据由群创建、成员加入、踢出、禁言、解散等业务自动写入。 + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 群名称 | `group_name`、`group_no` | 事件所属群 | 两行展示群名和群号 | +| 事件类型 | `event_type` | 群事件 | 成员加入、群创建等 | +| 操作人 | `operator_username`、`operator_nickname` | 发起事件用户 | 系统事件显示系统 | +| 目标用户 | `target_username`、`target_nickname` | 被操作用户 | 无目标显示 `--` | +| 可见范围 | `visibility` | 事件对谁可见 | 全员可见、管理员可见、仅自己 | +| 旧值 | `old_value` | 变更前值 | 无则 `--` | +| 新值 | `new_value` | 变更后值 | 无则 `--` | +| 时间 | `created_at` | 事件时间 | `YYYY-MM-DD HH:mm:ss` | + +事件类型枚举建议: + +- `group_created` 群创建。 +- `member_joined` 成员加入。 +- `member_left` 成员退出。 +- `member_kicked` 成员被踢。 +- `member_muted` 成员禁言。 +- `member_unmuted` 解除禁言。 +- `owner_transferred` 群主转让。 +- `admin_added` 设置管理员。 +- `admin_removed` 取消管理员。 +- `group_name_changed` 修改群名。 +- `group_banned` 群封禁。 +- `group_unbanned` 群解封。 +- `group_dissolved` 群解散。 + +截图: + +![群事件审计](screenshots-queried/14-group-event-audit.png) + +### 3.15 管理员登录历史 + +路由:`/admin-login-history` +接口:`GET /admin/admin-login-history` + +查询条件: + +- 开始时间 +- 结束时间 + +表格列: + +- 用户名 +- 登录时间 +- User-Agent +- IP 地址 + +按钮功能说明: + +- 查询:按开始时间、结束时间筛选管理员登录历史。 +- 分页:默认 `20 条/页`。 +- 本页不提供写操作。 + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 用户名 | `username` | 管理员账号 | 样例:heiyue | +| 登录时间 | `login_at` | 登录成功时间 | 倒序排列 | +| User-Agent | `user_agent` | 浏览器 UA | 长文本横向滚动或 tooltip | +| IP 地址 | `ip` | 登录 IP | IPv4/IPv6 | + +截图: + +![管理员登录](screenshots-queried/15-admin-login.png) + +### 3.16 用户登录历史 + +路由:`/user-login-history` +接口:`GET /admin/user-login-history` + +查询条件: + +- 用户编号 +- 用户名 +- 开始时间 +- 结束时间 +- IP + +表格列: + +- 用户编号 +- 用户名 +- 当前状态 +- 登录时间 +- 设备类型 +- 设备型号 +- 系统版本 +- App 版本 +- IP 地址 +- 归属地 +- 操作 + +行操作: + +- 快速封禁 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按用户编号、用户名、时间、IP 筛选 | `GET /admin/user-login-history` | 查询后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/user-login-history` | 回到全部登录历史 | +| 快速封禁 | 从登录记录封禁用户 | `PATCH /admin/users/{id}/status` | 需二次确认;封禁后撤销在线会话 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 用户编号 | `user_no` | 用户对外编号 | 支持筛选 | +| 用户名 | `username` | 用户名 | 支持筛选 | +| 当前状态 | `status` | 用户当前状态 | 正常/已封禁/已注销 | +| 登录时间 | `login_at` | 本次登录时间 | 倒序排列 | +| 设备类型 | `device_type` | 登录终端 | web/android/ios | +| 设备型号 | `device_model` | 设备名称 | 样例:OPPO PCHM10 | +| 系统版本 | `os_version` | 操作系统版本 | 样例:Android 11 | +| App 版本 | `app_version` | 客户端版本 | web 可显示 `-` | +| IP 地址 | `ip` | 登录 IP | 支持筛选 | +| 归属地 | `ip_location` | IP 地理位置 | 无法解析显示未知 | + +枚举说明: + +- 设备类型:`web` Web 端、`android` Android APP、`ios` iOS APP、`unknown` 未知设备。 +- 用户状态同用户管理。 + +截图: + +![用户登录](screenshots-queried/16-user-login.png) + +### 3.17 IP 账号统计 + +路由:`/ip-user-stats` +核心接口: + +- `GET /admin/audit/ip-user-stats` +- `GET /admin/audit/ip-user-stats/users` +- `POST /admin/users/ban-by-ip` + +查询条件: + +- IP,支持 IPv4 或 IPv6 + +表格列: + +- IP 地址 +- 归属地 +- 关联用户 +- 注册用户 +- 登录用户 +- 正常 +- 已封禁 +- 最近活动 +- 操作 + +行操作: + +- 用户列表 +- 封禁 IP,平台级账号可见 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按 IP 筛选统计 | `GET /admin/audit/ip-user-stats` | 支持 IPv4/IPv6 | +| 重 置 | 清空 IP | `GET /admin/audit/ip-user-stats` | 回到全部统计 | +| 用户列表 | 查看该 IP 关联用户 | `GET /admin/audit/ip-user-stats/users` | 弹窗展示注册/登录命中的用户 | +| 封禁 IP | 封禁该 IP 关联用户 | `POST /admin/users/ban-by-ip` | 平台账号可见;返回命中数、成功数、失败数 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| IP 地址 | `ip` | 统计 IP | IPv4/IPv6 | +| 归属地 | `ip_location` | IP 地理位置 | 样例:浙江 绍兴 | +| 关联用户 | `user_count` | 注册或登录命中过该 IP 的用户数 | 大于 1 显示“多账号”标签 | +| 注册用户 | `register_user_count` | 使用该 IP 注册的用户数 | 数字 | +| 登录用户 | `login_user_count` | 使用该 IP 登录的用户数 | 数字 | +| 正常 | `active_user_count` | 关联用户中正常数量 | 数字 | +| 已封禁 | `disabled_user_count` | 关联用户中封禁数量 | 数字 | +| 最近活动 | `last_seen_at` | 最近注册或登录时间 | `YYYY-MM-DD HH:mm:ss` | + +关联用户弹窗字段: + +- 用户 ID,平台账号可见。 +- 用户编号、用户名、昵称。 +- 来源:注册、登录,可同时命中。 +- 状态:正常、已封禁。 +- 最近活动。 + +截图: + +![IP 账号统计](screenshots-queried/17-ip-account-stats.png) + +### 3.18 登录失败日志 + +路由:`/login-failure-logs` +核心接口: + +- `GET /admin/login-failure-logs` +- `POST /admin/security/unlock` + +查询条件: + +- 类型 +- 登录账号 +- IP +- 开始时间 +- 结束时间 + +表格列: + +- 类型 +- 登录账号 +- 失败原因 +- IP 地址 +- User-Agent +- 时间 +- 操作 + +行操作: + +- 解锁 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按类型、账号、IP、时间筛选 | `GET /admin/login-failure-logs` | 查询后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/login-failure-logs` | 回到全部失败记录 | +| 解锁 | 解除账号或 IP 登录锁定 | `POST /admin/security/unlock` | 需二次确认 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 类型 | `type` | 登录失败账号类型 | 用户/管理员 | +| 登录账号 | `account` | 输入的账号 | 手机号、用户名或管理员账号 | +| 失败原因 | `reason` | 失败说明 | 样例:密码错误 | +| IP 地址 | `ip` | 失败来源 IP | 支持筛选 | +| User-Agent | `user_agent` | 客户端 UA | 长文本省略 | +| 时间 | `created_at` | 失败时间 | 倒序排列 | + +枚举说明: + +- 类型:`user` 用户、`admin` 管理员。 +- 失败原因:`password_error` 密码错误、`account_not_found` 账号不存在、`account_disabled` 账号被封禁、`captcha_error` 验证码错误、`ip_blocked` IP 被拦截、`too_many_attempts` 连续失败次数过多。 + +截图: + +![登录失败日志](screenshots-queried/18-login-failures.png) + +### 3.19 私域设置 + +路由:`/tenant-settings` +核心接口: + +- `GET /admin/tenant-settings` +- `PUT /admin/tenant-settings` + +配置项: + +- 消息字符上限:范围 1 到 5000,仅允许调低 +- 图片大小上限:范围 1 到 1000 MB +- 文件大小上限:范围 1 到 1000 MB,默认 50 MB +- 最大撤回消息时间:范围 1 到 1440 分钟;群主/管理员可撤回自己或他人消息,普通用户撤回自己的消息固定为 2 分钟 +- 是否开启手机验证码:关闭后,注册仅支持邀请码,短信登录与忘记密码不可用 + +操作: + +- 保存设置 + +按钮与字段说明: + +| 字段/按钮 | 接口字段建议 | 范围/枚举 | 业务影响 | +| --- | --- | --- | --- | +| 消息字符上限 | `message_char_limit` | 1 到 5000,仅允许调低 | 用户发送文本消息时校验长度 | +| 图片大小上限(MB) | `image_size_limit_mb` | 1 到 1000 | 图片上传前后端双校验 | +| 文件大小上限(MB) | `file_size_limit_mb` | 1 到 1000,默认 50 | 文件上传前后端双校验 | +| 最大撤回消息时间(分钟) | `max_recall_minutes` | 1 到 1440 | 群主/管理员撤回自己或他人消息的最长时限 | +| 是否开启手机验证码 | `sms_code_enabled` | 开启/关闭 | 关闭后注册仅支持邀请码,短信登录与忘记密码不可用 | +| 保存设置 | - | - | 提交当前租户配置,后端校验后清理租户配置缓存 | + +枚举说明: + +- `sms_code_enabled=true`:开启手机验证码,支持短信登录、忘记密码、手机号验证码注册。 +- `sms_code_enabled=false`:关闭手机验证码,仅支持邀请码注册。 + +截图: + +![私域设置](screenshots-queried/19-private-domain-settings.png) + +### 3.20 安全策略 + +路由:`/security-settings` +核心接口: + +- `GET /admin/security-settings` +- `PUT /admin/security-settings` +- `POST /admin/security/unlock` + +配置分组与字段: + +- 用户安全 + - 验证码触发次数:连续密码错误该次数后要求验证码,范围 1 到 10 + - 密码最小位数:范围 6 到 32 + - 强制包含字母 +- 注册登录管控 + - 用户注册功能 + - 修改密码功能 + - 用户登录功能 + - Web 端多端登录 +- 聊天消息管控 + - 全局好友功能 + - 全局群消息 + - 全局私聊消息 + - 群聊发言间隔:0 表示不限制;1 到 1000 表示同一群内两次发言的最小间隔 +- 群组功能管控 + - 群号搜索功能 + - 群创建功能 + - 群解散功能 + - 群管理员功能 + - 是否显示群人数 +- 其他功能开关 + - 允许用户注销账号 +- IP 访问控制 + - 注册 IP 黑名单:每行一个 IPv4、IPv6 或 CIDR,命中后无法注册及获取注册短信验证码 + - 登录 IP 黑名单:命中后无法密码登录、短信登录及刷新 Token;黑名单优先级高于用户分组登录白名单 +- 高级功能 + - 消息已读状态 + +操作: + +- 保存设置 + +按钮与字段说明: + +| 分组 | 字段 | 接口字段建议 | 范围/枚举 | 业务影响 | +| --- | --- | --- | --- | --- | +| 用户安全 | 验证码触发次数 | `captcha_trigger_count` | 1 到 10 | 连续密码错误达到次数后要求验证码 | +| 用户安全 | 密码最小位数 | `password_min_length` | 6 到 32 | 注册、修改密码、后台重置密码均生效 | +| 用户安全 | 强制包含字母 | `password_require_letter` | 开启/关闭 | 开启后密码必须包含英文字母 | +| 注册登录管控 | 用户注册功能 | `user_register_enabled` | 开启/关闭 | 关闭后用户端隐藏注册入口 | +| 注册登录管控 | 修改密码功能 | `password_change_enabled` | 开启/关闭 | 关闭后用户无法自行修改密码 | +| 注册登录管控 | 用户登录功能 | `user_login_enabled` | 开启/关闭 | 关闭后用户无法登录,不影响已登录用户 | +| 注册登录管控 | Web 端多端登录 | `web_multi_login_enabled` | 开启/关闭 | 开启后同账号可多浏览器同时登录 | +| 聊天消息管控 | 全局好友功能 | `global_friend_enabled` | 开启/关闭 | 关闭后无法搜索和添加好友 | +| 聊天消息管控 | 全局群消息 | `global_group_message_enabled` | 开启/关闭 | 关闭后所有群无法发送消息 | +| 聊天消息管控 | 全局私聊消息 | `global_private_message_enabled` | 开启/关闭 | 关闭后所有私聊无法发送消息 | +| 聊天消息管控 | 群聊发言间隔(秒) | `group_message_interval_seconds` | 0 到 1000 | 0 不限制,其他值限制同群发言间隔 | +| 群组功能管控 | 群号搜索功能 | `group_search_enabled` | 开启/关闭 | 关闭后隐藏群号和群搜索入口 | +| 群组功能管控 | 群创建功能 | `group_create_enabled` | 开启/关闭 | 关闭后所有人无法新建群聊 | +| 群组功能管控 | 群解散功能 | `group_dissolve_enabled` | 开启/关闭 | 关闭后用户端无法解散群,不影响后台 | +| 群组功能管控 | 群管理员功能 | `group_admin_enabled` | 开启/关闭 | 关闭后无法设置群管理员 | +| 群组功能管控 | 是否显示群人数 | `show_group_member_count` | 开启/关闭 | 关闭后用户端群资料不显示总人数 | +| 其他功能开关 | 允许用户注销账号 | `account_deactivate_enabled` | 开启/关闭 | 关闭后隐藏注销账号按钮 | +| IP 访问控制 | 注册 IP 黑名单 | `register_ip_blacklist` | 多行 IP/CIDR | 命中后无法注册和获取注册短信验证码 | +| IP 访问控制 | 登录 IP 黑名单 | `login_ip_blacklist` | 多行 IP/CIDR | 命中后无法密码登录、短信登录和刷新 Token | +| 高级功能 | 消息已读状态 | `message_read_status_enabled` | 开启/关闭 | 开启后显示消息已读/未读标记 | +| 保存设置 | - | - | 保存安全配置,写操作日志,刷新配置缓存 | + +枚举说明: + +- 所有开关均使用布尔值:`true` 开启,`false` 关闭。 +- IP 黑名单每行一个 IPv4、IPv6 或 CIDR,保存时去空行、去重、校验格式。 +- 登录 IP 黑名单优先于用户分组白名单。 + +截图: + +![安全策略](screenshots-queried/20-security-policy.png) + +## 4. 接口清单 + +从前端静态资源反查到的接口如下,建议后端按 REST 风格补齐: + +| 模块 | 接口 | +| --- | --- | +| 认证 | `/admin/auth/login`, `/admin/auth/profile`, `/admin/auth/password`, `/admin/auth/totp/status`, `/admin/auth/totp/setup`, `/admin/auth/totp/bind`, `/admin/auth/totp/unbind` | +| 统计 | `/admin/stats` | +| 用户 | `/admin/users`, `/admin/users/{id}`, `/admin/users/{id}/status`, `/admin/users/{id}/username`, `/admin/users/{id}/reset_password`, `/admin/users/{id}/ip-whitelist`, `/admin/users/{id}/friend-privilege`, `/admin/users/{id}/security-perm`, `/admin/users/{id}/kick`, `/admin/users/{id}/deactivate`, `/admin/users/batch`, `/admin/users/batch-ban`, `/admin/users/ban-by-ip`, `/admin/users/import-xlsx` | +| 用户分组 | `/admin/user-policy-groups`, `/admin/user-policy-groups/{id}`, `/admin/user-policy-groups/{id}/users`, `/admin/user-policy-groups/ip-whitelist` | +| 群组 | `/admin/groups`, `/admin/groups/{id}`, `/admin/groups/{id}/ban`, `/admin/groups/{id}/unban`, `/admin/groups/{id}/members`, `/admin/groups/{id}/members/kick`, `/admin/groups/{id}/members/mute`, `/admin/groups/{id}/members/unmute`, `/admin/groups/{id}/files`, `/admin/groups/{id}/files/{fileId}`, `/admin/groups/events`, `/admin/groups/quick-create` | +| 消息与文件 | `/admin/messages/search`, `/admin/messages/{id}` | +| 邀请码 | `/admin/invite-codes`, `/admin/invite-codes/{id}`, `/admin/invite-codes/{id}/usages` | +| 敏感词 | `/admin/sensitive-words`, `/admin/sensitive-words/{id}`, `/admin/sensitive-words/import`, `/admin/sensitive-words/export` | +| 反馈 | `/admin/feedbacks`, `/admin/feedbacks/{id}`, `/admin/feedbacks/{id}/priority`, `/admin/feedbacks/batch-handle`, `/admin/feedbacks/export` | +| 审计 | `/admin/audit/logs`, `/admin/audit/user-account-logs`, `/admin/audit/ip-user-stats`, `/admin/audit/ip-user-stats/users`, `/admin/control/audit-logs` | +| 登录日志 | `/admin/admin-login-history`, `/admin/user-login-history`, `/admin/login-failure-logs` | +| 设置 | `/admin/tenant-settings`, `/admin/security-settings`, `/admin/security/unlock` | +| 当前菜单未展示但 bundle 存在 | `/admin/tenants`, `/admin/admins`, `/admin/tenant-admins`, `/admin/roles`, `/admin/nodes`, `/admin/system/nodes-status`, `/admin/avatar-pool`, `/admin/avatar-pool/backfill`, `/admin/system-conversations`, `/admin/system-conversations/send`, `/admin/conversation-notices`, `/admin/reports`, `/admin/login-streak/rank`, `/admin/private-msg/rank`, `/admin/user-password`, `/admin/keyword-alerts`, `/admin/alert-keywords`, `/admin/alert-bot-config`, `/admin/domain/encrypt`, `/admin/domain/decrypt`, `/admin/domain/obfuscate`, `/admin/domain/config`, `/admin/domain/push-update`, `/admin/domain/push-bucket-update` | + +通用响应结构建议: + +```json +{ + "code": 0, + "message": "ok", + "data": { + "items": [], + "total": 0, + "page": 1, + "size": 20 + } +} +``` + +列表接口统一分页参数: + +- `page` +- `page_size` +- 各页面查询条件 + +## 5. 核心数据模型建议 + +建议至少设计以下数据表或领域模型: + +- 管理员账号:账号、密码、TOTP、角色、权限、登录历史 +- 用户:用户编号、用户名、昵称、手机号、头像、状态、注册方式、用户分组、最近登录、创建时间 +- 用户分组:权限开关、好友上限、登录白名单、排序权重、默认分组标记 +- 群组:群号、名称、群主、成员数、状态、创建时间 +- 群成员:用户、群、角色、群昵称、禁言状态、加入时间 +- 消息:会话类型、发送人、接收人/群、消息类型、内容、文件信息、状态、发送时间 +- 文件记录:发送人、接收方、文件类型、文件名、大小、URL、消息关联、状态 +- 邀请码:邀请码、指定用户、自动加好友、使用人数、创建人、状态、有效期 +- 敏感词:词条、等级、替换词、状态、创建时间 +- 用户反馈:用户、联系方式、内容、优先级、状态、处理人、处理备注 +- 操作日志:管理员、动作、目标类型、目标、查询条件、IP、时间 +- 用户账号审计:目标用户、操作者、操作类型、变更前后、IP、归属地、设备 +- 群事件审计:群、事件类型、操作人、目标用户、可见范围、旧值、新值、时间 +- 登录历史:账号类型、账号、登录时间、设备信息、IP、归属地 +- 登录失败日志:类型、账号、失败原因、IP、User-Agent、锁定/解锁状态 +- 租户设置:消息、文件、验证码、撤回时间等私域配置 +- 安全策略:密码、注册登录、聊天、群组、注销、IP 黑名单、已读状态配置 + +## 6. 权限建议 + +前端 bundle 中能看到的权限点包括: + +- `tenant_manage` +- `user_manage` +- `group_manage` +- `view_private_chat` +- `view_group_chat` +- `view_audit_log` + +建议在 HotGo 中拆成更细的菜单/按钮权限: + +- 用户管理:查看、创建、批量创建、导入、详情、重置密码、调整分组、封禁、强制下线、注销、IP 白名单 +- 群组管理:查看、详情、群文件、封禁、解散、成员踢出、禁言/解除禁言 +- 内容审核:消息查看、文件预览下载删除、敏感词增删改导入导出、反馈处理导出 +- 日志审计:查看、导出、解锁、IP 关联用户查看、按 IP 封禁 +- 系统设置:私域设置保存、安全策略保存 + +## 7. 实现优先级 + +第一阶段建议先完成可见菜单: + +1. 后台登录、权限、菜单、布局。 +2. 数据概览统计。 +3. 用户管理、用户分组、群组管理、群成员管理、邀请码管理。 +4. 单聊/群聊消息、文件管理、敏感词库、用户反馈。 +5. 操作日志、用户账号审计、群事件审计、登录历史、IP 统计、失败日志。 +6. 私域设置、安全策略。 + +第二阶段再补 bundle 中存在但当前菜单未展示的能力: + +- 租户、管理员、角色、节点、头像池、系统会话、系统通知、报表、排行榜、关键词告警、域名加密等。 + +## 8. 采集产物 + +本次调研文件保存在: + +- 截图目录:`docs/admin-adfahi-research/screenshots-queried/` +- 每页结构化采集:`docs/admin-adfahi-research/*.json` +- 前端 bundle:`docs/admin-adfahi-research/index-RosMaKu2.js` +- 接口尝试结果:`docs/admin-adfahi-research/api-samples.json` diff --git a/docs/backstage/IM-Admin内部查询接口缺口清单.md b/docs/backstage/IM-Admin内部查询接口缺口清单.md new file mode 100644 index 0000000..6831bc8 --- /dev/null +++ b/docs/backstage/IM-Admin内部查询接口缺口清单.md @@ -0,0 +1,582 @@ +# IM Admin 内部查询接口缺口清单 + +> 目标:梳理 HotGo/Admin 迁移到“Admin 只访问 hotgo,IM Server 只访问租户库”后,IM Server 需要补齐的 `/internal/admin-query/*` 只读接口。 +> 当前日期:2026-08-27 +> 适用项目:`im_manager` Admin 后台、`im_server` IM 服务端。 + +## 1. 背景 + +Kafka 写链路已经在 Admin 侧补齐:Admin 写操作落 `im_admin_commands`、`im_admin_outbox`,通过 Kafka 发给 IM Server,IM Server 执行业务后回传 `im.admin.command.result`。 + +查询链路还没有完全隔离。Admin 当前仍有不少页面通过 `imtenant.DB()` 或 `resolveImTenantDB()` 直连租户库。若要严格满足目标架构,Admin 查询租户业务数据时应调用 IM Server 的内部只读接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/... +``` + +IM Server 当前已经具备部分内部查询接口,但还不能覆盖 Admin 后台页面。 + +## 2. 已有接口 + +以下接口在 IM Server 当前实现中已经存在,可供 Admin 先迁移: + +| 接口 | 用途 | 备注 | +|---|---|---| +| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 用户详情 | 需要增强字段,见 P0。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 群详情和治理配置 | 需要增强 topic 基础字段,见 P0。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 群成员列表 | 当前偏 cursor 分页,Admin 表格还需要 total。 | +| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 消息详情 | 当前 Admin 暂未完整迁移消息页面。 | +| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 文件详情 | 还缺文件列表和引用列表。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | Kafka 死信列表 | Admin 已接入。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | Kafka 死信详情 | Admin 已接入。 | + +## 3. 统一约定 + +### 3.1 鉴权 + +沿用 IM Server 内部 API HMAC 鉴权: + +```text +X-Service-Name +X-Timestamp +X-Nonce +X-Signature +``` + +服务端需要校验: + +1. service 是否存在。 +2. service 是否包含 `admin_query` capability。 +3. timestamp 是否在允许窗口内。 +4. nonce 是否防重放。 +5. 签名是否匹配。 + +### 3.2 分页返回 + +Admin 的 `BasicTable` 更适合 page/per_page/total 格式。建议所有列表接口统一返回: + +```json +{ + "code": 0, + "data": { + "items": [], + "total": 0, + "page": 1, + "per_page": 20 + } +} +``` + +如果 IM Server 内部更适合 cursor,也建议同时返回: + +```json +{ + "code": 0, + "data": { + "items": [], + "total": 0, + "page": 1, + "per_page": 20, + "next_cursor": "", + "has_more": false + } +} +``` + +### 3.3 详情返回 + +详情接口统一返回: + +```json +{ + "code": 0, + "data": {} +} +``` + +### 3.4 时间与 ID + +1. 时间统一返回 RFC3339/RFC3339Nano 字符串。 +2. 雪花 ID、Tinode 用户 ID、19 位整数 ID 建议以字符串返回,避免前端丢精度。 +3. 入参同时兼容字符串 ID 和纯数字 ID 时,IM Server 内部自行解析。 + +## 4. P0:优先补齐 + +P0 是 Admin 当前核心页面迁移所需接口,补完后可以先迁移用户、群组、文件等高频页面。 + +### 4.1 用户列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/users +``` + +用途:IM 用户管理列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `user_id` | string | 用户 ID,支持精确查询。 | +| `nickname` | string | 昵称模糊查询。 | +| `auth_name` | string | 登录标识,匹配 username/phone/email/凭证。 | +| `state` | int | 用户状态。 | +| `created_at_start` | string | 创建时间开始。 | +| `created_at_end` | string | 创建时间结束。 | +| `last_seen_start` | string | 最后在线开始。 | +| `last_seen_end` | string | 最后在线结束。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量,最大建议 200。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `user_id` | 字符串用户 ID。 | +| `username` | 用户名。 | +| `phone` | 手机号。 | +| `nickname` | 昵称。 | +| `public_account` | 公开账号。 | +| `state` | 用户状态。 | +| `gender` | 性别。 | +| `region` | 地区。 | +| `auth_names` | 登录标识摘要。 | +| `credentials` | 凭证摘要。 | +| `auth_version` | 鉴权版本。 | +| `password_updated_at` | 密码更新时间。 | +| `last_seen` | 最后在线时间。 | +| `user_agent` | 最后设备 UA。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | + +当前 Admin 直连涉及表:`im_users`、`im_user_profiles`、`im_user_security`、`im_credentials`。 + +### 4.2 用户详情增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/users/{user_id} +``` + +建议增强返回字段: + +| 字段 | 说明 | +|---|---| +| `profile` | 结构化用户资料。 | +| `public` | Tinode public JSON。 | +| `trusted` | Tinode trusted JSON。 | +| `access` | 访问权限 JSON。 | +| `tags` | 标签。 | +| `security` | `auth_version/password_updated_at` 等安全信息。 | +| `credentials` | 凭证列表,包含 method/value/done。 | + +### 4.3 用户设备列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}/devices +``` + +用途:用户详情中的登录设备列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `device_id` | string | 设备 ID。 | +| `platform` | string | 平台。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `device_id` | 设备 ID。 | +| `platform` | 平台。 | +| `user_agent` | UA。 | +| `last_seen` | 最后在线。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | + +当前 Admin 直连涉及表:`im_devices`。 + +### 4.4 群列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups +``` + +用途:IM 群组管理列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `topic` | string | 群 Topic。 | +| `owner_id` | string | 群主 ID。 | +| `state` | int | Tinode topic 状态。 | +| `group_status` | string | 业务群状态。 | +| `created_at_start` | string | 创建时间开始。 | +| `created_at_end` | string | 创建时间结束。 | +| `touched_at_start` | string | 活跃时间开始。 | +| `touched_at_end` | string | 活跃时间结束。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `topic` | 群 Topic。 | +| `state` | Tinode 状态。 | +| `owner_id` | 群主 ID。 | +| `seq_id` | 最新消息序号。 | +| `del_id` | 删除序号。 | +| `sub_count` | Tinode 订阅成员数。 | +| `touched_at` | 活跃时间。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | +| `public` | Tinode public JSON。 | +| `trusted` | Tinode trusted JSON。 | +| `tags` | 标签。 | +| `aux` | 辅助 JSON。 | +| `group_name` | 群名称。 | +| `avatar_file_id` | 群头像文件 ID。 | +| `description` | 群简介。 | +| `group_status` | 业务群状态。 | +| `join_approval` | 入群审批开关。 | +| `mute_all` | 全员禁言。 | +| `member_upload_files` | 成员上传文件。 | +| `member_private_chat` | 成员私聊。 | +| `member_invite` | 成员邀请。 | +| `max_member_count` | 最大成员数。 | +| `max_admin_count` | 最大管理员数。 | +| `business_member_count` | 业务成员数。 | +| `settings_version` | 治理配置版本。 | +| `settings_updated_at` | 治理配置更新时间。 | + +当前 Admin 直连涉及表:`im_topics`、`im_group_settings`。 + +### 4.5 群详情增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic} +``` + +当前接口主要返回 `im_group_settings`。建议增强 Tinode topic 基础字段: + +| 字段 | 说明 | +|---|---| +| `state` | Topic 状态。 | +| `seq_id` | 最新消息序号。 | +| `del_id` | 删除序号。 | +| `sub_count` | 订阅数量。 | +| `public` | public JSON。 | +| `trusted` | trusted JSON。 | +| `access` | access JSON。 | +| `tags` | tags JSON。 | +| `aux` | aux JSON。 | +| `touched_at` | 活跃时间。 | + +### 4.6 群成员列表增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members +``` + +建议增强查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `user_id` | string | 用户 ID 精确过滤。 | +| `role` | string | `owner/admin/member`。 | +| `member_status` | string | 成员状态。 | +| `q` | string | 昵称、公开账号等关键词。 | +| `eligible_role` | string | 候选角色筛选。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | +| `cursor` | string | 兼容 cursor 分页。 | +| `limit` | int | 兼容 cursor 分页。 | + +建议增强返回: + +| 字段 | 说明 | +|---|---| +| `total` | 总数,Admin 表格需要。 | +| `items[].user_id` | 用户 ID。 | +| `items[].nickname` | 昵称。 | +| `items[].state` | 用户状态。 | +| `items[].mode_want` | 期望权限。 | +| `items[].mode_given` | 授予权限。 | +| `items[].recv_seq_id` | 接收序号。 | +| `items[].read_seq_id` | 已读序号。 | +| `items[].del_id` | 删除序号。 | +| `items[].role` | 业务角色。 | +| `items[].member_status` | 成员状态。 | +| `items[].group_nickname` | 群昵称。 | +| `items[].pinned` | 置顶。 | +| `items[].mute_notifications` | 免打扰。 | +| `items[].join_source` | 加入来源。 | +| `items[].joined_by` | 邀请或处理人。 | +| `items[].joined_at` | 加入时间。 | +| `items[].left_at` | 离开时间。 | +| `items[].removed_by` | 移除人。 | +| `items[].version` | 版本。 | + +当前 Admin 直连涉及表:`im_group_members`、`im_subscriptions`、`im_users`、`im_user_profiles`。 + +### 4.7 入群申请列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/join-requests +``` + +建议查询参数:`status`、`applicant_user_id`、`page`、`per_page`。 + +建议返回字段:`request_id`、`topic`、`applicant_user_id`、`inviter_user_id`、`source`、`message`、`status`、`reject_reason`、`handled_by`、`handled_at`、`expires_at`、`version`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_join_requests`。 + +### 4.8 群邀请列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/invitations +``` + +建议查询参数:`status`、`target_user_id`、`inviter_user_id`、`page`、`per_page`。 + +建议返回字段:`invitation_id`、`topic`、`inviter_user_id`、`target_user_id`、`message`、`status`、`result`、`join_request_id`、`expires_at`、`handled_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_invitations`。 + +### 4.9 群公告列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/announcements +``` + +建议查询参数:`status`、`page`、`per_page`。 + +建议返回字段:`announcement_id`、`topic`、`author_user_id`、`content`、`status`、`version`、`published_at`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_announcements`。 + +### 4.10 群禁言列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/mutes +``` + +建议查询参数:`status`、`user_id`、`page`、`per_page`。 + +建议返回字段:`topic`、`user_id`、`muted_by`、`muted_at`、`expires_at`、`reason`、`status`、`revoked_by`、`revoked_at`、`version`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_mutes`。 + +### 4.11 群管理日志 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/admin-logs +``` + +建议查询参数:`action`、`operator_id`、`page`、`per_page`。 + +建议返回字段:`log_id`、`topic`、`operator_id`、`target_user_id`、`action`、`object_type`、`object_id`、`before_data`、`after_data`、`request_id`、`idempotency_key`、`created_at`。 + +当前 Admin 直连涉及表:`im_group_admin_logs`。 + +### 4.12 群文件列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/files +``` + +用途:群治理页面中的群文件列表。不要和已有单文件详情接口混淆。 + +建议查询参数:`status`、`file_type`、`file_name`、`uploader_id`、`page`、`per_page`。 + +建议返回字段:`group_file_id`、`topic`、`file_id`、`source_topic`、`source_seq`、`uploader_id`、`file_name`、`mime_type`、`file_type`、`file_size`、`status`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_files`。 + +### 4.13 文件列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/files +``` + +用途:IM 文件管理列表。 + +建议查询参数:`file_id`、`user_id`、`status`、`mime_type`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`file_id`、`user_id`、`mime_type`、`file_size`、`file_name`、`location`、`etag`、`status`、`quarantine_reason`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_fileuploads`。 + +### 4.14 文件引用列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}/links +``` + +用途:文件详情中展示消息引用。 + +建议返回字段:`topic`、`seq_id`、`user_id`、`created_at`。 + +当前 Admin 直连涉及表:`im_filemsglinks`。 + +## 5. P1:后台审核和运营页面 + +### 5.1 审计日志 + +```text +GET /internal/admin-query/tenants/{tenant_code}/audit-logs +GET /internal/admin-query/tenants/{tenant_code}/audit-logs/{id} +``` + +建议查询参数:`actor_user_id`、`action`、`target_type`、`target_id`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`request_id`、`actor_user_id`、`action`、`target_type`、`target_id`、`ip`、`user_agent`、`detail`、`created_at`。 + +当前 Admin 直连涉及表:`im_audit_logs`。 + +### 5.2 实名审核 + +```text +GET /internal/admin-query/tenants/{tenant_code}/realname-verifications +GET /internal/admin-query/tenants/{tenant_code}/realname-verifications/{id} +``` + +建议查询参数:`user_id`、`status`、`real_name`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`user_id`、`real_name`、`id_card_no_masked`、`front_file_id`、`back_file_id`、`status`、`reject_reason`、`reviewed_by`、`reviewed_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_realname_verifications`。 + +### 5.3 举报工单 + +```text +GET /internal/admin-query/tenants/{tenant_code}/abuse-reports +GET /internal/admin-query/tenants/{tenant_code}/abuse-reports/{id} +GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target +``` + +建议查询参数:`reporter_user_id`、`target_type`、`target_id`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`report_id`、`reporter_user_id`、`target_type`、`target_id`、`reason_code`、`description`、`evidence_file_ids`、`status`、`handler_id`、`handled_at`、`handle_result`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_abuse_reports`。 + +### 5.4 意见反馈 + +```text +GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions +GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions/{id} +``` + +建议查询参数:`user_id`、`category`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`feedback_id`、`user_id`、`category`、`content`、`contact`、`file_ids`、`status`、`handler_id`、`handled_at`、`reply`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_feedback_submissions`。 + +### 5.5 业务 Outbox + +```text +GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events +GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events +GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events/{id} +GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events/{id} +``` + +建议查询参数:`event_type`、`status`、`target_type`、`target`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +当前 Admin 直连涉及表:`im_business_events_outbox`、`im_group_events_outbox`。 + +## 6. P2:如果要完全消除租户库直连 + +这些不是 Kafka 联调第一优先级,但如果最终要求 Admin 完全不碰租户库,也需要 IM Server 提供内部查询或改为 hotgo 摘要表。 + +### 6.1 组织架构 + +```text +GET /internal/admin-query/tenants/{tenant_code}/org/departments +GET /internal/admin-query/tenants/{tenant_code}/org/members +``` + +当前 Admin 直连涉及表:`im_org_departments`、`im_org_members`、`im_users`。 + +### 6.2 工作台应用 + +```text +GET /internal/admin-query/tenants/{tenant_code}/workbench/apps +``` + +当前 Admin 直连涉及工作台相关租户表。 + +### 6.3 租户任务看板摘要 + +```text +GET /internal/admin-query/tenants/{tenant_code}/dashboard/summary +``` + +当前 Admin 会统计多个租户库中的待处理实名、举报、反馈、Outbox 等数量。 + +### 6.4 租户配置类查询 + +如果这些配置仍放在租户库,也需要补接口;如果后续迁到 hotgo 控制库,则不需要通过 IM Server 查询。 + +```text +GET /internal/admin-query/tenants/{tenant_code}/feedback-config +GET /internal/admin-query/tenants/{tenant_code}/mailbox-config +GET /internal/admin-query/tenants/{tenant_code}/abuse-reasons +GET /internal/admin-query/tenants/{tenant_code}/app-releases +``` + +涉及模块:反馈配置、总经理信箱配置、举报原因、App 发布策略。 + +## 7. Admin 侧迁移建议 + +### 7.1 第一批迁移 + +在 IM Server 补完 P0 后,Admin 优先迁移: + +1. 用户列表、用户详情、用户设备。 +2. 群列表、群详情、群成员。 +3. 群入群申请、邀请、公告、禁言、管理日志、群文件。 +4. 文件列表、文件详情、文件引用。 + +### 7.2 第二批迁移 + +在 IM Server 补完 P1 后,Admin 再迁移: + +1. 审计日志。 +2. 实名审核。 +3. 举报工单。 +4. 意见反馈。 +5. 业务 Outbox。 + +### 7.3 保留 hotgo 查询的范围 + +以下数据天然属于 hotgo 控制库,Admin 可继续查 hotgo,不需要走 IM Server: + +1. 租户表和租户绑定表。 +2. Admin 命令表:`im_admin_commands`。 +3. Admin Outbox 表:`im_admin_outbox`。 +4. Admin Kafka 审计事件表:`im_audit_events`。 +5. 菜单、角色、管理员、系统配置、字典等 HotGo 系统表。 + +## 8. 验收标准 + +1. Admin 查询租户业务数据时,不再出现新的 `imtenant.DB()` 调用。 +2. Admin 查询 IM Server 内部接口时,统一使用 HMAC 签名。 +3. 列表接口支持 `page/per_page/total`,兼容后台表格分页。 +4. 详情接口返回结构化 JSON,不返回数据库内部敏感字段。 +5. 用户 ID、雪花 ID 等大整数以字符串返回。 +6. IM Server 为内部查询写审计日志,至少包含 `tenant_code`、`service_name`、`path`、`query`、`trace_id`、`status`、`duration_ms`。 +7. 关闭 IM Server 内部查询能力时,Admin 应得到明确错误,不泄露租户库信息。 + diff --git a/docs/backstage/IM-Kafka接入方案.md b/docs/backstage/IM-Kafka接入方案.md index 5f23e0c..c9dfa26 100644 --- a/docs/backstage/IM-Kafka接入方案.md +++ b/docs/backstage/IM-Kafka接入方案.md @@ -1,7 +1,7 @@ # IM Kafka 接入方案 -> 日期:2026-08-26 -> 关联文档:[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM 前后端逐图对接协议](./IM-前后端对接文档.md) +> 日期:2026-08-26 +> 关联文档:[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM 前后端逐图对接协议](./IM-前后端对接文档.md) > 目标:明确 Admin 后台、IM 服务、HotGo 控制库、租户业务库之间的 Kafka 接入边界、查询方式、鉴权方式和首批落地范围。 ## 0. 与范围识别文档的关系 @@ -786,6 +786,55 @@ Admin 处理命令: 2. IM 按死信记录中的 `source_topic` 重新投递原 envelope。 3. 投递成功后把死信标记为 `replayed`,并累计 `replay_count`。 4. 如果重放后仍失败,普通消费者会再次写入新的 dead 记录,保留完整处理痕迹。 + +### 11.12 IM Server 已接入的用户治理命令 + +IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin 侧写 hotgo 操作单并发送 Kafka Command,IM 消费后修改租户库用户状态、踢在线连接、清理缓存并回传 `im.admin.command.result`。 + +支持命令: + +| `event_type` | 行为 | Payload 关键字段 | +| --- | --- | --- | +| `user.ban` | 封禁用户,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.unban` | 解封用户,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.freeze` | 冻结账号,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.unfreeze` | 恢复账号,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.device.kick` | 踢指定设备在线连接,并删除设备推送记录。 | `actor_user_id`、`user_id` 或 `target_user_id`、`device_id`、`reason` | +| `user.logout_all` | 踢用户所有在线连接,不修改账号状态。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.account.cancel` | 注销账号,默认软删除;`hard=true` 时硬删除。 | `actor_user_id`、`user_id` 或 `target_user_id`、`hard`、`reason` | +| `user.account.delete` | 删除账号,按硬删除处理。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | + +命令示例: + +```json +{ + "event_id": "admin:user:ban:default:usrabc:001", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "user.ban", + "biz_type": "user_governance", + "biz_id": "usrabc", + "idempotency_key": "admin-operation-id", + "occurred_at": "2026-08-26T10:10:00.000Z", + "payload": { + "command_id": "admin-operation-id", + "actor_user_id": "usrAdmin", + "user_id": "usrabc", + "reason": "违规处理" + } +} +``` + +处理说明: + +1. 封禁/冻结共用 Tinode 用户状态 `StateSuspended`;解封/恢复共用 `StateOK`。 +2. 状态变更后 IM 会清理用户缓存,并向本节点 Hub 投递用户状态变更。 +3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。 +4. 全端登出只踢在线连接,不修改用户状态。 +5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。 +6. 命令执行成功后统一回传 `im.admin.command.result`,并写入 `im.audit.event`。 + 7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。 事件示例: diff --git a/docs/backstage/IM-Kafka接入联调测试文档.md b/docs/backstage/IM-Kafka接入联调测试文档.md new file mode 100644 index 0000000..5fa0a54 --- /dev/null +++ b/docs/backstage/IM-Kafka接入联调测试文档.md @@ -0,0 +1,1144 @@ +# IM Kafka 接入联调测试文档 + +> 关联文档:[IM Kafka 接入方案](./IM-Kafka接入方案.md)、[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM Kafka 基础表 SQL](./sql/IM-Kafka基础表SQL.sql) +> 适用范围:Admin/HotGo 与 IM Server 通过 Kafka 和内部只读 API 联调。 +> 当前约定:Admin 只访问 `hotgo`,IM Server 只访问租户库;跨系统写操作走 Kafka,跨系统查询走内部只读 API。 + +## 1. 联调目标 + +1. 验证 Kafka 基础生产、消费、幂等、重试、死信、Outbox 能正常工作。 +2. 验证 Admin 后台操作通过 `im.admin.command` 投递到 IM Server,并通过 `im.admin.command.result` 返回最终状态。 +3. 验证 IM Server 消息异步落库、文件生命周期、工作台事件、审计事件、缓存失效、死信处理、用户治理命令全部可用。 +4. 验证 Admin 与 IM Server 不跨库直连:Admin 查询租户业务数据走 IM 内部 API;IM 查询 hotgo 配置走 Admin 内部 API。 +5. 验证 Kafka 异常情况下不会产生重复业务结果,且失败可重试、可进入死信、可人工重放。 + +## 2. 环境准备 + +### 2.1 服务与依赖 + +| 组件 | 要求 | +| --- | --- | +| MySQL | `hotgo` 控制库、租户库如 `tinode` 已创建并可连接。 | +| Redis | IM Server 已配置 Redis,用于在线状态、短状态、热缓存和跨节点 fanout。 | +| Kafka | Topic 已创建或允许自动创建;建议测试环境显式创建。 | +| OSS | 文件上传下载仍走 OSS,Kafka 只处理文件元数据和生命周期事件。 | +| Admin/HotGo | 已接入 Kafka producer/consumer、outbox、内部配置查询接口。 | +| IM Server | 已启用 Kafka、内部 Admin Query API、Admin Internal Config Client。 | + +### 2.2 Kafka Topic + +| Topic | 方向 | 用途 | +| --- | --- | --- | +| `im.admin.command` | Admin -> IM | 后台群治理、用户治理、死信处理命令。 | +| `im.admin.command.result` | IM -> Admin | 后台命令执行结果。 | +| `im.config.event` | Admin -> IM | 租户配置、字典、第三方配置变更通知。 | +| `im.cache.invalidate` | Admin/IM -> IM | 跨节点缓存失效。 | +| `im.message.persist` | IM -> IM | 私聊、群聊消息异步落库。 | +| `im.file.lifecycle` | IM -> IM | 文件上传完成、附件绑定、群文件登记/删除。 | +| `im.workbench.event` | Admin/IM -> IM | 工作台事件、红点、系统通知。 | +| `im.audit.event` | IM -> Admin/审计消费者 | 审计事件。 | +| `im.dead.letter` | IM -> Admin/告警 | 消费失败死信。 | + +说明:`im.message.push` 已规划但本轮不联调,离线 Push Kafka 化仍在增强项中。 + +### 2.3 数据库准备 + +租户库必须执行: + +```sql +SOURCE docs/sql/IM-Kafka基础表SQL.sql; +``` + +重点确认租户库存在: + +| 表 | 用途 | +| --- | --- | +| `im_kafka_consume_log` | 消费幂等、重试状态。 | +| `im_kafka_outbox` | IM 侧可靠生产 Kafka 事件。 | +| `im_kafka_dead_letters` | 死信记录与人工处理状态。 | +| `im_messages` | 私聊消息存储。 | +| `im_group_messages` | 群聊消息存储。 | +| `im_filemsglinks` | 私聊附件引用。 | +| `im_group_files` | 群文件和群附件引用。 | +| `im_system_notifications` | 工作台等系统通知。 | +| `im_business_events_outbox` | WS 业务事件投递队列。 | + +Admin/HotGo 侧需具备: + +| 表/能力 | 用途 | +| --- | --- | +| `im_admin_commands` | Admin 操作单,记录后台操作 `pending/running/succeeded/failed/dead`。 | +| `im_admin_outbox` | Admin outbox,与操作单同事务写入,可靠发送 Kafka。 | +| `im_kafka_dead_letter_events` | Admin 消费 `im.dead.letter` 后落 HotGo,用于告警和排障。 | +| `im_audit_events` | Admin 消费 `im.audit.event` 后落 HotGo,用于 Kafka 审计事件列表。 | +| Admin 配置表/字典表 | 为 `/internal/im-config/*` 提供数据源。 | + +Admin/HotGo 侧需执行: + +```sql +SOURCE server/storage/data/generate/im_kafka_admin_tables.sql; +SOURCE server/storage/data/generate/im_kafka_event_tables.sql; +SOURCE server/storage/data/generate/im_kafka_command_menu.sql; +SOURCE server/storage/data/generate/im_dead_letter_menu.sql; +SOURCE server/storage/data/generate/im_kafka_audit_menu.sql; +``` + +### 2.4 IM Server 配置 + +`tinode.conf` 或 Docker 环境变量: + +```json +{ + "kafka": { + "enabled": true, + "brokers": ["127.0.0.1:9092"], + "client_id": "im-server", + "consumer_group": "im-server", + "allow_auto_topic_creation": false, + "max_retry_count": 5 + }, + "internal_api": { + "enabled": true, + "time_window_seconds": 300, + "services": { + "hotgo-admin": { + "secret": "same-secret", + "allow": ["admin_query"] + } + } + }, + "admin_internal_api": { + "enabled": true, + "base_url": "http://hotgo-admin:8080", + "service_name": "im-server", + "secret": "same-secret", + "timeout_ms": 3000 + } +} +``` + +### 2.5 Admin/HotGo 配置 + +Admin 需要: + +1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.workbench.event`。 +2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`、`im.audit.event`。 +3. 内部接口客户端调用 IM `/internal/admin-query/*`。如果 IM API 前缀为 `/v1`,Admin 配置 `imInternalApi.baseUrl` 应包含 `/v1`,例如 `http://im-server:6060/v1`。 +4. 内部接口服务端提供 IM `/internal/im-config/*`。 +5. 内部接口签名与 IM 一致。 + +HMAC 签名请求头: + +| Header | 说明 | +| --- | --- | +| `X-Service-Name` | 调用方服务名,如 `hotgo-admin` 或 `im-server`。 | +| `X-Timestamp` | Unix 秒级时间戳。 | +| `X-Nonce` | 一次性随机串。 | +| `X-Signature` | HMAC-SHA256 hex 签名。 | + +签名文本: + +```text +METHOD + "\n" + RequestURI + "\n" + BodySHA256Hex + "\n" + Timestamp + "\n" + Nonce +``` + +### 2.6 Admin 与 IM Server 分工 + +| 模块 | Admin/HotGo 负责 | IM Server 负责 | +| --- | --- | --- | +| 库访问边界 | 只访问 `hotgo` 控制库,不直连租户库。 | 只访问租户库,不直连 `hotgo`。 | +| 后台写操作 | 写 Admin 操作单和 Admin outbox,生产 `im.admin.command`。 | 消费命令、执行租户业务、回传 `im.admin.command.result`。 | +| 后台查询 | 通过 IM `/internal/admin-query/*` 查询租户业务数据。 | 提供白名单只读查询接口,统一 HMAC 鉴权。 | +| IM 配置读取 | 提供 `/internal/im-config/*`。 | 通过 Admin 内部配置接口读取租户、注册、第三方配置。 | +| Kafka 死信 | 消费 `im.dead.letter`,展示告警和处理入口。 | 记录死信,提供查询接口,消费处理命令。 | +| 审计 | 消费 `im.audit.event` 并落 HotGo 审计表。 | 关键业务动作生产审计事件。 | + +### 2.7 IM 内部查询接口矩阵 + +Admin 调用以下接口时统一使用 `GET /v1/internal/admin-query/tenants/{tenant_code}/...`,如果部署未配置 `/v1` 前缀,则以实际 IM HTTP 前缀为准。 + +P0 核心页面: + +| 接口 | 用途 | +| --- | --- | +| `/users` | 用户列表。 | +| `/users/{user_id}` | 用户详情。 | +| `/users/{user_id}/devices` | 用户设备列表。 | +| `/groups` | 群列表。 | +| `/groups/{topic}` | 群详情和治理配置。 | +| `/groups/{topic}/members` | 群成员分页列表。 | +| `/groups/{topic}/join-requests` | 入群申请列表。 | +| `/groups/{topic}/invitations` | 群邀请列表。 | +| `/groups/{topic}/announcements` | 群公告列表。 | +| `/groups/{topic}/mutes` | 群禁言列表。 | +| `/groups/{topic}/admin-logs` | 群管理日志。 | +| `/groups/{topic}/files` | 群文件列表。 | +| `/files` | 文件列表。 | +| `/files/{file_id}` | 文件详情。 | +| `/files/{file_id}/links` | 文件引用列表。 | +| `/kafka/dead-letters` | Kafka 死信列表。 | +| `/kafka/dead-letters/{dead_id}` | Kafka 死信详情。 | + +P1/P2 运营和完整隔离页面: + +| 接口 | 用途 | +| --- | --- | +| `/audit-logs`、`/audit-logs/{id}` | 审计日志列表和详情。 | +| `/realname-verifications`、`/realname-verifications/{id}` | 实名审核列表和详情。 | +| `/abuse-reports`、`/abuse-reports/{id}` | 举报工单列表和详情。 | +| `/reports/abuse/{report_id}/target` | 举报目标快照。 | +| `/feedback-submissions`、`/feedback-submissions/{id}` | 意见反馈列表和详情。 | +| `/outbox/business-events`、`/outbox/group-events` | 业务 Outbox 和群 Outbox 列表。 | +| `/outbox/business-events/{id}`、`/outbox/group-events/{id}` | Outbox 详情。 | +| `/org/departments`、`/org/members` | 组织部门和组织成员。 | +| `/workbench/apps` | 工作台应用配置。 | +| `/dashboard/summary` | 租户任务看板摘要。 | +| `/feedback-config`、`/mailbox-config` | 反馈配置、总经理信箱配置。 | +| `/abuse-reasons`、`/app-releases` | 举报原因、App 发布策略。 | + +列表接口统一验收 `items/total/page/per_page`,详情接口统一验收 `code=0,data={...}`。用户 ID、雪花 ID、`seq_id`、`dead_id` 等大整数或长 ID 建议按字符串处理,避免前端丢精度。 + +### 2.8 Admin 内部配置接口矩阵 + +IM Server 调 Admin 时统一使用 HMAC 签名,Admin 需提供: + +| 接口 | 用途 | +| --- | --- | +| `GET /internal/im-config/tenants/by-code/{tenant_code}` | 按租户编码查询租户。 | +| `GET /internal/im-config/tenants/by-id/{tenant_id}` | 按租户 ID 查询租户。 | +| `GET /internal/im-config/tenants?state=active&limit=1000` | 拉取启用租户列表。 | +| `GET /internal/im-config/registration/sms` | 读取注册短信配置。 | +| `GET /internal/im-config/third-party/{group}?keys=a,b` | 读取 OSS、文件安全、声网、极光等第三方配置。 | + +联调时需要特别确认 OSS 和文件安全配置已经从 Admin 字典项返回,IM 不再依赖本地硬编码。 + +### 2.9 Admin Command 事件矩阵 + +| 事件类型 | 业务 | +| --- | --- | +| `group.profile.update` | 后台修改群资料。 | +| `group.governance.update` | 后台修改群治理配置。 | +| `group.admin.set`、`group.admin.remove` | 设置或取消群管理员。 | +| `group.member.mute`、`group.member.unmute` | 成员禁言或解禁。 | +| `group.member.remove` | 移除群成员。 | +| `group.owner.transfer` | 转让群主。 | +| `user.ban`、`user.unban` | 用户封禁和解封。 | +| `user.freeze`、`user.unfreeze` | 用户冻结和恢复。 | +| `user.device.kick` | 踢指定设备。 | +| `user.logout_all` | 全端登出。 | +| `user.account.cancel`、`user.account.delete` | 注销或删除账号。 | +| `kafka.dead_letter.ignore` | 忽略死信。 | +| `kafka.dead_letter.resolve` | 标记死信已解决。 | +| `kafka.dead_letter.replay` | 重放死信。 | + +所有 Admin Command 都必须带 `event_id`、`tenant_code`、`event_type`、`biz_type`、`biz_id`、`payload.command_id`、`payload.actor_user_id`。Admin 不应把 Kafka 投递成功当成业务成功,最终状态以 `im.admin.command.result` 为准。 + +## 3. 通用验收规则 + +每个 Kafka 用例都必须检查: + +1. Kafka 原始事件 `event_id` 全局唯一,同一事件重复投递不会重复产生业务副作用。 +2. `tenant_code` 能正确路由到目标租户库。 +3. 消费成功后 `im_kafka_consume_log.status=succeeded`。 +4. 消费失败后 `retry_count` 增加,并按 `retry_backoff_ms` 重试。 +5. 超过上限后写入 `im_kafka_dead_letters`,并投递 `im.dead.letter`。 +6. Admin Command 成功、失败、死信都必须回传 `im.admin.command.result`。 +7. 业务结果、审计事件、日志里的 `trace_id/event_id/biz_id` 可串联排查。 + +## 4. 联调用例 + +### TC-KAFKA-001 基础启动与 Topic 连通 + +目标:验证 Admin 和 IM 都能连接 Kafka,IM 消费者全部启动。 + +步骤: + +1. 启动 Kafka、Redis、MySQL。 +2. 启动 Admin/HotGo。 +3. 启动 IM Server。 +4. 查看 IM 日志应出现 Kafka enabled,且无 consumer 启动错误。 +5. Admin 在「IM管理 -> Kafka命令」点击「发送缓存失效测试」,或调用 `POST /admin/imKafkaCommand/cacheInvalidate`,发送一条无业务副作用的测试 `im.cache.invalidate` 事件。 + +预期: + +1. IM 成功消费测试事件。 +2. `im_kafka_consume_log` 写入一条 succeeded 记录。 +3. 无死信。 + +### TC-KAFKA-002 内部接口鉴权成功 + +目标:验证 Admin 调 IM 内部查询接口签名通过。 + +请求: + +```http +GET /v1/internal/admin-query/tenants/default/users/usrxxx +X-Service-Name: hotgo-admin +X-Timestamp: +X-Nonce: +X-Signature: +``` + +预期: + +1. HTTP 200。 +2. 响应 `code=0`。 +3. 返回用户基础资料。 + +### TC-KAFKA-003 内部接口鉴权失败 + +目标:验证内部接口拒绝错误签名、过期时间戳、重复 nonce。 + +步骤: + +1. 使用错误 secret 调用 `/internal/admin-query/*`。 +2. 使用超过时间窗的 timestamp 调用。 +3. 使用同一 nonce 重复调用两次。 + +预期: + +1. 错误 secret 返回 401。 +2. 过期 timestamp 返回 401。 +3. 重复 nonce 第二次返回 401。 +4. 业务数据不泄露。 + +### TC-KAFKA-004 IM 调 Admin 配置查询 + +目标:验证 IM 通过 Admin `/internal/im-config/*` 获取 hotgo 配置。 + +步骤: + +1. 开启 IM `admin_internal_api.enabled=true`。 +2. Admin 提供以下接口: + - `GET /internal/im-config/tenants/by-code/default` + - `GET /internal/im-config/tenants/by-id/1` + - `GET /internal/im-config/tenants?state=active&limit=1000` + - `GET /internal/im-config/registration/sms` + - `GET /internal/im-config/third-party/im_third_party?keys=...` +3. 重启 IM。 +4. 执行登录、上传、文件安全配置读取等动作。 + +预期: + +1. IM 优先请求 Admin 内部配置接口。 +2. Admin 验签通过并返回必要字段。 +3. IM 本地功能正常。 +4. Admin 接口异常时,IM 可回落旧 DB 适配器,日志有 warn。 + +说明:Admin `/internal/im-config/*` 是内部服务接口,不带 `/admin` 后台前缀。 + +### TC-KAFKA-005 Admin 群资料修改命令 + +目标:验证后台群资料修改通过 Kafka 到 IM。 + +Admin 发送: + +```json +{ + "event_id": "admin:group:profile:update:001", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "group.profile.update", + "biz_type": "group_admin", + "biz_id": "grpxxx", + "payload": { + "command_id": "cmd-group-profile-001", + "actor_user_id": "usrAdmin", + "topic": "grpxxx", + "version": 1, + "profile": { + "name": "新群名称", + "description": "新群描述", + "avatar_file_id": "file_xxx" + } + } +} +``` + +预期: + +1. IM 更新群业务表。 +2. `im.admin.command.result` 返回 `status=succeeded`。 +3. C 端在线群成员收到业务刷新事件或可重新拉取看到新资料。 +4. 消费日志 succeeded。 +5. 审计事件已投递。 + +### TC-KAFKA-006 Admin 群治理配置 + +目标:验证后台修改入群审批、全员禁言、成员上传、私聊、邀请开关。 + +事件类型:`group.governance.update` + +Payload: + +```json +{ + "command_id": "cmd-group-govern-001", + "actor_user_id": "usrAdmin", + "topic": "grpxxx", + "version": 2, + "governance": { + "join_approval": true, + "mute_all": true, + "member_upload_files": false, + "member_private_chat": false, + "member_invite": false + } +} +``` + +预期: + +1. 群设置版本递增或与业务规则一致。 +2. 新配置对成员发送、上传、邀请等入口生效。 +3. Admin 收到成功结果。 + +### TC-KAFKA-007 Admin 群成员治理 + +目标:验证管理员设置、取消、禁言、解禁、移除成员、转让群主。 + +事件类型: + +| 命令 | 预期 | +| --- | --- | +| `group.admin.set` | 成员角色变为 admin。 | +| `group.admin.remove` | 成员角色恢复 member。 | +| `group.member.mute` | 成员禁言生效。 | +| `group.member.unmute` | 成员禁言解除。 | +| `group.member.remove` | 成员离群,不能继续收发该群消息。 | +| `group.owner.transfer` | 群主变更,旧群主权限调整。 | + +每条命令都检查: + +1. 租户库业务表状态正确。 +2. 在线 C 端收到状态变化。 +3. Admin 操作单从 running 变为 succeeded。 +4. 重复发送同一 `event_id` 不重复执行。 + +### TC-KAFKA-008 用户封禁与解封 + +目标:验证 Admin 可通过 Kafka 封禁/解封用户。 + +封禁事件: + +```json +{ + "event_id": "admin:user:ban:001", + "tenant_code": "default", + "event_type": "user.ban", + "biz_type": "user_governance", + "biz_id": "usrxxx", + "payload": { + "command_id": "cmd-user-ban-001", + "actor_user_id": "usrAdmin", + "user_id": "usrxxx", + "reason": "违规" + } +} +``` + +步骤: + +1. 目标用户保持在线。 +2. Admin 发送 `user.ban`。 +3. 目标用户尝试继续发消息。 +4. 目标用户重新登录。 +5. Admin 发送 `user.unban`。 +6. 目标用户重新登录。 + +预期: + +1. 封禁后用户状态为 `susp`。 +2. 在线会话被踢下线。 +3. 封禁期间登录被拒绝。 +4. 解封后状态为 `ok`,可正常登录。 +5. 两条命令均回传 result。 + +### TC-KAFKA-009 用户冻结与恢复 + +目标:验证冻结/恢复账号命令。 + +事件类型:`user.freeze`、`user.unfreeze` + +预期: + +1. `user.freeze` 设置用户状态 `susp` 并踢下线。 +2. `user.unfreeze` 恢复用户状态 `ok`。 +3. 重复投递同一 `event_id` 不重复产生额外副作用。 + +### TC-KAFKA-010 踢指定设备 + +目标:验证只踢指定设备,不影响同账号其他设备。 + +前置: + +1. 同一用户使用两个设备登录,设备 ID 分别为 `device-a`、`device-b`。 + +事件: + +```json +{ + "event_id": "admin:user:device:kick:001", + "tenant_code": "default", + "event_type": "user.device.kick", + "biz_type": "user_governance", + "biz_id": "usrxxx:device-a", + "payload": { + "command_id": "cmd-device-kick-001", + "actor_user_id": "usrAdmin", + "user_id": "usrxxx", + "device_id": "device-a", + "reason": "后台踢设备" + } +} +``` + +预期: + +1. `device-a` 对应会话被踢。 +2. `device-b` 保持在线。 +3. `device-a` 推送设备记录被删除。 +4. Admin 收到 `evicted_sessions` 数量。 + +### TC-KAFKA-011 全端登出 + +目标:验证后台让用户所有设备退出登录。 + +事件类型:`user.logout_all` + +预期: + +1. 用户所有在线会话被踢。 +2. 用户状态不改变。 +3. 用户可重新登录。 + +### TC-KAFKA-012 注销与删除账号 + +目标:验证 Admin 可注销或删除租户内账号。 + +事件类型: + +| 命令 | 预期 | +| --- | --- | +| `user.account.cancel` | 默认软删除,`hard=true` 时硬删除。 | +| `user.account.delete` | 按硬删除处理。 | + +步骤: + +1. 目标用户在线。 +2. Admin 发送注销命令。 +3. 查询用户状态。 +4. 尝试重新登录。 + +预期: + +1. 用户会话被踢。 +2. 用户相关 Topic 被停止。 +3. 用户被删除或标记 `del`。 +4. 登录失败。 +5. Admin 收到 result。 + +### TC-KAFKA-013 私聊消息异步落库 + +目标:验证 Kafka 开启时私聊消息通过 `im.message.persist` 落库。 + +步骤: + +1. 用户 A 与用户 B 建立私聊。 +2. A 发送带 `x-im-client-mid` 的私聊消息。 +3. 查询 Kafka `im.message.persist`。 +4. 查询租户库 `im_messages`。 +5. A 使用同一 `x-im-client-mid` 重复发送。 + +预期: + +1. A 立即收到服务端 ack 和 `seq_id`。 +2. `seq_id` 为 BIGINT/int64。 +3. 消费成功后 `im_messages` 有一条记录。 +4. 重复发送不重复落库。 +5. 消费日志 succeeded。 + +### TC-KAFKA-014 群聊消息异步落库 + +目标:验证群消息写入 `im_group_messages`,不混入私聊表。 + +步骤: + +1. 创建群并加入至少两个成员。 +2. 成员发送群消息。 +3. 查询 `im_group_messages`。 +4. 查询 `im_messages`。 +5. 重连客户端按 Topic seq 拉取消息。 + +预期: + +1. 群消息只写 `im_group_messages`。 +2. `seq_id` 单调增长,类型为 BIGINT/int64。 +3. 客户端补偿拉取成功。 +4. 24 小时热消息可进入 Redis 热缓存。 + +### TC-KAFKA-015 消息消费失败重试 + +目标:验证 `im.message.persist` 消费失败可重试并最终成功。 + +步骤: + +1. 临时制造租户库写入失败,如锁表或停止库连接。 +2. 发送消息。 +3. 恢复数据库。 +4. 等待重试。 + +预期: + +1. `im_kafka_consume_log.retry_count` 增加。 +2. 未超过重试上限时不进入死信。 +3. 数据库恢复后消息最终落库。 +4. 状态变为 succeeded。 + +### TC-KAFKA-016 文件上传完成事件 + +目标:验证文件上传完成后产生 `im.file.lifecycle` 事实事件。 + +步骤: + +1. 客户端上传文件到 OSS。 +2. 上传完成后查询租户库文件记录。 +3. 检查 Kafka outbox 和 `im.file.lifecycle`。 + +预期: + +1. 文件字节流只进入 OSS。 +2. Kafka 只包含文件元数据,不包含文件内容。 +3. 上传完成事件可被消费并审计。 + +### TC-KAFKA-017 消息附件绑定 + +目标:验证消息落库后附件引用由 `im.file.lifecycle` 绑定。 + +步骤: + +1. 上传图片或文件。 +2. 发送带附件的私聊消息。 +3. 发送带附件的群聊消息。 +4. 查询附件引用表。 + +预期: + +1. 私聊附件写入 `im_filemsglinks`。 +2. 群聊附件写入 `im_group_files`。 +3. 重复消费不重复绑定。 +4. 文件 ACL 与业务引用可追踪。 + +### TC-KAFKA-018 群文件登记与删除 + +目标:验证群文件业务事件接入 `im.file.lifecycle`。 + +步骤: + +1. 群内登记文件。 +2. 查询 `im_group_files`。 +3. 删除群文件。 +4. 检查生命周期事件和审计事件。 + +预期: + +1. 群文件登记成功。 +2. 删除状态正确。 +3. 文件事件可用于后续回收/合规删除。 + +### TC-KAFKA-019 工作台会议事件 + +目标:验证会议创建、更新、取消、提醒、参会响应产生工作台事件。 + +步骤: + +1. 创建会议。 +2. 修改会议。 +3. 发送提醒。 +4. 参会人响应。 +5. 取消会议。 + +预期: + +1. 每个动作产生 `im.workbench.event`。 +2. 消费后写 `im_system_notifications`。 +3. 写 `im_business_events_outbox`。 +4. 在线 C 端收到系统通知或红点刷新。 +5. 审计事件已投递。 + +Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作台测试事件」,或调用 `POST /admin/imKafkaCommand/workbenchEvent`,验证 Admin 具备生产 `im.workbench.event` 的能力。真实会议、日报/月报、信箱业务结果仍以 IM Server 对应业务用例为准。 + +### TC-KAFKA-020 日报/月报事件 + +目标:验证日报、月报提交和催办事件。 + +步骤: + +1. 创建日报草稿。 +2. 保存并提交日报。 +3. 管理员催办日报。 +4. 创建、提交、重开月报。 + +预期: + +1. 对应事件进入 `im.workbench.event`。 +2. 通知目标用户正确。 +3. 工作台红点或系统通知可被客户端看到。 + +### TC-KAFKA-021 总经理信箱事件 + +目标:验证总经理信箱提交事件和审计。 + +步骤: + +1. 用户提交信箱内容。 +2. 检查 `im.workbench.event`。 +3. 检查 `im.audit.event`。 +4. Admin 后台消费后生成处理单。 + +预期: + +1. 提交成功后有工作台事件。 +2. 审计事件包含用户、租户、业务 ID。 +3. Admin 可基于事件处理。 + +### TC-KAFKA-022 配置事件失效 + +目标:验证 Admin 配置变更后 IM 清理进程内缓存。 + +事件类型: + +| 事件 | 预期 | +| --- | --- | +| `tenant.updated` | 清租户缓存。 | +| `tenant.status.changed` | 清租户缓存。 | +| `tenant.limits.updated` | 清租户配置缓存。 | +| `tenant.features.updated` | 清租户功能缓存。 | +| `dictionary.updated` | 清字典/第三方配置缓存。 | +| `third_party.updated` | 清第三方配置缓存。 | +| `registration.config.updated` | 清注册短信配置缓存。 | + +步骤: + +1. Admin 修改配置。 +2. 发送 `im.config.event`。 +3. IM 再次读取对应配置。 + +预期: + +1. IM 消费成功。 +2. 下次读取不使用旧缓存。 +3. 可从 Admin 内部配置接口重新获取新值。 + +### TC-KAFKA-023 通用缓存失效 + +目标:验证 `im.cache.invalidate` 可按 namespace 删除缓存。 + +示例事件: + +```json +{ + "event_id": "cache:user:001", + "tenant_code": "default", + "event_type": "cache.redis_object.invalidate", + "biz_type": "cache", + "biz_id": "user:usrxxx", + "payload": { + "tenant_code": "default", + "tenant_id": 1, + "namespace": "user", + "keys": ["usrxxx"] + } +} +``` + +预期: + +1. 用户 Redis 对象缓存被删除。 +2. 后续查询重新读 MySQL 或 Admin 内部配置接口。 +3. 消费幂等。 + +### TC-KAFKA-024 群热消息缓存失效 + +目标:验证群热消息可按 Topic 或 seq range 失效。 + +事件: + +```json +{ + "event_id": "cache:group-hot:001", + "tenant_code": "default", + "event_type": "cache.group_hot_messages.invalidate", + "biz_type": "cache", + "biz_id": "grpxxx", + "payload": { + "tenant_id": 1, + "namespace": "group_hot_messages", + "topic": "grpxxx", + "ranges": [{"low": 100, "hi": 120}] + } +} +``` + +预期: + +1. 指定群热消息范围失效。 +2. 客户端再次拉取时能从最终存储补偿。 + +### TC-KAFKA-025 死信生成 + +目标:验证不可恢复错误进入死信。 + +步骤: + +1. Admin 发送不存在用户或非法 payload 的命令。 +2. 或临时制造持续失败直到超过最大重试。 +3. 查询 `im_kafka_dead_letters`。 +4. 消费 `im.dead.letter`。 + +预期: + +1. 死信表出现记录。 +2. 死信 Topic 有 `kafka.dead_letter.created`。 +3. Admin 操作单收到 `status=dead` 的 result。 +4. 死信记录包含原始 envelope、reason、error、retry_count。 + +### TC-KAFKA-026 死信查询 + +目标:验证 Admin 可通过 IM 内部 API 查询死信。 + +请求: + +```http +GET /v1/internal/admin-query/tenants/default/kafka/dead-letters?status=dead&limit=50 +GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} +``` + +预期: + +1. 列表返回摘要,不返回超大 envelope。 +2. 详情返回原始 envelope。 +3. 支持 `status/biz_type/biz_id/event_type/cursor/limit` 过滤。 + +### TC-KAFKA-027 死信忽略与解决 + +目标:验证 Admin 可通过 Kafka 命令处理死信状态。 + +事件类型: + +```json +{ + "event_type": "kafka.dead_letter.ignore", + "payload": { + "command_id": "cmd-dead-ignore-001", + "actor_user_id": "usrAdmin", + "dead_id": "evt_dead_xxx", + "note": "测试死信忽略" + } +} +``` + +预期: + +1. `ignore` 后死信状态为 `ignored`。 +2. `resolve` 后死信状态为 `resolved`。 +3. Admin 收到 result。 +4. 审计事件已投递。 + +### TC-KAFKA-028 死信重放 + +目标:验证死信可人工重放。 + +步骤: + +1. 修复导致死信的业务数据或配置。 +2. Admin 发送 `kafka.dead_letter.replay`。 +3. 观察原 Topic 是否重新收到 envelope。 +4. 查询死信记录和消费日志。 + +预期: + +1. IM 将死信改为 `pending_replay`。 +2. 删除原 `im_kafka_consume_log`。 +3. 按原 `source_topic` 重投 envelope。 +4. 重投成功后死信状态为 `replayed`,`replay_count` 增加。 +5. 原业务消费者重新处理并成功。 + +### TC-KAFKA-029 审计事件 + +目标:验证关键后台和敏感操作都有审计事件。 + +覆盖: + +1. 群治理命令。 +2. 用户治理命令。 +3. 文件生命周期。 +4. 工作台状态变化。 +5. 死信生成和处理。 + +预期: + +1. `im.audit.event` 收到事件。 +2. 事件包含 `tenant_code`、`actor`、`biz_type`、`biz_id`、`event_type`、`occurred_at`。 +3. Admin 审计消费者可落 HotGo `im_audit_events`。 +4. Admin 可在「IM管理 -> Kafka审计事件」查看审计事件列表和详情。 + +### TC-KAFKA-030 Outbox 可靠投递 + +目标:验证 IM 侧 Kafka Outbox 防止业务成功但 Kafka 丢事件。 + +步骤: + +1. 临时停止 Kafka。 +2. 执行会产生 outbox 的操作,如发送消息、提交工作台事件。 +3. 查询 `im_kafka_outbox`。 +4. 恢复 Kafka。 +5. 等待 outbox worker 投递。 + +预期: + +1. Kafka 停止时业务 outbox 记录为 `pending/failed`。 +2. 恢复后记录变为 `sent`。 +3. 下游消费者最终收到事件。 +4. 不重复产生业务副作用。 + +## 5. Admin 侧联调检查清单 + +| 检查项 | 验收标准 | +| --- | --- | +| 操作单 | 每个后台写操作先写 hotgo 操作单。 | +| Admin outbox | 操作单与 outbox 同事务写入。 | +| Command 生产 | `event_id/idempotency_key/tenant_code/biz_id` 稳定可追踪。 | +| Result 消费 | `im.admin.command.result` 能更新操作单状态。 | +| 死信消费 | `im.dead.letter` 能生成告警或待处理记录。 | +| 审计消费 | `im.audit.event` 能落 HotGo `im_audit_events`,并可在 Kafka审计事件页面查看。 | +| 配置接口 | `/internal/im-config/*` 支持 IM HMAC 调用。 | +| 查询客户端 | 调 IM `/internal/admin-query/*` 带签名并处理 401/403/404/5xx。 | +| 页面状态 | 后台页面显示 pending/running/succeeded/failed/dead,不把 Kafka 发送成功当业务成功。 | + +## 6. IM Server 侧联调检查清单 + +| 检查项 | 验收标准 | +| --- | --- | +| Kafka 启动 | producer、consumer、outbox worker 正常启动。 | +| 租户路由 | `tenant_code` 能路由到正确租户库。 | +| 消费幂等 | 重复 `event_id` 不重复执行业务。 | +| 重试 | 可重试错误按退避时间重试。 | +| 死信 | 超过上限或不可重试错误进入死信并回 result。 | +| Admin Query API | 内部接口验签、鉴权、nonce 防重放生效。 | +| Admin Config Client | 可从 Admin 拉租户、注册、第三方配置。 | +| 用户治理 | 封禁/冻结踢下线,解封/恢复可登录,设备踢出只影响指定设备。 | +| 群治理 | 群资料、角色、禁言、移除、转让群主全部生效。 | +| 消息落库 | 私聊写 `im_messages`,群聊写 `im_group_messages`。 | +| 文件生命周期 | 附件引用绑定到正确业务表。 | +| 工作台事件 | 生成系统通知和 WS 业务事件。 | +| 审计 | 关键动作进入 `im.audit.event`。 | + +## 7. 回归用例 + +1. Kafka 关闭时,IM 基础登录、私聊、群聊、文件上传下载仍可用。 +2. Redis 异常时,Kafka 消费不应 panic;允许热缓存和 nonce 校验降级按配置表现。 +3. Admin 内部配置接口不可用时,IM 本地旧 DB 读取兜底可用。 +4. IM 内部查询接口关闭时,Admin 查询应收到 404,不泄露数据。 +5. 同一用户在多端登录,`user.device.kick` 只踢指定设备,`user.logout_all` 踢全部设备。 +6. 同一 Admin Command 重复发送 3 次,业务只成功一次,结果可重复查询。 +7. 群消息高并发发送时 `seq_id` 单调递增且无重复。 +8. 文件上传后未发送消息,不应产生错误附件引用。 +9. 工作台事件目标用户为空时,不应产生错误通知。 +10. 死信重放失败后应再次进入死信,不覆盖原处理痕迹。 + +## 8. 联调问题定位 + +| 现象 | 优先排查 | +| --- | --- | +| Admin 操作一直 pending | Admin outbox 是否成功投递;`queue.driver` 是否为 `kafka`;Kafka broker 是否可用。 | +| Admin 操作一直 running | Admin 是否消费 `im.admin.command.result`;IM 是否收到 command;`event_id` 是否重复卡在 processing。 | +| IM 消费失败 | 查 `im_kafka_consume_log.last_reason/last_error` 和 IM 日志 `event_id`。 | +| 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 | +| 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 | +| 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 | +| 消息未落库 | 查 `im_kafka_outbox` 是否 sent,`im.message.persist` 是否消费成功。 | +| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 | +| 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 | +| 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 | + +## 9. 联调完成标准 + +1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期、工作台、缓存失效、死信。 +2. Admin 操作单状态闭环可视化:pending -> running -> succeeded/failed/dead。 +3. IM 租户库无重复业务记录,Kafka 消费日志无异常堆积。 +4. 死信可查询、可忽略、可解决、可重放。 +5. Admin 与 IM 均不跨库直连,内部查询接口鉴权通过。 +6. 离线 Push Kafka 化未纳入本次验收,继续按增强项跟进。 + +## 10. 本地 Docker 联调记录 + +### 10.1 2026-08-27 冒烟联调 + +本轮不是全量联调,只完成本地 Docker 环境启动和 Kafka/内部接口主链路冒烟。后续仍需按第 4 章逐条补齐。 + +联调环境: + +| 组件 | 地址/容器 | 结果 | +| --- | --- | --- | +| MySQL | `relaxed_cori:3306`,宿主机 `127.0.0.1:3306` | 已连接,本地库包含 `hotgo`、`tinode`。 | +| Redis | `brave_blackburn:6379`,宿主机 `127.0.0.1:6379` | 已连接。 | +| Kafka | `im-local-kafka:9092`,宿主机 `127.0.0.1:19092` | 已创建联调 Topic。 | +| IM Server | `im-local-im:6060`,宿主机 `127.0.0.1:6060` | healthy,Kafka consumer 已启动。 | +| Admin/HotGo | `im-local-admin:8000`,宿主机 `127.0.0.1:8000` | healthy,Kafka consumer 已启动。 | + +说明:本地已有 Kafka 容器 `kafka:9092` 的 `advertised.listeners=localhost:9092`,Docker 内服务会回连自身 localhost,故本轮单独启动 `im-local-kafka` 用于容器联调。 + +本轮已测: + +| 用例/链路 | 测试内容 | 结果 | 证据 | +| --- | --- | --- | --- | +| TC-KAFKA-001 | Kafka Topic 创建,IM consumer 启动。 | 通过 | IM 日志显示 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.message.persist`、`im.file.lifecycle`、`im.workbench.event` consumer started。 | +| TC-KAFKA-002 | Admin -> IM 内部查询 HMAC。 | 通过 | `GET /internal/admin-query/tenants/default/users?page=1&per_page=1` 返回 `code=0`,`total=29`。 | +| TC-KAFKA-004 | IM -> Admin 内部配置 HMAC。 | 通过 | `GET /internal/im-config/tenants?limit=1000&state=active` 返回 `code=0` 和 default 租户。 | +| TC-KAFKA-008/011 | `user.logout_all` 后台用户治理命令。 | 通过 | `tinode.im_kafka_consume_log` 中 `local-logout-fixed-*` 状态为 `succeeded`。 | +| TC-KAFKA-023 | `im.cache.invalidate` 通用缓存失效。 | 通过 | `local-cache-*` 写入 `im_kafka_consume_log`,状态 `succeeded`,IM 日志显示 `kafka cache invalidate applied`。 | +| TC-KAFKA-025 | 非法 `user.logout_all` 进入死信。 | 通过 | `local-logout-*` 状态为 `dead`,`im_kafka_dead_letters` 写入 `MALFORMED_COMMAND`。 | +| TC-KAFKA-029 | 用户治理审计事件。 | 通过 | Admin 消费 `im.audit.event` 链路已验证;正式落表应为 `hotgo.im_audit_events`。 | + +本轮发现并处理: + +| 问题 | 现象 | 处理 | +| --- | --- | --- | +| Admin Docker 前端构建失败 | `pnpm run build` 非 TTY 环境报 `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`。 | Admin `Dockerfile` 增加 `ENV CI=true`。 | +| Admin Kafka 版本配置不兼容 | Admin 启动时报 `invalid version 2.0.0.0`。 | 本地联调配置临时改为 `version: "2.0.0"`。 | +| IM 启动缺少 UID key | IM 报 `crypto/xtea: invalid key size 0`。 | Docker 启动显式传入 `UID_ENCRYPTION_KEY`、`API_KEY_SALT`、`AUTH_TOKEN_KEY`。 | +| HotGo 表前缀不一致 | Admin 内部配置接口错误查询 `hg_im_tenant`,但 IM 业务/配置表正式表名应保持 `im_tenant`。 | 联调时暴露的是 Admin 查询/ORM 表名前缀问题;正式方案必须修 Admin 侧表名映射或 DAO 前缀逻辑,不能新增 `hg_im_*` 兼容表。 | +| Admin result 消费格式不兼容 | Admin 消费 `im.admin.command.result` 报 `IM命令结果缺少command_id`。 | Admin consumer 已兼容 Kafka Envelope + payload 格式。 | +| Admin 审计表前缀不一致 | Admin 消费 `im.audit.event` 错误查询 `hg_im_audit_events`,正式表名应保持 `im_audit_events`。 | 正式方案必须修 Admin 侧审计模型/DAO 表名映射,所有 `im_*` 业务表不加 `hg_` 前缀。 | + +本轮未覆盖,后续继续: + +| 范围 | 待测内容 | +| --- | --- | +| 群治理 | 群资料、治理配置、管理员设置/取消、成员禁言/解禁、移除成员、转让群主。 | +| 用户治理完整集 | `user.ban`、`user.unban`、`user.freeze`、`user.unfreeze`、`user.device.kick`、注销/删除账号。 | +| 消息异步落库 | 私聊 `im_messages`、群聊 `im_group_messages`、`seq_id` BIGINT、重复 `client_mid` 幂等。 | +| 文件生命周期 | 上传完成、附件绑定、群文件登记/删除、OSS 元数据和引用表。 | +| 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 | +| 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 | +| Outbox | Kafka 停止/恢复时 `im_kafka_outbox`、Admin outbox 的可靠投递。 | +| Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 | + +### 10.2 2026-08-27 完整后端链路联调 + +本轮在本地 Docker 环境继续补齐 Kafka 后端链路联调。覆盖方式以 Kafka 直接投递、内部 HTTP HMAC 调用、MySQL 业务表/消费日志核验为主;Admin 前端页面点击未做浏览器人工验收,页面层需要后续按菜单再走一遍。 + +联调环境延续 10.1: + +| 组件 | 地址/容器 | 结果 | +| --- | --- | --- | +| MySQL | `relaxed_cori:3306` | 正常。 | +| Redis | `brave_blackburn:6379` | 正常。 | +| Kafka | `im-local-kafka:9092`,宿主机 `127.0.0.1:19092` | 正常;Outbox 用例中曾停止并恢复。 | +| IM Server | `im-local-im:6060` | 已重建为包含本轮修复的 `im-local-server:dev`,当前 healthy。 | +| Admin/HotGo | `im-local-admin:8000` | 当前 healthy;Kafka 断开时曾 fatal 退出,见问题记录。 | + +本轮通过项: + +| 用例/范围 | 覆盖内容 | 结果 | 证据 | +| --- | --- | --- | --- | +| TC-KAFKA-003 | IM 内部查询接口未签名访问。 | 通过 | `GET /internal/admin-query/...` 未带签名返回 401 `AUTH_REQUIRED`。 | +| TC-KAFKA-004 | Admin 内部配置接口签名成功/失败。 | 通过 | 未签名返回 401;签名访问 `/internal/im-config/tenants?limit=1&state=active` 返回 default 租户。 | +| TC-KAFKA-005 | 后台群资料修改。 | 通过 | `group.profile.update` 消费 `succeeded`,`im_group_settings.name/description/version` 更新。 | +| TC-KAFKA-006 | 后台群治理配置。 | 通过 | `group.governance.update` 消费 `succeeded`,`join_approval/mute_all/member_*` 更新。 | +| TC-KAFKA-007 | 群管理员、禁言、移除、转让群主。 | 通过 | `group.admin.set/remove`、`group.member.mute/unmute/remove`、`group.owner.transfer` 均有成功消费记录;`im_group_members/im_group_mutes` 状态变更正确。 | +| TC-KAFKA-008 | 用户封禁与解封。 | 通过 | `user.ban/user.unban` 消费 `succeeded`,用户最终恢复 `state=0`。 | +| TC-KAFKA-009 | 用户冻结与恢复。 | 通过 | `user.freeze/user.unfreeze` 消费 `succeeded`,用户最终恢复 `state=0`。 | +| TC-KAFKA-010 | 踢指定设备。 | 通过 | `user.device.kick` 消费 `succeeded`,不存在设备按幂等成功处理。 | +| TC-KAFKA-011 | 全端登出。 | 通过 | `user.logout_all` 消费 `succeeded`。 | +| TC-KAFKA-012 | 注销与删除账号。 | 通过 | `user.account.cancel/user.account.delete` 使用本地测试账号验证,消费 `succeeded`。 | +| TC-KAFKA-013 | 私聊消息异步落库。 | 通过 | `message.persist.p2p` 消费 `succeeded`,`im_messages` 写入测试消息。 | +| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `message.persist.group` 消费 `succeeded`,`im_group_messages` 写入测试群消息。 | +| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | `file.upload.completed` 消费 `succeeded`。 | +| TC-KAFKA-017 | 消息附件绑定。 | 通过 | 群聊附件绑定写入 `im_group_files`;私聊附件绑定修复后写入 `im_filemsglinks`。 | +| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | `group.file.created/group.file.deleted` 消费 `succeeded`。 | +| TC-KAFKA-019 | 工作台会议事件。 | 通过 | `workbench.meeting.created` 消费 `succeeded`,写入 `im_system_notifications` 和 `im_business_events_outbox`。 | +| TC-KAFKA-022 | 配置事件失效。 | 通过 | `dictionary.updated` 消费 `succeeded`。 | +| TC-KAFKA-023 | 通用缓存失效。 | 通过 | `cache.redis_object.invalidate` 消费 `succeeded`。 | +| TC-KAFKA-024 | 群热消息缓存失效。 | 通过 | `cache.group_hot_messages.invalidate` 消费 `succeeded`。 | +| TC-KAFKA-025 | 死信生成。 | 通过 | 故意发送非法 `user.logout_all`,消费状态 `dead`,写入 `im_kafka_dead_letters`。 | +| TC-KAFKA-026 | 死信查询。 | 通过 | `/internal/admin-query/tenants/default/kafka/dead-letters` 和详情接口返回 200。 | +| TC-KAFKA-027 | 死信忽略与解决。 | 通过 | `kafka.dead_letter.ignore/resolve` 消费 `succeeded`,死信状态变为 `ignored/resolved`。 | +| TC-KAFKA-028 | 死信重放。 | 通过 | `kafka.dead_letter.replay` 消费 `succeeded`,死信状态变为 `replayed`,`replay_count=1`。 | +| TC-KAFKA-029 | 审计事件。 | 通过 | 用户治理、群治理、死信、文件等事件投递 `im.audit.event`;正式落表应为 `hotgo.im_audit_events`。 | +| TC-KAFKA-030 | IM Kafka Outbox 可靠投递。 | 通过 | Kafka 停止时 outbox `pending -> failed`;Kafka 恢复并重启 IM 后 `failed -> sent`,下游消费成功。 | + +本轮消费统计: + +| 口径 | 数量 | +| --- | ---: | +| `codex_it_*` 和本轮修复验证事件总数 | 38 | +| 成功消费 | 35 | +| 故意制造死信 | 3 | +| 修复后仍卡 processing | 0 | + +代表性事件: + +| 类型 | Event ID | +| --- | --- | +| 用户治理批次 | `codex_it_110224_*` | +| 群成员移除复测 | `codex_it_remove_110302_271b2ff6` | +| 私聊/群聊消息落库 | `codex_it_msg_110432_*`、`codex_it_msg_110434_*` | +| 私聊附件修复验证 | `codex_it_fix_p2p_attach_b93a9a4b` | +| 工作台事件 | `codex_it_evt_110730_workbench_meeting_created_b764faa3` | +| Outbox 断 Kafka 恢复 | `codex_it_outbox_7f52e221` | +| 死信处理 | `codex_it_dead_111524_*`、`codex_it_dead_111528_*`、`codex_it_dead_111531_*` | + +本轮发现并处理: + +| 问题 | 影响 | 处理结果 | +| --- | --- | --- | +| 私聊附件绑定触发 IM panic | `file.attachments.bind` 对私聊消息调用 `store.Files.LinkAttachments(tenantID, "", msgId, attachments)` 时,空 topic 进入 `types.GetTopicCat(topic)` 导致 `slice bounds out of range`,IM 退出。 | 已修复 `server/store/store.go`:仅当 `topic != ""` 时判断 `TopicCatMe`;重建 IM 后复测私聊附件绑定成功,`im_filemsglinks` 写入。 | +| Kafka 停止时 Admin 进程 fatal 退出 | Outbox 断 Kafka 用例中停止 `im-local-kafka` 后,Admin consumer 报 `client has run out of available brokers` 并 `FATA` 退出。 | 已记录为 Admin 侧待修复问题:Kafka consumer 断 broker 不应 fatal,应退避重连。 | +| Kafka 停止/恢复后 IM consumer 自动恢复不稳定 | Kafka 恢复后,IM 仍 healthy,但消费端未立即消费新探针;重启 IM 后积压事件被补偿消费。 | 已记录为 IM 侧增强/修复项:Kafka reader 断 broker 后需要自动重建 reader 并继续消费。 | +| 备用群业务快照不一致 | 对 `grpEC7ddYMoI5s` 执行 `group.member.remove` 返回 `RESOURCE_NOT_FOUND` 并进入死信。 | 换用业务表已确认成员的 `grpgvTd1mFvM2M` 后移除成功;前一次作为死信生成用例保留。 | + +未做或需要人工补充: + +| 范围 | 说明 | +| --- | --- | +| Admin 前端页面点击 | 本轮验证了后端接口、Kafka 生产消费和数据库状态;未使用浏览器逐个菜单点选操作单页面、死信页面、审计页面。 | +| 真实客户端 WS 在线通知 | 后台命令会生成业务事件/系统通知;本轮未启动真实 C 端 WebSocket 客户端观察 UI 实时刷新。 | +| Admin 查询迁移状态 | 本轮验证 IM Server 内部查询接口可用,但 Admin 查询侧尚未全部切换到内部查询 API,页面查询隔离需要后续迁移。 | +| 离线 Push | 按既定范围仍放在未实现增强项,本轮不验收。 | + +### 10.3 2026-08-27 内部查询 API 双向联调 + +本轮在本地 Docker 环境重建 `im-local-server:dev` 和 `im-local-admin:dev` 后,对 Admin 查询 IM Server、IM Server 查询 Admin 两个方向做 HMAC 内部接口联调。IM Server 使用本地 MySQL `tinode`,Admin 使用本地 MySQL `hotgo`;Admin 本地联调容器为了避开队列消费者 Kafka 版本解析退出问题,临时使用 `disk` 队列驱动,不影响本节 HTTP 内部接口结论。 + +双向接口结果: + +| 方向 | 接口/范围 | 结果 | 证据 | +| --- | --- | --- | --- | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users?page=1&per_page=3` | 通过 | 返回 200,首条 `user_id=1565659909440147456`,JSON 类型为 number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users/{user_id}` | 通过 | 使用数据库 BIGINT 用户 ID 查询详情返回 200。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users?user_id={user_id}` | 通过 | 使用数据库 BIGINT 用户 ID 过滤列表返回 200。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/groups/grpgvTd1mFvM2M` | 通过 | 返回 200,`owner_id=1565659909045882880`,JSON 类型为 number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/groups/grpgvTd1mFvM2M/members` | 通过 | 返回 200,成员 `user_id/joined_by` 均为数据库 BIGINT number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/kafka/dead-letters?limit=1` | 通过 | 返回 200。 | +| Admin -> IM Server | 未签名请求 | 通过 | 返回 401,提示 `internal signature required`。 | +| IM Server -> Admin | `/internal/im-config/tenants?limit=1&state=active` | 通过 | 返回 200,租户 `default`,业务库 `tinode`。 | +| IM Server -> Admin | `/internal/im-config/tenants/by-code/default` | 通过 | 返回 200,租户 `default`,业务库 `tinode`。 | +| IM Server -> Admin | `/internal/im-config/registration/sms` | 通过 | 返回 200,`enabled=true`。 | +| IM Server -> Admin | `/internal/im-config/third-party/oss?keys=endpoint,bucket,access_key_id` | 通过 | 返回 200,配置 map 正常返回。 | +| IM Server -> Admin | 未签名请求 | 通过 | 返回 401。 | + +运行时链路也已确认:`im-local-im` 启动后持续通过 Docker 网络访问 `im-local-admin:8000/internal/im-config/tenants?limit=1000&state=active`,Admin 日志显示每 2 秒一次返回 200,说明不是只通过脚本单独打通。 + +本轮发现并处理: + +| 问题 | 影响 | 处理结果 | +| --- | --- | --- | +| Admin `/internal/im-config/registration/sms` HTTP 200 但业务 `code=53` | IM Server 查询注册短信开关时会拿到业务失败响应。 | 已修复 Admin 控制器扫描目标,将 `g.Map` 改为 `*entity.SysConfig`,重建 Admin 后复测返回 `code=0`。 | +| Admin 本地容器入口模板缺少新增内部接口配置 | 使用默认入口脚本会覆盖完整 `config.yaml`,导致内部接口密钥配置丢失。 | 本地联调改为挂载完整临时配置并直接启动 `./hotgo`;正式镜像建议同步补齐 `config.template`。 | +| Admin 本地 Kafka 队列版本解析退出 | Admin 启动队列消费者时 `invalid version 2.0.0.0`,进程 fatal。 | 本地 HTTP 联调临时切到 `disk` 队列驱动;Kafka consumer 退避重连和版本配置仍按增强项跟进。 | +| Admin 租户表查询仍出现 `hg_im_tenant` | 默认数据库前缀 `hg_` 会污染 `im_tenant/im_tenant_config` 这类 IM 公共表查询。 | 已修复 Admin `imtenant.RegistryDB()`:自动建立/使用 `Prefix` 为空的 `imRegistry` 连接;重建 Admin 后日志确认查询 `hotgo.im_tenant`,不再访问 `hg_im_tenant`。 | + +ID 口径结论:IM Server 外部聊天协议仍使用 `usr...`;Admin 内部管理/查询接口统一使用数据库 BIGINT/int64,查询返回和写命令入参不再混用 Tinode `usr...` 格式。 diff --git a/docs/backstage/IM-Kafka接入范围识别.md b/docs/backstage/IM-Kafka接入范围识别.md index 7e388a7..9316573 100644 --- a/docs/backstage/IM-Kafka接入范围识别.md +++ b/docs/backstage/IM-Kafka接入范围识别.md @@ -1,7 +1,7 @@ # IM Kafka 接入范围识别 -> 日期:2026-08-26 -> 依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md) +> 日期:2026-08-26 +> 依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md) > 目标:从前后端对接文档逐章识别适合接入 Kafka 的链路,明确 Kafka 与 WebSocket、Redis、MySQL、OSS 的边界。 ## 1. 总体边界 @@ -73,14 +73,7 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、 3. 总经理信箱提交生产 `im.workbench.event` 和 `im.audit.event`,后台处理结果再回推 C 端。 4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。 -## 7. 已补充:缓存失效与 Admin 配置查询 - -1. `im.cache.invalidate` 已接入 IM 消费端,支持租户、注册短信、第三方配置、字典配置、Redis 对象缓存、群热消息缓存失效。 -2. IM 已增加 Admin 内部配置查询客户端,开启 `admin_internal_api.enabled=true` 后优先通过 Admin 查询 hotgo 侧配置,旧数据库读取作为灰度兜底。 -3. Admin 侧需要补齐 `/internal/im-config/*` 只读接口,并与 IM 使用同一套 HMAC 签名密钥。 -4. Kafka 死信已补齐查询、忽略、解决、重放闭环:查询走 IM 内部 API,处理走 `im.admin.command`。 - -## 8. 首批建议实现 +## 7. 首批建议实现 1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。 2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。 diff --git a/docs/backstage/IM-后台全量需求文档.md b/docs/backstage/IM-后台全量需求文档.md new file mode 100644 index 0000000..a528341 --- /dev/null +++ b/docs/backstage/IM-后台全量需求文档.md @@ -0,0 +1,570 @@ +# IM 后台全量需求文档 + +> 版本:V1.0 草案 +> 编写日期:2026-08-21 +> 适用范围:HotGo 管理后台、IM 服务端、IM 客户端动态配置、控制库、租户业务库 +> 输入来源:`docs/im/IM-前后端对接文档.md`、`docs/im/IM-注册、邀请码与认证数据模型改造需求文档.md`、`docs/im/IM-HotGo注册短信开关与邀请码管理改造交接文档.md`、`docs/backstage/IM-后台需求文档.md` + +## 1. 文档目标 + +本文从前后端对接协议、注册/邀请码/认证改造文档中,完整识别 IM 项目需要由后台管理系统承载的能力,包括: + +1. 后台页面和业务管理模块。 +2. 平台级、租户级开关和配置。 +3. 后端统一维护、前端动态加载的字典枚举。 +4. 定时任务、Outbox、异步投递、重试和补偿任务。 +5. 客户端通过 `bootstrap`、配置接口、字典接口动态渲染的 UI 能力。 +6. 权限、审计、日志脱敏、导出和安全边界。 + +本文是后台全量需求盘点,不替代具体接口设计和数据库迁移 SQL。后续开发时,应按 HotGo 项目规范拆成独立 CRUD 模块、配置模块、任务模块和权限菜单。 + +## 2. 基础原则 + +### 2.1 多租户边界 + +当前多租户采用“一个 HotGo 控制库 + 每租户一个 IM 独立业务库”方案。 + +| 数据类型 | 存储位置 | 是否带 `tenant_id` | 说明 | +| --- | --- | --- | --- | +| 租户元数据 | HotGo 控制库 | 是 | `im_tenant`、租户配置、平台审计、邀请码主数据 | +| 平台全局配置 | HotGo 控制库 | 否或按配置域区分 | 例如 `hg_sys_config.group=im` | +| 邀请码批次和主表 | HotGo 控制库 | 是 | 后台生成、查询、撤销、导出 | +| 租户用户、群、消息、文件、组织、工单 | 租户 IM 业务库 | 否 | 通过登录态租户上下文选择库,不在业务表里保存 `tenant_id` | +| 租户内使用事实和业务 Outbox | 租户 IM 业务库 | 否 | 例如 `im_registration_invite_uses`、`im_business_events_outbox` | + +浏览器不能传入可信 `tenant_id`、`db_name`、`db_dsn`。平台管理员跨租户操作时,由后端基于登录身份和权限选择租户;租户管理员只能使用自身绑定的租户上下文。 + +### 2.2 HotGo 与 IM 服务职责 + +| 系统 | 职责 | +| --- | --- | +| HotGo 后台 | 菜单权限、后台页面、平台控制库、租户元数据、平台配置、邀请码管理、运维诊断、后台审计 | +| IM 服务端 | 客户端注册登录、实时消息、业务状态机、租户业务库事务、WebSocket、Outbox 消费、客户端动态配置下发 | +| 客户端 | 根据服务端返回的配置、权限、字典和策略动态渲染,不硬编码业务开关和枚举 | + +原则上,租户内用户、群、文件、实名、举报、反馈等业务状态变更优先通过 IM 服务端管理 API 或受控业务通道完成,避免绕过 IM 的状态机和审计。当前注册邀请码和密码重置存在明确的 HotGo 直连数据库边界,必须严格按交接文档执行。 + +### 2.3 配置分层 + +| 层级 | 示例 | 管理入口 | +| --- | --- | --- | +| 平台全局配置 | 注册短信发送开关、短信服务商、对象存储默认配置、通用风控模板 | 平台后台 | +| 租户基础配置 | 租户状态、企业码、名称、数据库、配额、品牌、终端地址 | 平台后台,部分租户只读 | +| 租户业务策略 | 消息策略、文件策略、群治理、隐私默认值、工作台模块 | 企业后台 | +| 用户个人配置 | 隐私开关、青少年模式状态、设备推送 Token | 客户端为主,后台只读或安全处置 | +| 前端动态 UI 配置 | `bootstrap.features`、`settings_menu`、`workbench.modules`、原因字典 | IM 服务端返回,来源于后台配置和权限 | + +## 3. 后台全量模块总览 + +| 模块 | 平台后台 | 企业后台 | 类型 | 优先级 | +| --- | --- | --- | --- | --- | +| 租户生命周期 | 是 | 否 | 管理页面、任务、审计 | P0 | +| 租户配置与配额 | 是 | 部分只读或申请变更 | 配置、开关、动态 UI | P0 | +| 注册短信开关 | 是 | 否 | 全局开关、审计 | P0 | +| 邀请码管理 | 平台可代管 | 是 | CRUD、导出、审计 | P0 | +| 管理员、角色、权限 | 是 | 是 | 权限、字典、审计 | P0 | +| 用户与账号安全 | 平台可运维 | 是 | 管理页面、Outbox | P0 | +| 组织架构 | 否 | 是 | CRUD、导入导出、动态 UI | P1 | +| 群治理 | 平台可审计 | 是 | 配置、管理页面、字典 | P1 | +| 消息策略与系统消息 | 平台模板 | 租户配置 | 配置、模板、任务 | P1 | +| 文件、收藏、搜索治理 | 平台运维 | 是 | 配置、任务、审计 | P1 | +| Push、短信、TURN、对象存储 | 是 | 租户专属配置 | 敏感配置、状态检查 | P1 | +| 工作台应用 | 平台模板 | 是 | 动态 UI 配置 | P2 | +| 会议 | 平台审计 | 是 | 管理页面、提醒任务 | P2 | +| 日报、月报 | 平台审计 | 是 | 模板、统计、提醒任务 | P2 | +| 总经理信箱 | 平台审计 | 是 | 配置、工单、动态文案 | P2 | +| 实名认证 | 平台审计 | 是 | 审核、策略、字典 | P2 | +| 青少年模式 | 平台模板 | 是 | 策略、动态文案、限制 | P2 | +| 反诈与内容审核 | 平台策略 | 租户策略与处置 | 风控、字典、审计 | P2 | +| 举报与反馈 | 平台可审计 | 是 | 原因字典、工单、导出 | P2 | +| App 版本更新 | 是 | 可按租户覆盖 | 配置、动态 UI | P2 | +| Outbox 与后台任务 | 是 | 只读本租户 | 运维、重试、死信 | P0 | +| 审计与日志脱敏 | 是 | 只读本租户 | 安全基础能力 | P0 | + +## 4. 开关与配置清单 + +### 4.1 平台全局开关 + +| 配置键 | 类型 | 默认 | 管理权限 | 使用方 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `im.register_sms_enabled` | bool | true | 平台超级管理员 | IM 注册发码接口 | 控制注册验证码是否真实发送;关闭时仍返回倒计时,不暴露给客户端 | +| `im.invite_batch_max_quantity` | int | 1000 | 平台管理员 | HotGo 邀请码管理 | 单批邀请码生成数量上限 | +| `im.invite_export_max_rows` | int | 待定 | 平台管理员 | HotGo 导出 | 防止明文邀请码大批量泄露 | +| `im.sms_provider_enabled` | bool | true | 平台管理员 | IM 发码服务 | 是否启用短信服务商,不等同于注册短信业务开关 | +| `im.audit_retention_days` | int | 待定 | 平台管理员 | 审计任务 | 平台审计保留期 | +| `im.outbox_retry_enabled` | bool | true | 平台运维 | Outbox 任务 | 是否自动重试平台/租户 Outbox | + +`register_sms_enabled` 存在 HotGo `hg_sys_config`,真实值必须保存为 `1/0`。前端复用 `sys_switch` 时要显式映射:UI `1=开启`、`2=关闭`,不能把 `2` 直接写成 bool。 + +### 4.2 租户基础配置 + +| 配置 | 类型 | 是否动态下发 | 管理入口 | 说明 | +| --- | --- | --- | --- | --- | +| 租户状态 | enum | 是 | 平台后台 | `provisioning/active/suspended/deleting/deleted` | +| 企业码 | string | 登录前解析 | 平台后台 | 客户端 `tenants/resolve` 使用 | +| 租户名称、Logo、品牌色 | string/file | 是 | 平台后台 | 登录页、工作台、关于页展示 | +| `db_name/db_dsn` | string | 否 | 平台后台 | 只允许后端使用,不给浏览器 | +| 用户配额 | int | 是 | 平台后台 | 注册、导入用户、邀请加入时强校验 | +| 群数量配额 | int | 是 | 平台后台 | 建群时校验 | +| 群成员上限 | int | 是 | 平台/企业后台 | 建群、邀请、审批通过时校验 | +| 文件存储配额 | int64 | 是 | 平台/企业后台 | 上传、转发、收藏、群文件共用 | +| 终端 API 地址 | url | 登录前解析 | 平台后台 | HTTP、WebSocket、文件服务地址 | +| 可用登录方式 | array enum | 登录前解析 | 平台后台 | 密码、手机号、企业码等 | +| 密码策略 | json | 登录/注册/改密 | 平台/企业后台 | 长度、复杂度、历史密码、过期策略 | +| App 更新策略 | json | 是 | 平台后台 | platform/channel 维度版本策略 | + +### 4.3 客户端 Bootstrap 动态配置 + +`GET /v1/im/bootstrap` 是客户端登录后的 UI 和能力裁剪总入口,后台必须为以下字段提供配置来源。 + +| 字段 | 后台配置项 | 客户端影响 | +| --- | --- | --- | +| `features.create_group` | 是否允许用户建群 | 隐藏或禁用建群入口 | +| `features.user_search` | 是否允许搜索用户 | 隐藏添加好友搜索入口 | +| `features.scan_qr` | 是否启用扫码 | 隐藏二维码/扫码入口 | +| `features.system_notifications` | 系统通知开关 | 展示系统通知 Topic | +| `features.message_recall` | 消息撤回开关 | 长按菜单是否显示撤回 | +| `features.message_edit` | 消息编辑开关 | 长按菜单是否显示编辑 | +| `features.organization` | 组织架构开关 | 工作台/通讯录组织入口 | +| `features.meetings` | 会议开关 | 工作台会议入口 | +| `features.daily_report/monthly_report` | 日报/月报开关 | 工作台报表入口 | +| `features.executive_mailbox` | 总经理信箱开关 | 工作台信箱入口 | +| `message_policy.recall_window_seconds` | 撤回时间窗口 | 超时隐藏操作 | +| `message_policy.edit_window_seconds` | 编辑时间窗口 | 超时隐藏操作 | +| `message_policy.supported_actions` | 消息操作集合 | 转发、收藏、复制、删除等 | +| `limits.max_file_size` | 单文件大小 | 上传前校验 | +| `limits.max_text_length` | 文本最大长度 | 输入框限制 | +| `limits.max_forward_targets` | 转发目标上限 | 多选转发限制 | +| `settings_menu` | 设置菜单配置 | iOS/Android 设置页动态入口 | +| `workbench.modules` | 工作台模块配置 | 工作台排序、图标、路由、角标 | + +### 4.4 注册、认证与邀请码配置 + +| 配置 | 作用 | 后台入口 | 备注 | +| --- | --- | --- | --- | +| 注册短信发送 | 是否真实调用短信服务商 | 平台后台“IM 注册” | 全局开关 | +| 用户名正则 | 普通用户名规则 | 平台只读配置或代码常量 | 当前固定 `^[a-z0-9]{1,10}$` | +| 固定国家号 | 手机号补 `+86` | 平台只读配置 | 本期固定中国 | +| 邀请码长度和字符集 | 生成规则 | 平台只读配置 | 当前固定 5 位 `[A-Za-z]` | +| 邀请码批量上限 | 批量生成限制 | 平台后台 | 默认 1000 | +| 邀请码有效期 | 单批/单码字段 | 企业后台 | 创建时必填,必须晚于当前时间 | +| 邀请码明文查看权限 | 控制列表/详情是否显示明文 | 菜单权限 | 查看、查询、导出需独立审计 | +| 密码重置策略 | 管理员重置密码规则 | 企业后台 | 写 `security.password_reset` Outbox | +| 登录 Token 失效策略 | `auth_version` 校验 | 后台只读/运维 | 重置密码后清退旧会话 | + +### 4.5 消息与会话策略 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 文本最大长度 | int | 输入框、服务端发送校验 | +| 消息撤回开关和时间窗 | bool/int | P2P、群聊均需服务端校验 | +| 消息编辑开关和时间窗 | bool/int | 编辑后产生版本或审计 | +| 多选转发目标上限 | int | 单选、多选、合并转发共用 | +| 支持消息操作 | array enum | copy、forward、favorite、recall、edit、delete | +| 已读回执策略 | enum/bool | 可受隐私和企业强制策略影响 | +| 链接识别与风险检测 | bool/json | URL 发送前后检查,触发反诈挑战 | +| 系统通知 Topic | string/json | 系统消息列表和角标 | +| 消息频控 | json | 防刷、风控、举报联动 | + +### 4.6 通讯录、好友与隐私策略 + +| 配置 | 类型 | 客户端影响 | +| --- | --- | --- | +| 是否允许添加好友 | bool | 添加好友入口 | +| 是否允许精准账号搜索 | bool | 用户 ID/用户名/手机号搜索 | +| 添加我的方式 | enum array | 账号、手机号、二维码、群聊 | +| 好友申请验证策略 | enum | 直接添加、需要验证、禁止 | +| 黑名单能力 | bool | 黑名单入口和服务端拦截 | +| 资料可见性默认值 | enum | 主页字段裁剪 | +| 最近在线可见性 | enum | 会话状态展示 | +| 已读回执默认值 | bool | 会话回执 | +| 企业强制隐私字段 | json | `editable=false` 时前端禁用 | + +### 4.7 群治理配置 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 是否允许建群 | bool | bootstrap 和建群接口 | +| 群成员上限 | int | 加群、邀请、审批事务内校验 | +| 入群策略 | enum | free、approval、invite_only | +| 成员邀请权限 | enum/bool | 群成员是否可邀请别人 | +| 成员上传文件 | bool | 群文件上传入口 | +| 成员私聊 | bool | 群成员主页是否允许发起会话 | +| 成员互加好友 | bool | 群资料页添加好友入口 | +| 全员禁言 | bool | 输入框禁用 | +| 成员禁言时长 | enum/int | 后台和群管理员使用 | +| 管理员数量上限 | int | 添加管理员时校验 | +| 群二维码有效期 | int | 二维码页和失效任务 | +| 群公告策略 | json | 发布、置顶、已读统计 | + +### 4.8 文件、收藏与搜索策略 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 单文件大小上限 | int64 | 上传前后校验 | +| 允许 MIME/扩展名 | array | 图片、视频、语音、普通文件按 purpose 区分 | +| 文件 purpose 策略 | json | avatar、chat、group_file、report、abuse_evidence、feedback | +| 文件保留期 | int | 过期清理任务 | +| 孤儿文件清理时间窗 | int | 上传未绑定业务时清理 | +| 文件病毒扫描 | bool/json | 同步拦截或异步隔离 | +| 收藏总量/容量上限 | int/int64 | 收藏入口和服务端校验 | +| 搜索开关 | bool | 聊天记录、文件、链接搜索入口 | +| 搜索索引策略 | json | 重建、增量、失败重试 | + +### 4.9 通话、Push 和外部服务配置 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 音视频通话开关 | bool | 呼叫入口 | +| 通话超时 | int | invite 超时时间 | +| TURN/STUN 服务器 | secret/json | WebRTC ICE 配置 | +| 通话记录保留期 | int | 后台查询和清理 | +| APNS/FCM 配置 | secret/json | 推送服务 | +| Push 锁屏隐私 | enum/bool | 通知内容是否显示明文 | +| Push 模板 | json | 系统消息、好友申请、会议提醒等 | +| 短信服务商配置 | secret/json | 验证码、通知短信 | +| 对象存储配置 | secret/json | 文件服务 | +| 外部 REST 插件配置 | secret/json | 扩展业务调用 | + +### 4.10 办公协同配置 + +| 模块 | 配置 | 说明 | +| --- | --- | --- | +| 工作台 | 模块 code、名称、图标、路由、排序、启用、权限 | `GET /v1/im/workbench` 动态返回 | +| 组织架构 | 组织可见范围、是否允许跨部门搜索 | 通讯录和工作台组织入口 | +| 会议 | 是否启用、提醒提前量、可见范围、取消策略 | 会议通知和提醒任务 | +| 日报 | 模板、必填规则、最大长度、补交天数、提醒时间 | 普通用户和管理员统计 | +| 月报 | 模板、必填规则、最大长度、重开流程 | 月报统计和详情 | +| 总经理信箱 | 是否启用、匿名、必填字段、最大长度、附件策略、提示文案 | 文案必须服务端下发,不由客户端硬编码 | + +### 4.11 安全、审核、举报与反馈配置 + +| 模块 | 配置 | 说明 | +| --- | --- | --- | +| 实名认证 | 是否强制、允许跳过、提醒周期、重复身份策略、审核字段 | 影响 `bootstrap.security.realname` | +| 青少年模式 | 策略版本、限制集合、提示文案、是否可启用 | 客户端确认时携带 `policy_version` | +| 反诈风控 | 高风险动作、提示文案、二次确认、阻断策略、Token TTL | 返回 `RISK_CONFIRMATION_REQUIRED` | +| 内容审核 | 审核模式、词库、插件、异步处置 | sync_block、async_hold、async_post_audit | +| 举报 | target_type 原因字典、证据规则、合并策略、限频 | `GET /v1/im/reports/abuse/reasons` | +| 反馈 | 类别、最大长度、联系方式、附件策略、诊断授权文案 | `GET /v1/im/feedback/config` | + +### 4.12 App 更新配置 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| platform | enum | ios、android、desktop 等 | +| channel | string/enum | stable、beta、enterprise | +| latest_version | string | 最新版本 | +| min_supported | string | 最低支持版本 | +| update_type | enum | none、optional、recommended、force | +| package_url/store_url | url | 安装来源 | +| release_notes | text | 更新说明 | +| remind_after_seconds | int | 非强制更新再次提醒间隔 | + +## 5. 字典与枚举清单 + +后台新增枚举必须由后端统一注册字典,前端通过字典接口加载,不能在页面硬编码文本。 + +### 5.1 平台与租户 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_tenant_status` | provisioning、active、suspended、deleting、deleted | +| `im_tenant_plan` | free、standard、enterprise、custom | +| `im_tenant_config_scope` | platform、tenant、user | +| `im_operation_result` | success、failed、partial | + +### 5.2 注册、认证、邀请码 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_register_sms_status` | enabled、disabled | +| `im_invite_status` | active、expired、revoked | +| `im_invite_action` | create、batch_create、view、query_exact、revoke、batch_revoke、export | +| `im_auth_scene` | register、login、forgot_password、bind_phone、close_youth_mode | +| `im_auth_channel` | sms、password、independent_password | +| `im_challenge_status` | pending、verified、expired、consumed、failed | +| `im_user_status` | normal、disabled、locked、deleted | +| `im_auth_level` | anonymous、authenticated、admin | + +### 5.3 好友、群与消息 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_friend_request_status` | pending、accepted、rejected、expired、cancelled | +| `im_friend_add_method` | account、phone、qr、group | +| `im_profile_visibility` | public、friends、none | +| `im_group_role` | owner、admin、member | +| `im_group_join_policy` | free、approval、invite_only | +| `im_group_request_status` | pending、approved、rejected、expired | +| `im_group_mute_type` | all、member | +| `im_message_action` | copy、forward、favorite、recall、edit、delete、multi_select | +| `im_message_content_type` | text、image、audio、video、file、card、link、system | +| `im_read_state` | unread、read | + +### 5.4 文件、搜索、通话 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_file_purpose` | chat、user_avatar、group_avatar、group_file、daily_report、monthly_report、abuse_evidence、feedback | +| `im_file_status` | uploaded、bound、quarantined、deleted、expired | +| `im_file_scan_status` | pending、clean、risky、failed | +| `im_search_index_status` | pending、indexed、failed、deleted | +| `im_call_type` | audio、video | +| `im_call_status` | ringing、accepted、declined、missed、cancelled、ended、timeout | + +### 5.5 办公协同 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_workbench_module_status` | enabled、disabled | +| `im_meeting_status` | scheduled、cancelled、finished | +| `im_meeting_response` | pending、accepted、declined、tentative | +| `im_report_type` | daily、monthly | +| `im_report_status` | draft、submitted、reopened、archived | +| `im_mailbox_submission_status` | received、assigned、replied、closed | + +### 5.6 安全、审核、举报、反馈 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_realname_status` | unverified、pending、approved、rejected | +| `im_youth_restriction` | night_quiet、content_filter、payment_guard、contact_limit | +| `im_risk_level` | low、medium、high | +| `im_risk_decision` | continue、cancel | +| `im_moderation_mode` | off、sync_block、async_hold、async_post_audit | +| `im_moderation_status` | pending、passed、blocked、removed | +| `im_abuse_target_type` | user、group、message | +| `im_abuse_report_status` | received、processing、resolved、rejected、merged | +| `im_abuse_reason` | fraud、harassment、pornography、violence、spam、other | +| `im_feedback_category` | bug、suggestion、complaint、other | +| `im_feedback_status` | received、processing、resolved、closed | + +### 5.7 终端、外部服务与错误 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_app_platform` | ios、android、desktop、web | +| `im_app_channel` | stable、beta、enterprise | +| `im_update_type` | none、optional、recommended、force | +| `im_push_provider` | apns、fcm、vendor | +| `im_outbox_status` | pending、processing、done、failed、dead | +| `im_error_reason` | TENANT_UNAVAILABLE、TENANT_MISMATCH、AUTH_FAILED、TOKEN_EXPIRED、ACCOUNT_DISABLED、RATE_LIMITED、VALIDATION_FAILED、IDEMPOTENCY_CONFLICT、RESOURCE_NOT_FOUND、FORBIDDEN、FRIEND_RELATION_REQUIRED、MESSAGE_NOT_ALLOWED、GROUP_MUTED、GROUP_MEMBER_LIMIT_EXCEEDED、TENANT_USER_QUOTA_EXCEEDED、TENANT_GROUP_QUOTA_EXCEEDED、FILE_TOO_LARGE、FILE_FORBIDDEN、FILE_UNAVAILABLE、STORAGE_QUOTA_EXCEEDED、REALNAME_REQUIRED、YOUTH_MODE_RESTRICTED、RISK_CONFIRMATION_REQUIRED、RISK_ACTION_BLOCKED、REPORT_RATE_LIMITED | + +## 6. 定时任务与异步任务 + +### 6.1 P0 基础任务 + +| 任务 | 触发方式 | 作用 | 后台能力 | +| --- | --- | --- | --- | +| 租户业务库初始化 | 创建租户后异步执行 | 创建库、执行 schema、初始化系统数据 | 进度、失败原因、重试 | +| 租户健康检查 | 定时 | 检查 DB 连接、schema version、基础 Topic | 状态看板、告警 | +| Outbox 消费状态巡检 | 定时 | 检查积压、失败、死信 | 列表、重试、死信 | +| `security.password_reset` 消费 | IM 轮询或 Kafka | 清退会话、拒绝旧 Token | 事件查询、重试 | +| 注册邀请码使用投影 | IM Outbox 或投影任务 | 将租户库 `invite_uses` 汇总到控制库主表 | 使用次数、最后使用时间、幂等补偿 | +| 审计日志脱敏与落库 | 同步/异步 | 保存操作审计,避免明文泄露 | 查询、导出、保留期 | + +### 6.2 业务任务 + +| 任务 | 说明 | +| --- | --- | +| 邀请码过期状态刷新 | 邀请码可查询时实时判断过期;可增加定时任务做状态冗余和统计,不作为注册校验唯一依据 | +| 邀请码批量生成 | 大批量生成应可异步化,支持进度、失败重试、幂等键 | +| 文件孤儿清理 | 上传后未绑定业务的文件定期清理 | +| 文件过期清理 | 按 purpose 和保留期清理 | +| 病毒扫描和隔离处理 | 异步扫描结果更新文件状态并通知用户 | +| 搜索索引重建 | 消息、文件、链接索引的全量/增量重建 | +| 系统通知投递 | 系统消息、公告、风控提醒、会议提醒的 Push 和站内信投递 | +| Push 重试和死信 | 第三方推送失败后的重试、死信、凭证异常告警 | +| 会议提醒 | 按会议配置提醒参会人,支持提醒未响应者 | +| 日报/月报提醒 | 按租户配置提醒未提交者,生成统计 | +| 总经理信箱通知 | 新提交、分派、回复、关闭通知 | +| 实名审核超时提醒 | 提醒审核员处理 | +| 风控 challenge 过期 | 清理过期 challenge,记录取消/过期审计 | +| 举报和反馈 SLA 提醒 | 超时未处理提醒,支持工单状态流转 | +| App 更新配置缓存刷新 | 版本策略变更后刷新缓存 | + +### 6.3 任务后台需求 + +1. 任务列表:任务名、租户、状态、进度、最近执行时间、耗时、错误原因。 +2. 任务详情:输入参数、幂等键、关联审计、关联 Outbox、重试次数。 +3. 操作:手动重试、暂停、恢复、标记死信、导出错误明细。 +4. 安全:租户管理员只能看本租户任务;平台运维可跨租户查看但必须审计。 + +## 7. 前端 UI 动态配置需求 + +### 7.1 客户端动态 UI + +客户端不能硬编码以下 UI 能力,必须由服务端返回: + +1. 登录前:租户品牌、企业码解析结果、终端地址、认证方式、密码策略、App 更新策略。 +2. 登录后:首页入口、`features`、`permissions`、`settings_menu`、工作台模块、系统通知 Topic。 +3. 聊天页:可执行消息操作、撤回/编辑时间窗、上传限制、转发上限、已读回执策略。 +4. 通讯录:组织架构入口、用户搜索入口、添加我的方式、黑名单入口。 +5. 群聊:入群策略、群文件入口、群成员私聊、成员邀请、禁言状态、管理员入口。 +6. 工作台:模块名称、图标、路由、排序、角标、启用状态和权限。 +7. 日报/月报:模板、校验、补交天数、是否展示管理员统计。 +8. 总经理信箱:提示文案、匿名说明、必填字段、附件规则。 +9. 实名、青少年模式、反诈:策略版本、提示文案、限制集合、确认 Token。 +10. 举报与反馈:原因、类别、最大长度、附件规则、诊断授权文案。 + +### 7.2 HotGo 管理后台动态 UI + +HotGo 后台自身也需要动态化: + +1. 平台管理员显示租户选择器;租户管理员隐藏租户选择器并固定当前租户。 +2. 按权限显示按钮:邀请码明文查看、导出、撤销、密码重置、跨租户查看必须独立权限。 +3. 表格状态、筛选下拉、表单选项必须从后端字典加载。 +4. 敏感配置字段只允许写入,不默认回显明文;回显使用脱敏值和“已配置”状态。 +5. 导出前必须展示范围、数量、敏感字段提示,二次确认并写审计。 +6. 表单提交不接受浏览器传入 `tenant_id/db_name/db_dsn` 作为可信路由依据。 + +## 8. 后台页面需求 + +### 8.1 平台后台 + +| 页面 | 核心能力 | +| --- | --- | +| 租户管理 | 创建、初始化、启停、删除流程、配置 DB、查看用量 | +| 租户配置 | 配额、品牌、终端地址、登录方式、密码策略、功能开关 | +| IM 注册设置 | 注册短信发送开关、批量邀请码上限、导出限制 | +| 平台管理员 | 平台账号、角色、权限、二次认证 | +| 外部服务配置 | SMS、Push、TURN、对象存储、审核插件 | +| App 版本 | 按 platform/channel 配置版本更新策略 | +| 运维看板 | 租户健康、Outbox、任务、缓存、连接状态 | +| 平台审计 | 跨租户操作、敏感配置变更、导出记录 | + +### 8.2 企业后台 + +| 页面 | 核心能力 | +| --- | --- | +| 邀请码管理 | 单个生成、批量生成、列表、精确查询、批次详情、撤销、导出、使用记录 | +| 企业管理员 | 添加管理员、设置角色、移除管理员、权限范围 | +| 用户管理 | 用户列表、状态、资料、重置密码、撤销会话、设备、安全事件 | +| 组织架构 | 部门树、成员、移动、导入导出、组织权限 | +| 群管理 | 群列表、详情、成员、管理员、禁言、转让群主、解散、群文件 | +| 消息与系统通知 | 模板、公告、系统通知、投递记录 | +| 文件治理 | 文件列表、用途、大小、状态、隔离、删除、下载审计 | +| 搜索治理 | 索引状态、重建、失败明细 | +| 工作台 | 模块配置、排序、图标、角标来源、权限绑定 | +| 会议 | 列表、详情、取消、提醒未响应者、统计 | +| 日报/月报 | 模板、统计、明细、提醒、重开流程 | +| 总经理信箱 | 配置、提交列表、分派、回复、关闭 | +| 实名审核 | 提交列表、详情、通过、拒绝、策略 | +| 青少年模式 | 策略、文案、用户状态、独立密码重置 | +| 反诈与审核 | 风控策略、challenge、内容审核队列、处置 | +| 举报工单 | 原因配置、举报列表、证据、处置、合并 | +| 意见反馈 | 类别配置、反馈列表、诊断附件、处理状态 | +| 租户审计 | 本租户后台操作、敏感查看、导出、业务处置 | + +## 9. 数据与接口边界 + +### 9.1 控制库建议承载 + +| 表或配置 | 说明 | +| --- | --- | +| `im_tenant` | 租户元数据、状态、企业码、DB 路由 | +| `im_tenant_config` | 租户配置、配额、功能开关、品牌 | +| `hg_sys_config` | 平台全局配置,例如注册短信发送 | +| `im_registration_invite_batches` | 邀请码批次 | +| `im_registration_invite_codes` | 邀请码主表、明文码、状态、使用统计投影 | +| `im_admin_operation_audit` | IM 后台业务审计 | +| `hg_admin_member.tenant_id` | 后台管理员绑定 IM 租户 | +| 平台 Outbox/任务表 | 平台任务、投递、重试、死信 | + +### 9.2 租户业务库建议承载 + +| 表 | 说明 | +| --- | --- | +| `im_users` | 用户主数据,目标包含 `username` 非空唯一、`phone` 可空唯一 | +| `im_auth` | 只按 `userid` 保存 Basic 密码凭据,删除 `scheme/uname` | +| `im_user_security` | `auth_version`、安全状态 | +| `im_registration_invite_uses` | 邀请码使用事实,只由 IM 注册事务写 | +| `im_business_events_outbox` | 租户内业务事件,例如密码重置、邀请码使用 | +| 用户、组织、群、文件、会议、报表、信箱、实名、举报、反馈业务表 | 不带 `tenant_id`,由选库隔离 | + +### 9.3 注册认证迁移后台需求 + +1. 增加迁移状态看板:每个租户当前 schema version、迁移批次、成功/失败数。 +2. 存量用户 username 回填报告:冲突、非法字符、保留账号、自动生成结果。 +3. phone 规范化报告:可转为 `+86`、重复冲突、格式异常。 +4. `im_auth` 双读/切换/清理阶段状态展示。 +5. 迁移前后登录回归数据:用户名登录、手机号登录、忘记密码、管理员重置密码。 +6. 失败租户支持重试和导出错误明细。 + +## 10. 权限、审计与日志脱敏 + +### 10.1 权限点 + +P0 必须独立拆分以下权限: + +1. 注册短信开关读取、修改。 +2. 邀请码列表、创建、批量创建、明文查看、精确查询、撤销、批量撤销、导出。 +3. 租户创建、启停、DB 配置查看、DB 配置修改。 +4. 用户查看、状态修改、重置密码、撤销会话、设备查看。 +5. 审计查看、审计导出。 +6. Outbox 查看、重试、标记死信。 +7. 外部服务敏感配置查看状态、修改。 + +### 10.2 审计范围 + +必须审计: + +1. 注册短信开关变更。 +2. 邀请码生成、批量生成、明文查询/查看、导出、撤销。 +3. 管理员重置密码、撤销会话、禁用用户。 +4. 租户创建、启停、删除流程、DB 路由配置变更。 +5. 群管理员变更、禁言、转让群主、解散群。 +6. 文件下载敏感证据、隔离、删除。 +7. 实名审核、举报处置、反馈处理。 +8. 风控挑战确认、取消、阻断。 +9. 任何跨租户查看、导出和代操作。 + +审计字段至少包含:`request_id`、`tenant_id`、`operator_id`、`operator_role`、`action`、`target_type`、`target_id`、`result`、`ip`、`user_agent`、`detail_json`、`created_at`。 + +### 10.3 日志脱敏 + +访问日志、错误日志、审计摘要、导出任务日志不得记录以下明文: + +1. 邀请码、验证码、密码、独立密码。 +2. `Authorization`、`serviceSecret`、短信/Push/对象存储密钥。 +3. `db_dsn`、数据库密码。 +4. 身份证件、实名原始图片、敏感证据文件临时 URL。 + +字段名至少覆盖:`inviteCode`、`invite_code`、`code`、`verification_code`、`password`、`secret`、`serviceSecret`、`service_secret`、`authorization`、`db_dsn`。 + +## 11. 验收清单 + +### 11.1 P0 + +1. 平台管理员可管理租户生命周期、配置和配额,租户管理员无法越权。 +2. 每个租户业务库独立,租户业务表不依赖 `tenant_id` 过滤。 +3. 注册短信开关只有平台超级管理员可改,值保存为 `1/0`,IM 服务缓存期内生效。 +4. 邀请码支持单个、批量、列表、精确查询、撤销、导出和使用统计。 +5. 邀请码明文查看、查询、导出都有独立权限和审计。 +6. 管理员重置密码写入 `im_auth`、递增 `auth_version`、写 `security.password_reset` Outbox。 +7. `bootstrap` 能返回功能开关、消息策略、限制、设置菜单、工作台基础配置。 +8. `hg_sys_log` 和业务日志不会泄露邀请码、密码、验证码、Token、密钥和 DSN。 + +### 11.2 P1 + +1. 用户、组织、群治理、文件治理、系统通知、Push/SMS/TURN/对象存储配置可后台管理。 +2. 群成员上限、用户配额、文件配额、消息策略在并发事务中强校验。 +3. 文件清理、Outbox 重试、Push 重试、搜索索引任务可观察、可重试。 +4. 前端所有状态、下拉、按钮权限从字典和权限接口动态加载。 + +### 11.3 P2 + +1. 工作台、会议、日报、月报、总经理信箱配置能动态影响客户端。 +2. 实名、青少年模式、反诈、举报、反馈具备策略配置、工单处理、审计。 +3. App 更新策略按 platform/channel 下发,异常时不误触发强制更新。 +4. 敏感证据、实名资料、诊断包具备权限、保留期和下载审计。 + +## 12. 已发现冲突与待确认项 + +1. 注册规则存在文档冲突:`IM-注册、邀请码与认证数据模型改造需求文档.md` 定义用户名和手机号是独立字段,二者同时填写时都保存;`IM-HotGo注册短信开关与邀请码管理改造交接文档.md` 的 3.1 仍写“手机号非空时忽略用户名”。建议以新的注册模型文档为准,并同步修正交接文档。 +2. 邀请码批次和主表目前按交接文档放在 HotGo 控制库,带 `tenant_id`;邀请码使用事实放在租户业务库。该设计不违反“租户业务表不带 tenant_id”,但需要在实现中明确控制库和业务库边界。 +3. 邀请码过期是否需要物理状态刷新任务待定。注册校验必须实时判断 `expires_at`,定时任务只能做冗余状态和统计。 +4. 注册短信开关当前是平台全局配置;未来若需要企业级差异,需迁移到 `im_tenant_config` 并定义优先级。 +5. 当前交接文档允许 HotGo 对部分租户库直连操作;长期是否统一收敛到 IM 管理 API 或 Kafka,需要在运维网络条件稳定后再评审。 +6. 开发/测试环境固定验证码 `123456` 不能进入生产;生产必须使用强随机验证码和真实短信服务商响应。 diff --git a/docs/backstage/IM-后台菜单覆盖测试文档.md b/docs/backstage/IM-后台菜单覆盖测试文档.md new file mode 100644 index 0000000..6b5f475 --- /dev/null +++ b/docs/backstage/IM-后台菜单覆盖测试文档.md @@ -0,0 +1,418 @@ +# IM 后台菜单覆盖测试文档 + +## 1. 测试范围 + +本文档用于覆盖 IM 后台菜单从「组织架构」开始,沿左侧菜单向下逐个页面点击验证。 + +当前覆盖菜单顺序: + +1. 组织架构 +2. 文件治理 +3. 注册邀请码 +4. 文件策略 +5. 通知模板 +6. 工作台配置 +7. 外部服务配置 +8. 版本更新 +9. 举报原因 +10. 总经理信箱配置 +11. 意见反馈配置 +12. 任务看板 +13. Kafka 命令 +14. Kafka 死信 +15. Kafka 审计事件 + +## 2. 测试环境 + +| 项目 | 地址 | +| --- | --- | +| 前端 | `http://127.0.0.1:8001/` | +| 后端 | `http://127.0.0.1:8000/admin` | +| 登录账号 | 使用具备 IM 管理权限的后台账号 | +| 浏览器 | Chrome,打开 DevTools Network 和 Console | + +## 3. 通用检查项 + +每进入一个菜单页面,都先执行以下检查: + +| 编号 | 检查项 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| COMMON-001 | 点击左侧菜单 | URL、页签、面包屑、页面标题正确变化 | 待测 | +| COMMON-002 | 页面首次加载 | 无白屏,无永久 loading,无明显样式错乱 | 待测 | +| COMMON-003 | Console | 无新的 JS Error,例如组件方法不存在、字典方法不存在 | 待测 | +| COMMON-004 | Network | 页面主列表接口 HTTP 200,业务响应 `code=0` | 待测 | +| COMMON-005 | 查询按钮 | 点击后列表刷新,请求参数包含搜索表单值 | 待测 | +| COMMON-006 | 重置按钮 | 搜索条件清空,列表回到默认查询 | 待测 | +| COMMON-007 | 展开/收起 | 搜索区域展开、收起正常,不遮挡表格 | 待测 | +| COMMON-008 | 表格刷新图标 | 点击后重新请求列表接口 | 待测 | +| COMMON-009 | 表格密度/列设置 | 可打开设置,切换后表格仍正常显示 | 待测 | +| COMMON-010 | 分页 | 切换页码和每页数量,请求参数正确,页面不报错 | 待测 | + +说明:涉及删除、撤销、重放、清理等副作用操作时,优先使用测试数据。没有测试数据时,至少验证弹窗、参数校验、取消流程;正式执行前确认不会影响线上有效数据。 + +## 4. 组织架构 + +菜单路径:`IM管理 / 组织架构` + +主要区域:部门列表、成员列表。 + +### 4.1 搜索与列表 + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ORG-001 | 打开「组织架构」 | 页面标题显示「组织架构」,部门列表和成员列表正常渲染 | 待测 | +| ORG-002 | 选择租户后查询部门 | 部门列表请求成功,显示部门 ID、部门名称、状态、成员数等字段 | 待测 | +| ORG-003 | 输入部门名称、状态后点击查询 | 部门列表按条件刷新 | 待测 | +| ORG-004 | 输入成员关键词、在职状态后点击查询 | 成员列表按条件刷新 | 待测 | +| ORG-005 | 点击部门行「成员」 | 成员列表切换到当前部门范围,添加成员按钮可用 | 待测 | + +### 4.2 部门按钮 + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ORG-010 | 点击「新增部门」 | 打开部门编辑弹窗 | 待测 | +| ORG-011 | 新增部门弹窗点击「取消」 | 弹窗关闭,列表不刷新或数据不变化 | 待测 | +| ORG-012 | 新增部门弹窗直接点击「保存」 | 必填项校验正常展示 | 待测 | +| ORG-013 | 填写部门名称、上级部门、排序、状态后点击「保存」 | 保存成功,部门列表刷新,新部门出现 | 待测 | +| ORG-014 | 点击部门行「编辑」 | 打开编辑弹窗,已有数据正确回填 | 待测 | +| ORG-015 | 修改部门信息后点击「保存」 | 保存成功,列表展示新值 | 待测 | +| ORG-016 | 点击部门行「删除」再点取消 | 弹窗关闭,部门不删除 | 待测 | +| ORG-017 | 点击部门行「删除」并确认 | 删除成功,列表刷新;有子部门或成员时应给出清晰错误 | 待测 | +| ORG-018 | 点击「导出部门」 | 触发文件下载,导出条件与当前查询一致 | 待测 | + +### 4.3 成员按钮 + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ORG-020 | 未选择部门时观察「添加成员」 | 按钮禁用或提示先选择部门 | 待测 | +| ORG-021 | 选择部门后点击「添加成员」 | 打开成员编辑弹窗,部门信息默认带入 | 待测 | +| ORG-022 | 成员弹窗直接点击「保存」 | 必填项校验正常展示 | 待测 | +| ORG-023 | 填写用户 ID、岗位、排序、可联系等信息后保存 | 保存成功,成员列表刷新 | 待测 | +| ORG-024 | 点击成员行「编辑」 | 打开编辑弹窗,成员信息正确回填 | 待测 | +| ORG-025 | 修改成员信息后保存 | 保存成功,列表展示新值 | 待测 | +| ORG-026 | 点击成员行「移除」再取消 | 弹窗关闭,成员仍保留 | 待测 | +| ORG-027 | 点击成员行「移除」并确认 | 移除成功,成员列表刷新 | 待测 | +| ORG-028 | 点击「导出成员」 | 触发文件下载,导出条件与当前查询一致 | 待测 | + +## 5. 文件治理 + +菜单路径:`IM管理 / 文件治理` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| FILE-001 | 打开页面 | 文件列表正常展示,包含文件 ID、状态、用途、上传用户、MIME、大小、引用数等字段 | 待测 | +| FILE-002 | 按租户、文件 ID、上传用户、状态、用途、MIME、Topic、更新时间查询 | 列表按条件刷新 | 待测 | +| FILE-003 | 点击行操作「详情」 | 打开详情弹窗,展示文件基础信息和引用列表 | 待测 | +| FILE-004 | 详情弹窗关闭 | 弹窗正常关闭,列表状态不丢失 | 待测 | +| FILE-005 | 点击行操作「隔离」再取消 | 确认框正常,取消后文件状态不变 | 待测 | +| FILE-006 | 点击行操作「隔离」并确认 | 文件状态变为隔离或列表刷新后状态正确 | 待测 | +| FILE-007 | 点击行操作「删除」再取消 | 确认框正常,取消后文件不删除 | 待测 | +| FILE-008 | 点击行操作「删除」并确认 | 删除成功,列表刷新 | 待测 | +| FILE-009 | 点击「清理孤儿文件」再取消 | 确认框正常,取消后不触发清理 | 待测 | +| FILE-010 | 点击「清理孤儿文件」并确认 | 清理请求成功,页面给出成功或结果提示 | 待测 | + +## 6. 注册邀请码 + +菜单路径:`IM管理 / 注册邀请码` + +实际菜单路由:`/#/im/registrationInvite` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| INVITE-001 | 打开页面 | 邀请码列表正常展示,包含租户、邀请码、状态、批次 ID、到期时间、使用次数等字段 | 待测 | +| INVITE-002 | 平台管理员不选择租户点击「添加」 | 提示先选择租户 | 待测 | +| INVITE-003 | 选择租户后点击「添加」 | 打开生成邀请码弹窗 | 待测 | +| INVITE-004 | 生成弹窗点击「取消」 | 弹窗关闭,列表不变化 | 待测 | +| INVITE-005 | 生成弹窗清空到期时间后点击「确认」 | 表单校验提示请选择到期时间 | 待测 | +| INVITE-006 | 填写到期时间、备注后点击「确认」 | 生成成功,列表刷新并出现新邀请码 | 待测 | +| INVITE-007 | 点击「批量生成」 | 打开批量生成弹窗,包含生成数量、到期时间、备注 | 待测 | +| INVITE-008 | 批量生成数量小于 1 或大于 1000 | 表单校验拦截 | 待测 | +| INVITE-009 | 输入合法数量后确认 | 批量生成成功,列表出现同一批次的邀请码 | 待测 | +| INVITE-010 | 按邀请码、状态、批次 ID、创建人、创建时间、到期时间查询 | 列表按条件刷新 | 待测 | +| INVITE-011 | 点击行操作「批次」 | 打开批次详情弹窗,展示总数、有效、撤销、过期、使用次数和明细 | 待测 | +| INVITE-012 | 点击有效邀请码行操作「撤销」再取消 | 确认框正常,取消后状态不变 | 待测 | +| INVITE-013 | 点击有效邀请码行操作「撤销」并确认 | 撤销成功,状态变为已撤销 | 待测 | +| INVITE-014 | 点击有效批次行操作「撤销批次」再取消 | 确认框正常,取消后批次状态不变 | 待测 | +| INVITE-015 | 点击有效批次行操作「撤销批次」并确认 | 批次内有效邀请码变为已撤销 | 待测 | +| INVITE-016 | 点击「导出」 | 下载 CSV 文件,内容与当前筛选条件一致 | 待测 | + +## 7. 文件策略 + +菜单路径:`IM管理 / 文件策略` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| FILE-POLICY-001 | 打开页面 | 策略列表正常展示,包含用途、大小上限、保留天数、自动清理、病毒扫描、MIME 白名单等字段 | 待测 | +| FILE-POLICY-002 | 按租户、用途、自动清理、病毒扫描查询 | 列表按条件刷新 | 待测 | +| FILE-POLICY-003 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| FILE-POLICY-004 | 编辑弹窗点击「取消」 | 弹窗关闭,数据不变化 | 待测 | +| FILE-POLICY-005 | 修改大小上限、保留天数、开关、MIME 白名单后保存 | 保存成功,列表刷新并展示新值 | 待测 | +| FILE-POLICY-006 | 输入非法大小或保留天数后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 8. 通知模板 + +菜单路径:`IM管理 / 通知模板` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| NOTIFY-001 | 打开页面 | 模板列表正常展示,包含场景、状态、站内、Push、短信、标题模板、内容模板等字段 | 待测 | +| NOTIFY-002 | 按租户、场景、状态查询 | 列表按条件刷新 | 待测 | +| NOTIFY-003 | 点击行操作「编辑」 | 打开编辑弹窗,模板字段正确回填 | 待测 | +| NOTIFY-004 | 编辑弹窗点击「取消」 | 弹窗关闭,数据不变化 | 待测 | +| NOTIFY-005 | 修改标题模板、内容模板、站内/Push/短信开关后保存 | 保存成功,列表刷新并展示新值 | 待测 | +| NOTIFY-006 | 清空必填字段后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 9. 工作台配置 + +菜单路径:`IM管理 / 工作台配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| WORKBENCH-001 | 打开页面 | 工作台模块列表正常展示,包含编码、名称、路由、排序、图标 ID、角标 Key、状态、权限等字段 | 待测 | +| WORKBENCH-002 | 按租户、编码、名称、状态查询 | 列表按条件刷新 | 待测 | +| WORKBENCH-003 | 点击「新增模块」 | 打开模块编辑弹窗 | 待测 | +| WORKBENCH-004 | 新增弹窗点击「取消」 | 弹窗关闭,列表不变化 | 待测 | +| WORKBENCH-005 | 新增弹窗直接保存 | 必填项校验正常展示 | 待测 | +| WORKBENCH-006 | 填写编码、名称、路由、排序、状态等信息后保存 | 保存成功,列表刷新 | 待测 | +| WORKBENCH-007 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| WORKBENCH-008 | 修改模块信息后保存 | 保存成功,列表展示新值 | 待测 | +| WORKBENCH-009 | 点击行操作「禁用」再取消 | 确认框正常,取消后状态不变 | 待测 | +| WORKBENCH-010 | 点击行操作「禁用」并确认 | 禁用成功,列表刷新后状态正确 | 待测 | +| WORKBENCH-011 | 点击「保存排序」 | 排序保存成功;列表刷新后顺序保持一致 | 待测 | + +## 10. 外部服务配置 + +菜单路径:`IM管理 / 外部服务配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| EXT-CONFIG-001 | 打开页面 | 配置列表正常展示,包含服务、服务商、端点、启用、配置状态、敏感配置、检查结果等字段 | 待测 | +| EXT-CONFIG-002 | 按租户、服务、服务商、启用状态查询 | 列表按条件刷新 | 待测 | +| EXT-CONFIG-003 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| EXT-CONFIG-004 | 编辑弹窗切换「启用」开关 | 开关状态可变更,保存前不应提前提交 | 待测 | +| EXT-CONFIG-005 | 修改服务商、端点、配置内容后保存 | 保存成功,列表刷新并展示新值 | 待测 | +| EXT-CONFIG-006 | 点击行操作「检查」再取消 | 确认框正常,取消后不触发检查 | 待测 | +| EXT-CONFIG-007 | 点击行操作「检查」并确认 | 检查请求成功,检查结果和检查时间刷新 | 待测 | + +## 11. 版本更新 + +菜单路径:`IM管理 / 版本更新` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| RELEASE-001 | 打开页面 | 版本策略列表正常展示,包含平台、渠道、最新版本、最低支持、更新类型、下载地址等字段 | 待测 | +| RELEASE-002 | 按租户、平台、渠道查询 | 列表按条件刷新 | 待测 | +| RELEASE-003 | 点击「新增策略」 | 打开策略编辑弹窗 | 待测 | +| RELEASE-004 | 新增弹窗直接保存 | 必填项校验正常展示 | 待测 | +| RELEASE-005 | 填写平台、渠道、版本号、更新类型、地址、发布时间后保存 | 保存成功,列表刷新 | 待测 | +| RELEASE-006 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| RELEASE-007 | 修改版本策略后保存 | 保存成功,列表展示新值 | 待测 | +| RELEASE-008 | 输入非法版本号或 URL 后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 12. 举报原因 + +菜单路径:`IM管理 / 举报原因` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ABUSE-001 | 打开页面 | 原因列表正常展示,包含对象、编码、文案、要求描述、允许证据、排序、状态等字段 | 待测 | +| ABUSE-002 | 按租户、对象、编码、状态查询 | 列表按条件刷新 | 待测 | +| ABUSE-003 | 点击「新增原因」 | 打开原因编辑弹窗 | 待测 | +| ABUSE-004 | 新增弹窗直接保存 | 必填项校验正常展示 | 待测 | +| ABUSE-005 | 填写对象、编码、文案、排序、状态等信息后保存 | 保存成功,列表刷新 | 待测 | +| ABUSE-006 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| ABUSE-007 | 修改原因配置后保存 | 保存成功,列表展示新值 | 待测 | +| ABUSE-008 | 点击行操作「禁用」再取消 | 确认框正常,取消后状态不变 | 待测 | +| ABUSE-009 | 点击行操作「禁用」并确认 | 禁用成功,列表刷新后状态正确 | 待测 | + +## 13. 总经理信箱配置 + +菜单路径:`IM管理 / 总经理信箱配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| MAILBOX-001 | 打开页面 | 配置表单正常展示,租户选择框可用 | 待测 | +| MAILBOX-002 | 选择租户 | 自动加载该租户信箱配置 | 待测 | +| MAILBOX-003 | 点击「刷新」 | 当前租户配置重新加载,表单无异常 | 待测 | +| MAILBOX-004 | 未选择租户时观察「保存」 | 按钮禁用或提示先选择租户 | 待测 | +| MAILBOX-005 | 修改启用状态、接收人、处理策略等配置后点击「保存」 | 保存成功,再次刷新后配置保持一致 | 待测 | +| MAILBOX-006 | 输入非法配置后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 14. 意见反馈配置 + +菜单路径:`IM管理 / 意见反馈配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| FEEDBACK-001 | 打开页面 | 配置表单正常展示,租户选择框可用 | 待测 | +| FEEDBACK-002 | 选择租户 | 自动加载该租户反馈配置 | 待测 | +| FEEDBACK-003 | 点击「刷新」 | 当前租户配置重新加载,表单无异常 | 待测 | +| FEEDBACK-004 | 未选择租户时观察「保存」 | 按钮禁用或提示先选择租户 | 待测 | +| FEEDBACK-005 | 修改启用状态、分类、处理人、限制策略等配置后点击「保存」 | 保存成功,再次刷新后配置保持一致 | 待测 | +| FEEDBACK-006 | 输入非法配置后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 15. 任务看板 + +菜单路径:`IM管理 / 任务看板` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| TASK-001 | 打开页面 | 看板列表正常展示,包含租户、企业码、状态、DB、业务待投递、业务失败、群待投递、群失败、孤儿文件、待物理清理等字段 | 待测 | +| TASK-002 | 按租户、状态查询 | 列表按条件刷新 | 待测 | +| TASK-003 | 查看建议动作字段 | 异常租户展示可理解的建议动作;正常租户展示正常或为空 | 待测 | +| TASK-004 | 刷新表格 | 统计数据重新加载,无接口报错 | 待测 | + +## 16. Kafka 命令 + +菜单路径:`IM管理 / Kafka命令` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| KAFKA-CMD-001 | 打开页面 | 命令列表正常展示,包含租户、命令 ID、事件类型、业务类型、业务 ID、状态、错误码、消息等字段 | 待测 | +| KAFKA-CMD-002 | 按租户、命令 ID、事件 ID、事件类型、业务 ID、状态、创建时间查询 | 列表按条件刷新 | 待测 | +| KAFKA-CMD-003 | 点击「发送缓存失效测试」 | 请求成功,新增一条缓存失效命令或页面提示成功 | 待测 | +| KAFKA-CMD-004 | 点击「发送工作台测试事件」 | 请求成功,新增一条工作台测试事件命令或页面提示成功 | 待测 | +| KAFKA-CMD-005 | 点击行操作「详情」 | 打开详情弹窗,展示命令 payload、结果、错误原因等信息 | 待测 | +| KAFKA-CMD-006 | 详情弹窗点击「关闭」 | 弹窗正常关闭 | 待测 | +| KAFKA-CMD-007 | 发送测试事件后等待消费结果 | 状态从待发送/已发送变为成功或失败,失败时错误信息可读 | 待测 | + +## 17. Kafka 死信 + +菜单路径:`IM管理 / Kafka死信` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| DEAD-001 | 打开页面 | 死信列表正常展示,包含死信 ID、事件 ID、来源 Topic、消费组、事件类型、业务类型、业务 ID、状态等字段 | 待测 | +| DEAD-002 | 按租户、状态、事件类型、业务类型、业务 ID 查询 | 列表按条件刷新 | 待测 | +| DEAD-003 | 点击行操作「详情」 | 打开详情弹窗,展示死信 payload、错误原因、处理信息 | 待测 | +| DEAD-004 | 详情弹窗点击「关闭」 | 弹窗正常关闭 | 待测 | +| DEAD-005 | 详情弹窗点击「忽略」 | 操作成功,状态变为已忽略或列表刷新后状态正确 | 待测 | +| DEAD-006 | 详情弹窗点击「标记已处理」 | 操作成功,状态变为已处理 | 待测 | +| DEAD-007 | 详情弹窗点击「重放」 | 操作成功,重放次数增加,状态或处理结果正确更新 | 待测 | +| DEAD-008 | 对已处理、已忽略死信重复操作 | 系统应拦截或给出清晰提示,不出现脏状态 | 待测 | + +## 18. Kafka 审计事件 + +菜单路径:`IM管理 / Kafka审计事件` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| KAFKA-AUDIT-001 | 打开页面 | 审计事件列表正常展示,包含租户、事件 ID、事件类型、动作、资源、资源 ID、操作者、结果等字段 | 待测 | +| KAFKA-AUDIT-002 | 按租户编码、事件 ID、事件类型、动作、资源、资源 ID、结果、接收时间查询 | 列表按条件刷新 | 待测 | +| KAFKA-AUDIT-003 | 点击行操作「详情」 | 打开详情弹窗,展示事件详情、请求上下文、结果信息 | 待测 | +| KAFKA-AUDIT-004 | 详情弹窗点击「关闭」 | 弹窗正常关闭 | 待测 | + +## 19. 测试数据建议 + +| 数据类型 | 建议准备 | +| --- | --- | +| 租户 | 至少 1 个正常租户,具备独立业务库 | +| 组织架构 | 至少 1 个根部门、1 个子部门、2 个用户成员 | +| 文件治理 | 至少 1 个正常文件、1 个可隔离文件、1 个孤儿文件 | +| 注册邀请码 | 至少 1 个有效邀请码、1 个过期邀请码、1 个批次邀请码 | +| Kafka | Kafka、消费者、IM Server 正常运行;准备 1 条可产生成功结果的命令和 1 条可产生死信的命令 | +| 配置类 | 文件策略、通知模板、工作台、外部服务、版本更新、举报原因均有初始数据 | + +## 20. 缺陷记录模板 + +| 缺陷编号 | 菜单 | 操作步骤 | 实际结果 | 预期结果 | Console/Network 信息 | 严重级别 | 状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| BUG-001 | | | | | | | 待处理 | + +## 21. 第一轮执行记录 + +执行时间:2026-09-03 15:16 - 15:22 + +执行环境: + +| 项目 | 值 | +| --- | --- | +| 前端 | `http://127.0.0.1:8001/` | +| 后端 | Docker 容器 `im-local-admin`,端口 `8000` | +| MySQL | Docker 容器 `relaxed_cori`,库 `hotgo`、`tinode`、`tinode_test` | +| Redis | Docker 容器 `brave_blackburn` | +| Kafka | Docker 容器 `kafka` | + +本轮说明: + +1. 从左侧菜单「组织架构」开始,按菜单顺序逐页点击。 +2. 对查询、重置、展开、刷新、新增、编辑、详情等非破坏性按钮进行了点击验证。 +3. 对删除、隔离、撤销、禁用、重放等有副作用的按钮,本轮只验证入口和取消流程,不确认执行。 +4. 「收起」按钮在自动化脚本里先于「展开」检测,产生过误报,本轮不作为缺陷记录。 +5. 「注册邀请码」菜单点击正常,真实路由为 `/#/im/registrationInvite`;直接访问 `/#/im/registrationInvite/index` 会进入 ErrorPage,测试文档已修正。 + +### 21.1 页面通过项 + +| 菜单 | 本轮结果 | +| --- | --- | +| 组织架构 | 页面可打开,查询/重置/展开/新增部门可点击;添加成员因未先选择部门处于禁用,符合预期 | +| 文件治理 | 页面可打开,查询/重置/展开/详情可点击;列表有数据 | +| 注册邀请码 | 通过左侧菜单可打开,查询/重置/展开/添加/批量生成/导出可见;列表有 2 条历史数据 | +| 工作台配置 | 页面可打开,查询/重置/展开/新增模块/编辑可点击;禁用弹窗可打开并取消 | +| 版本更新 | 页面可打开,查询/重置/展开/新增策略/编辑可点击;列表有数据 | +| 举报原因 | 页面可打开,查询/重置/展开/新增原因/编辑可点击;禁用弹窗可打开并取消 | +| 总经理信箱配置 | 页面可打开,保存/刷新按钮可见,刷新可点击 | +| 意见反馈配置 | 页面可打开,保存/刷新按钮可见,刷新可点击 | +| 任务看板 | 页面可打开,查询/重置/展开可点击 | + +### 21.2 本轮发现的问题 + +| 缺陷编号 | 菜单 | 操作步骤 | 实际结果 | 预期结果 | 证据 | 严重级别 | 状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| BUG-IM-001 | 文件策略 | 点击「IM管理 / 文件策略」,或点击查询/重置 | 页面 Console 报 `检查IM文件策略失败`,列表无法加载,行操作「编辑」不可测 | 文件策略列表应正常加载,缺省策略应自动初始化 | 后端日志:`SELECT COUNT(1) FROM im_file_policy ... Error 1146: Table 'hotgo.im_file_policy' doesn't exist`;代码位置:`server/internal/logic/sys/im_file_policy.go:128` | P0 | 待处理 | +| BUG-IM-002 | 通知模板 | 点击「IM管理 / 通知模板」,或点击查询/重置 | 页面 Console 报 `检查IM通知模板失败,请稍后重试!`,列表无法加载,行操作「编辑」不可测 | 通知模板列表应正常加载,缺省模板应自动初始化 | 后端日志:`SELECT COUNT(1) FROM im_notification_template ... Error 1146: Table 'hotgo.im_notification_template' doesn't exist`;代码位置:`server/internal/logic/sys/im_notification.go:123` | P0 | 待处理 | +| BUG-IM-003 | 外部服务配置 | 点击「IM管理 / 外部服务配置」,或点击查询/重置 | 页面 Console 报 `初始化IM外部服务配置失败`,列表无法加载,行操作「编辑」「检查」不可测 | 外部服务配置列表应正常加载,缺省服务配置应自动初始化 | 后端日志:`SELECT COUNT(1) FROM im_external_config ... Error 1146: Table 'hotgo.im_external_config' doesn't exist`;代码位置:`server/internal/logic/sys/im_external_config.go:194` | P0 | 待处理 | +| BUG-IM-004 | Kafka命令 | 点击「IM管理 / Kafka命令」后点击「发送缓存失效测试」 | 页面 toast 显示 `读取IM命令Outbox失败`,Kafka 命令列表仍为 0 条 | 应写入 Outbox 并投递 Kafka,页面能看到新事件或明确状态 | 后端日志:`读取IM命令Outbox失败: element of parameter "pointer" for function Scan should type of struct/*struct/[]struct/[]*struct`;代码位置:`server/internal/library/imkafka/command.go:103` | P0 | 待处理 | +| BUG-IM-005 | Kafka命令 | 点击「发送缓存失效测试」「发送工作台测试事件」后查看数据库 | 正确表 `hotgo.im_admin_outbox` 为 0 条;带前缀表 `hotgo.hg_im_admin_outbox` 新增 3 条 pending 记录 | Admin 新增 IM 表和代码查询应统一使用无前缀 `im_*` 表,避免 `hg_im_*` 和 `im_*` 双表分裂 | MySQL:`SHOW TABLES LIKE 'hg_im_%'` 可见 `hg_im_admin_outbox`、`hg_im_admin_commands`、`hg_im_audit_events`;`hg_im_admin_outbox` 有 pending 数据,`im_admin_outbox` 为空 | P0 | 待处理 | +| BUG-IM-006 | Kafka死信 | 点击「IM管理 / Kafka死信」 | 页面可打开,但列表无数据,行操作「详情」「忽略」「标记已处理」「重放」无法覆盖 | 联调测试库应准备至少 1 条死信数据,才能覆盖处置按钮 | MySQL:`hotgo.im_kafka_dead_letter_events` 计数为 0 | P1 | 待补测试数据 | +| BUG-IM-007 | Kafka审计事件 | 点击「IM管理 / Kafka审计事件」 | 页面可打开,但列表无数据,行操作「详情」无法覆盖 | 联调测试库应准备至少 1 条审计事件数据,才能覆盖详情按钮 | MySQL:`hotgo.im_audit_events` 计数为 0 | P1 | 待补测试数据 | + +### 21.3 数据库核查结果 + +| 库 | 表 | 结果 | +| --- | --- | --- | +| `hotgo` | `im_file_policy` | 不存在 | +| `hotgo` | `im_notification_template` | 不存在 | +| `hotgo` | `im_external_config` | 不存在 | +| `hotgo` | `im_admin_commands` | 存在,0 条 | +| `hotgo` | `im_admin_outbox` | 存在,0 条 | +| `hotgo` | `hg_im_admin_outbox` | 存在,3 条 pending | +| `hotgo` | `im_kafka_dead_letter_events` | 存在,0 条 | +| `hotgo` | `im_audit_events` | 存在,0 条 | + +### 21.4 下一步建议 + +1. 先导入缺失表 SQL:`server/storage/data/generate/im_file_policy_menu.sql`、`server/storage/data/generate/im_notification_menu.sql`、`server/storage/data/generate/im_external_config_menu.sql`。 +2. 修复 Kafka Outbox 表名前缀分裂问题,确保所有 Kafka Admin 表统一访问 `im_*`,不要落到 `hg_im_*`。 +3. 修复 `DispatchOutboxByEvent` 中 `g.DB().GetOne` 触发的 `Scan` 参数类型错误。 +4. 准备 Kafka 死信和 Kafka 审计事件测试数据后,重新覆盖「详情」「忽略」「标记已处理」「重放」等按钮。 +5. 上述 P0 处理后,重新执行本测试文档第 4 到第 18 章。 + +## 22. 第二轮修复记录 + +执行时间:2026-09-03 15:37 - 15:50 + +### 22.1 已处理项 + +| 缺陷编号 | 处理结果 | 复测结果 | +| --- | --- | --- | +| BUG-IM-001 | 已补齐 `hotgo.im_file_policy` 表;访问文件策略列表时可自动初始化默认策略 | `/admin/imFilePolicy/list?tenantId=1` 返回 `code=0`,列表 6 条 | +| BUG-IM-002 | 已补齐 `hotgo.im_notification_template` 表;访问通知模板列表时可自动初始化默认模板 | `/admin/imNotification/templateList` 返回 `code=0`,列表 7 条 | +| BUG-IM-003 | 已补齐 `hotgo.im_external_config` 表;访问外部服务配置列表时可自动初始化默认配置 | `/admin/imExternalConfig/list?tenantId=1` 返回 `code=0`,列表 5 条 | +| BUG-IM-004 | 已修复 Kafka Admin 命令/Outbox 读写 DB,统一改为无前缀 `imRegistry` 连接 | 点击等价接口 `/admin/imKafkaCommand/cacheInvalidate` 返回 `code=0`,不再报 `读取IM命令Outbox失败` | +| BUG-IM-005 | 已修复 `im_admin_commands`、`im_admin_outbox`、`im_audit_events`、`im_kafka_dead_letter_events` 读写前缀分裂 | 新增 Kafka 测试事件写入 `hotgo.im_admin_outbox`,状态为 `sent`;没有继续写入 `hg_im_admin_outbox` | + +### 22.2 额外修复 + +| 问题 | 处理结果 | 复测结果 | +| --- | --- | --- | +| Docker 本地 admin 缺少运行时 MySQL/Redis 环境变量导致启动 panic | 本地容器启动时显式传入 `MYSQL_DSN`、`REDIS_ADDR`、`TOKEN_SECRET_KEY` 等变量 | `im-local-admin` 正常启动,`http://127.0.0.1:8000/admin/` 可访问 | +| admin 容器内 `im-server:6060` 解析到不可用地址,死信内部查询 EOF | `config.template`、`entrypoint.sh` 增加 IM API 环境变量;本地容器使用 `IM_INTERNAL_API_BASE_URL=http://im-local-im:6060` | `/admin/imDeadLetter/list?tenantId=1` 返回 `code=0`,列表 18 条 | +| 测试库 IM 菜单中文标题乱码 | 已在测试库用 UTF-8 连接修复 `hg_admin_menu.title`;新增 `im_menu_title_repair.sql` 可重复修复 | `imManager`、`imOrgIndex`、`imFilePolicyIndex`、`imKafkaCommandIndex` 等标题查询显示正常中文 | +| 部分新增菜单 SQL 使用旧版 `hg_admin_menu` 字段 `route/is_cache/created_by/updated_by` | 已转换相关 `*_menu.sql` 菜单插入字段为当前 HotGo 表结构 | 相关 SQL 在本地 `hotgo` 库重复执行通过,无 `Unknown column 'route'` | + +### 22.3 当前剩余注意项 + +1. 当前浏览器页签仍保存旧 token,页面上可能继续弹「登录身份已失效」;刷新并重新登录后再继续 UI 点击测试。 +2. `hg_im_admin_outbox` 里还保留第一轮测试误写入的 3 条旧数据,仅用于证明旧问题,不影响新链路;后续可按运维需要清理。 +3. Kafka 审计事件列表当前仍无数据,需要 IM Server 产生或导入审计事件后才能覆盖详情弹窗。 diff --git a/docs/backstage/IM-未实现增强项.md b/docs/backstage/IM-未实现增强项.md new file mode 100644 index 0000000..0bd3f7c --- /dev/null +++ b/docs/backstage/IM-未实现增强项.md @@ -0,0 +1,358 @@ +# IM 未实现增强项 + +> 更新时间:2026-08-22 +> 范围:HotGo IM 后台、IM 服务端配合项、客户端动态配置来源 + +本文用于记录当前后台已识别但尚未完成的增强项,作为后续开发排期和验收依据。当前多租户方案为“一个 HotGo 控制库 + 每租户一个独立 IM 业务库”,租户业务表不带 `tenant_id`,后台必须通过登录态解析租户并选择租户库。 + +## 已完成基线 + +1. 邀请码管理:单个生成、批量生成、列表、批次详情、撤销、批量撤销、导出、注册短信开关。 +2. 租户管理:列表、详情、编辑、状态、用量/健康、删除前检查、进入删除流程、恢复。 +3. 用户管理:列表、详情、设备、重置密码、撤销会话、账号状态停用/恢复。 +4. 审计日志:列表、详情、导出。 +5. Outbox 运维:列表、详情、重试、标记死信,覆盖 `im_business_events_outbox` 和 `im_group_events_outbox`。 +6. 租户边界:用户、审计、群、实名、工单、Outbox 等后台接口已按登录态解析租户范围。 +7. 群治理处置:治理配置更新、解散群、成员禁言、解除禁言、入群审批、管理员设置/取消、群主转让、群文件删除。 + +## 剩余 P0 + +### P0-1 Bootstrap 动态配置来源 + +状态:未实现。 + +目标: + +1. 后台提供 `bootstrap` 所需配置来源,包括 `features`、`message_policy`、`limits`、`settings_menu`、`workbench.modules`。 +2. IM 服务端从后台配置或控制库读取后,下发给客户端。 +3. 客户端不硬编码功能开关、消息策略、设置菜单和工作台基础入口。 + +建议实现: + +1. 扩展 `im_tenant_config` 或新增独立配置表。 +2. 使用 GoFrame CLI 重新生成 DAO/entity/do,禁止手改生成文件。 +3. 后台提供租户配置页面,敏感字段脱敏回显。 +4. IM 服务端实现配置读取和缓存,缓存失效策略单独定义。 + +依赖: + +1. 控制库迁移 SQL。 +2. IM 服务端 `bootstrap` 接口对接。 +3. 客户端按返回配置裁剪 UI。 + +### P0-2 完整租户配置与配额 + +状态:部分实现。 + +已实现: + +1. `max_users` +2. `max_groups` +3. `max_group_members` +4. 租户状态、企业码、名称、DB 路由 + +未实现: + +1. 品牌 Logo、品牌色、登录页展示配置。 +2. HTTP API、WebSocket、文件服务等终端地址。 +3. 可用登录方式。 +4. 密码策略。 +5. 文件存储配额。 +6. 功能开关配置。 +7. App 更新策略基础配置。 + +建议实现: + +1. 将基础配额、品牌、终端地址、登录方式、密码策略拆成清晰字段或 JSON 配置。 +2. 对 `db_dsn`、密钥类字段只保存不明文回显。 +3. 平台管理员可编辑,租户管理员按权限只读或申请变更。 + +### P0-3 日志脱敏统一治理 + +状态:未实现。 + +目标: + +1. 访问日志、错误日志、审计摘要、导出任务日志不得泄露敏感字段。 +2. 覆盖邀请码、验证码、密码、Token、服务密钥、数据库 DSN、实名证件信息。 + +至少覆盖字段: + +1. `inviteCode` +2. `invite_code` +3. `code` +4. `verification_code` +5. `password` +6. `secret` +7. `serviceSecret` +8. `service_secret` +9. `authorization` +10. `db_dsn` +11. `id_number` +12. `token` + +建议实现: + +1. 在统一响应/日志中间件或日志写入前增加脱敏工具。 +2. 对业务审计 `detail_json` 做字段级脱敏。 +3. 补充单元测试,覆盖嵌套 JSON、大小写字段、数组对象。 + +### P0-4 邀请码明文查看与精确查询审计拆分 + +状态:部分实现。 + +已实现: + +1. 邀请码生成、批量生成、列表、批次详情、撤销、批量撤销、导出。 +2. 基础菜单权限。 + +待确认/增强: + +1. 明文查看是否需要独立权限点。 +2. 精确查询邀请码是否需要独立权限点。 +3. 导出明文邀请码是否需要二次确认和独立审计。 +4. 查询/查看/导出操作是否全部写入业务审计。 + +建议实现: + +1. 增加 `imInvitePlainView` 或等价权限。 +2. 列表默认脱敏,只有具备权限时显示明文。 +3. 精确查询、明文查看、导出记录审计,审计中不写邀请码明文。 + +## 后续 P1 + +### P1-0 组织架构管理 + +状态:部分实现。 + +已实现: + +1. 部门列表。 +2. 部门新增、编辑、移动。 +3. 部门删除前校验子部门和成员。 +4. 部门移动防环校验。 +5. 部门成员列表。 +6. 成员添加、编辑、调岗、移除。 +7. 组织搜索基础接口,覆盖部门和成员。 +8. 组织操作写入 IM 后台审计。 +9. 部门导出和成员导出。 + +待实现/配合: + +1. 组织数据权限:当前后台按租户选库隔离,部门级 `org_admin` 数据范围尚未建模。 +2. 组织导入:需要定义 Excel 字段、冲突覆盖规则、失败明细和幂等更新策略。 +3. 离职用户处理策略:当前支持 `employment_state` 和 `contactable`,客户端/IM 服务端需按该状态裁剪通讯录和可联系入口。 +4. 组织版本增量同步:当前维护部门/成员版本号,客户端增量同步协议需由 IM 服务端继续承接。 + +### P1-1 群治理处置动作 + +状态:已完成基础后台闭环。 + +已实现: + +1. 群列表、详情。 +2. 成员列表。 +3. 治理配置查看。 +4. 入群申请、邀请、公告、禁言、管理日志列表。 +5. 治理配置更新。 +6. 全员禁言开关。 +7. 单人禁言/解禁。 +8. 解散群。 +9. 入群审批通过/拒绝。 +10. 设置/取消管理员。 +11. 转让群主。 +12. 群文件列表和删除处置。 + +待 IM 服务端配合确认: + +1. 后台写入 `im_group_events_outbox` 后,IM 服务端消费者需同步在线会话、群资料缓存和成员 ACL。 +2. 群文件删除当前为业务索引软删除,底层对象文件物理清理归入 P1-2 文件治理。 + +### P1-2 文件治理 + +状态:部分实现。 + +已实现: + +1. 文件列表。 +2. 文件用途、大小、状态查询。 +3. 隔离、删除。 +4. 文件详情查看审计。 +5. 孤儿文件手动清理。 +6. 孤儿文件定时任务 `imFileCleanupOrphan`,参数为保留天数、单租户处理上限。 +7. 租户级文件用途策略配置:大小上限、保留天数、MIME 白名单、自动清理开关、病毒扫描开关。 + +待实现/配合: + +1. 下载审计:需要 IM 服务端文件下载链路或后台下载代理在实际下载时写入审计。 +2. 底层对象物理删除:当前后台将 `im_fileuploads.status` 标记为已删除,物理对象清理需由文件服务或 IM 服务端清理器消费 `location`。 +3. 过期文件执行:后台已提供策略配置,IM 服务端/文件清理器需读取 `im_file_policy` 后执行过期扫描、状态更新和物理清理。 +4. 文件用途识别目前基于 `im_filemsglinks`/群文件索引做基础归类,头像、举报证据、反馈附件等用途需要 IM 服务端补齐绑定关系或独立索引。 +5. 病毒扫描执行:后台已提供开关,实际扫描任务、扫描结果回写和用户通知需要 IM 服务端或文件服务承接。 + +### P1-3 外部服务配置 + +状态:部分实现。 + +已实现: + +1. SMS 服务商配置。 +2. Push/APNS/FCM 配置。 +3. TURN/STUN 配置。 +4. 对象存储配置。 +5. 审核插件配置。 +6. 敏感字段 `secret_json` 只写入,不明文回显,仅返回 `hasSecret`。 +7. 基础配置完整性检查,写入 `status`、`checked_at`、`check_result`。 +8. 配置变更和检查写入 IM 后台审计。 + +待实现/配合: + +1. 真实连通性检查:需要各服务商 SDK 或 IM 服务端提供探测接口。 +2. IM 服务端 `bootstrap`、短信、Push、TURN、对象存储、审核链路需要读取 `im_external_config`。 +3. `im_external_config` 为新增控制库表,需要执行 `server/storage/data/generate/im_external_config_menu.sql`。 + +### P1-4 任务看板 + +状态:部分实现。 + +已实现: + +1. 只读任务看板,按租户汇总 IM 业务库健康状态。 +2. Outbox 巡检:统计业务事件和群事件的待投递、失败数量。 +3. 文件清理巡检:统计孤儿文件和待物理清理文件数量。 +4. 展示最新业务事件、最新群事件、最新文件更新时间。 +5. 根据失败/待处理/待清理数量给出基础建议动作。 + +待实现/配合: + +1. 主动执行任务:当前看板只读,不直接触发修复或调度。 +2. Push 重试:需要 IM 服务端暴露 Push 失败队列、重试入口或统一任务表。 +3. 搜索索引重建:需要 IM 服务端定义索引任务入口、进度查询和失败重试模型。 +4. 邀请码投影补偿:需要 IM 服务端或后台定义投影任务表、幂等键和重放策略。 +5. 文件物理清理任务详情:需要文件服务或 IM 服务端回写物理删除结果。 +6. 跨租户任务执行审计:执行型任务落地后需补充独立权限点和审计明细。 + +### P1-5 系统通知模板 + +状态:部分实现。 + +已实现: + +1. 租户级通知模板列表。 +2. 账号安全、好友申请、群事件、文件事件、会议提醒、日报月报提醒、工单处理等默认场景初始化。 +3. 站内通知、Push、短信渠道开关。 +4. 标题模板、内容模板、扩展 JSON 配置。 +5. 模板启用/禁用。 +6. 模板变更写入 IM 后台审计。 + +待实现/配合: + +1. 实际投递:需要 IM 服务端 Outbox 消费器或系统 Topic 桥接读取模板并发送。 +2. 投递记录:需要 IM 服务端回写站内、Push、短信投递状态和失败原因。 +3. 模板变量校验:当前仅校验非空和 JSON 格式,变量白名单、缺失变量检查需要按具体场景定义。 +4. 多语言模板:当前为单语言模板,后续如客户端支持多语言需扩展语言维度。 + +## 后续 P2 + +### P2-1 工作台模块配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户工作台模块列表、搜索。 +2. 模块新增、编辑、启用/禁用。 +3. 模块排序接口和后台入口。 +4. 默认初始化组织架构、会议通知、日报、月报、总经理信箱五个入口。 +5. 路由选项对齐 IM 服务端当前白名单,避免配置后客户端接口丢弃。 +6. 工作台配置变更写入 IM 后台审计。 + +待 IM 服务端/客户端配合确认: + +1. `GET /v1/im/workbench` 当前读取 `im_workbench_apps` 已具备,需在实际环境确认租户库连接与后台修改后的缓存刷新策略。 +2. 用户或角色级工作台权限表 `im_workbench_user_apps` 尚未接入后台,当前只覆盖租户级模块配置。 +3. 未读角标实时事件 `workbench.badge.updated` 的聚合和增量推送仍由 IM 服务端承接。 +4. 新增自定义入口若超出当前五个路由,需要先扩展 IM 服务端路由白名单和客户端页面。 + +### P2-2 App 版本更新策略 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户级客户端版本策略列表、搜索。 +2. 按 `platform + channel` 配置最新版本、最低支持版本、更新类型、商店地址、安装包地址、校验值和再次提醒时间。 +3. 默认初始化 iOS/Android stable 策略。 +4. 配置变更写入 IM 后台审计。 + +待 IM 服务端/客户端配合确认: + +1. `GET /v1/im/config/app-version` 已有 IM 服务端读取链路,需确认实际环境租户库数据和客户端版本比较规则。 +2. 灰度比例、分渠道灰度名单、强更弹窗样式尚未建模。 + +### P2-3 举报原因配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 按用户、群组、消息三类对象配置举报原因。 +2. 支持原因新增、编辑、禁用、排序、是否要求描述、是否允许上传证据。 +3. 默认初始化诈骗、骚扰、垃圾广告、违法违规等基础原因。 +4. 配置变更写入 IM 后台审计。 + +待实现/配合: + +1. 举报工单 SLA、合并策略、批量导出仍未实现。 +2. 举报工单处置结果与客户端通知联动仍由后续工单模块增强。 + +### P2-4 总经理信箱配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户级总经理信箱启用/禁用。 +2. 匿名提交开关。 +3. 必填字段、最大字数、附件策略配置。 +4. 告知标题和告知内容配置,更新时同步刷新 `notice_updated_at`。 +5. 配置变更写入 IM 后台审计。 + +待实现/配合: + +1. 信箱提交列表、处理状态、回复闭环尚未实现。 +2. 受理人/部门、SLA、匿名身份展示范围尚未建模。 + +### P2-5 意见反馈配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户级意见反馈分类配置。 +2. 最大字数、附件策略、联系方式开关、诊断信息提示配置。 +3. 配置变更写入 IM 后台审计。 + +待实现/配合: + +1. 反馈提交列表、合并、回复、导出尚未实现。 +2. 客户端分类文案如需多语言,需要扩展分类结构。 + +### P2 待实现 + +1. 会议。 +2. 日报/月报。 +3. 总经理信箱提交处理闭环。 +4. 青少年模式。 +5. 反诈与内容审核策略。 +6. 举报/反馈 SLA、合并、导出。 +7. 实名审核策略配置。 + +## 验收提示 + +1. 所有后台租户业务操作必须通过登录态解析租户,不能信任浏览器传入的 `tenantId`。 +2. 平台管理员跨租户操作必须选择租户并写审计。 +3. 租户管理员隐藏租户选择器,后端使用绑定租户。 +4. 所有新增按钮必须有独立菜单权限。 +5. 前端字典、状态、下拉选项必须从后端字典加载。 +6. 敏感字段不得在日志、审计、导出文件中明文出现。 diff --git a/docs/im/IM-前后端对接文档.md b/docs/im/IM-前后端对接文档.md index b416f68..9a28d68 100644 --- a/docs/im/IM-前后端对接文档.md +++ b/docs/im/IM-前后端对接文档.md @@ -2,8 +2,8 @@ > 版本:v2.0 > 日期:2026-08-11 -> 需求依据:[IM 产品原型需求文档](IM-产品原型需求文档.md) -> 设计依据:[IM 系统设计文档](IM-系统设计文档.md) +> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md) +> 设计依据:[IM 系统设计文档](./IM-系统设计文档.md) > 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。 ## 0. 公共对接约定 @@ -29,7 +29,7 @@ API Key 是应用级接入标识,由部署人员在发布客户端前生成并 1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 -3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 +3. 输出中的 `HMAC salt` 配置到服务端 `server/meetingim.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 生成示意: @@ -44,7 +44,7 @@ API Key、企业码和用户 Token 是三个不同概念: | 值 | 取得时间 | 作用 | 是否代表用户身份 | | --- | --- | --- | --- | -| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | +| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 MeetingIM 入口 | 否 | | `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | | `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | @@ -57,11 +57,11 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或 | 通道 | 地址 | 认证 | 用途 | | --- | --- | --- | --- | | WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | -| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 | -| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | -| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token ` | 文件上传下载;禁止 Query 传 Token | +| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 | +| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | +| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token ` | 文件上传下载;禁止 Query 传 Token | -HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 +HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: @@ -135,6 +135,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 ```text 读取客户端预置的 host 和普通 API Key -> 用户输入/客户端读取 tenant_code +-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 -> 带 API Key 完成 WebSocket Upgrade -> hi(tenant_code) -> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 @@ -146,7 +147,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 -> 进入可发送状态 ``` -当前服务端没有 `/v1/im/tenants/resolve`,因此现阶段企业码是否有效是在第一帧 `{hi}` 中校验的。只有 WebSocket Upgrade 返回 `101 Switching Protocols` 后才能发送下面的 JSON 文本帧。 +当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 `hi` 请求: @@ -164,7 +165,9 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 {"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}} ``` -`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 +`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer `,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 + +当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。 `hi` 成功示例: @@ -218,7 +221,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 ```http POST /v0/channels/lp HTTP/1.1 Host: im.example.com -X-Tinode-APIKey: <预置的普通 API Key> +X-MeetingIM-APIKey: <预置的普通 API Key> Content-Length: 0 ``` @@ -232,7 +235,7 @@ Content-Length: 0 ```http POST /v0/channels/lp?sid=session-id HTTP/1.1 -X-Tinode-APIKey: <预置的普通 API Key> +X-MeetingIM-APIKey: <预置的普通 API Key> Content-Type: application/json {"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} @@ -242,16 +245,16 @@ POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 ```http GET /v0/channels/lp?sid=session-id HTTP/1.1 -X-Tinode-APIKey: <预置的普通 API Key> +X-MeetingIM-APIKey: <预置的普通 API Key> ``` 收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 `sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。 -#### 0.5.3 目标产品流程 +#### 0.5.3 企业解析与产品流程 -第 1.1 节规划的 `POST /v1/im/tenants/resolve` 属于新增接口。实现后目标流程调整为: +第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为: ```text 读取预置 API Key @@ -261,7 +264,169 @@ X-Tinode-APIKey: <预置的普通 API Key> -> login -> sub me -> 恢复会话 ``` -企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。 +企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。 + +#### 0.5.4 WSS 契约输入输出示例 + +本节用于 WebSocket 联调。除特别说明外: + +- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。 +- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。 +- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。 +- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。 +- 成功控制包一般为 `{ctrl.code=2xx}`;失败控制包一般为 `{ctrl.code=4xx/5xx}`,产品稳定错误优先读 `ctrl.params.reason`,没有时按 `code/text` 兜底。 + +##### 0.5.4.1 连接、握手、登录和注册 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| WebSocket Upgrade | `GET /v0/channels?apikey=AQ...` | HTTP `101 Switching Protocols`;API Key 无效时 HTTP `403` | +| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` | +| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | +| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` | + +##### 0.5.4.2 启动订阅、会话列表和增量恢复 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| 订阅本人 `me` | `{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}` | `{"ctrl":{"id":"sub-me-1","code":200,"text":"ok"}}`,随后可能收到 `{"meta":{"id":"sub-me-1","topic":"me","desc":{"public":{"fn":"alice"}},"sub":[{"topic":"grp...","public":{"name":"项目群"},"seq":12,"read":10,"recv":12,"updated":"2026-08-20T09:00:00Z"}]}}` | +| 订阅查找 `fnd` | `{"sub":{"id":"sub-fnd-1","topic":"fnd"}}` | `{"ctrl":{"id":"sub-fnd-1","code":200,"text":"ok"}}` | +| 订阅会话并拉历史 | `{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":1,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"ctrl":{"id":"sub-topic-1","code":200,"text":"ok"}}`,随后 `{"meta":{"id":"sub-topic-1","topic":"grp...","desc":{"public":{"name":"项目群"},"seq":12},"sub":[{"user":"usr...","mode":"JRWP"}]}}` 和多条 `{data}` | +| 断线恢复增量 | `{"sub":{"id":"resume-grp-1","topic":"grp...","get":{"what":"data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"data":{"topic":"grp...","seq":125,"from":"usr...","ts":"2026-08-20T09:00:00Z","content":{"text":"恢复后的第一条"}}}` | +| 只拉元数据 | `{"get":{"id":"get-topic-1","topic":"grp...","what":"desc sub"}}` | `{"meta":{"id":"get-topic-1","topic":"grp...","desc":{"seq":12,"public":{"name":"项目群"}},"sub":[{"user":"usr...","read":10,"recv":12}]}}` | +| 离开/取消订阅 | `{"leave":{"id":"leave-1","topic":"grp...","unsub":false}}` | `{"ctrl":{"id":"leave-1","code":200,"text":"ok"}}` | + +##### 0.5.4.3 发送消息、媒体卡片、名片和转发卡片 + +| 类型 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | +| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` | +| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` | +| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` | +| 名片消息 | `{"pub":{"id":"pub-card-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"contact_card","x-im-client-mid":"mid_card_1"},"content":{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan"}}}` | `{"ctrl":{"id":"pub-card-1","code":202,"params":{"seq":16}}}` | +| 合并转发卡片 | `{"pub":{"id":"pub-fwd-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"forward_bundle","x-im-client-mid":"mid_fwd_1"},"content":{"bundle_id":"fbundle_1","title":"聊天记录","item_count":3,"preview":[{"from_display":"张三","summary":"你好"}]}}}` | `{"ctrl":{"id":"pub-fwd-1","code":202,"params":{"seq":17}}}` | +| @成员消息 | `{"pub":{"id":"pub-mention-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_at_1","mentions":[{"user_id":"usr...","offset":0,"length":3}]},"content":{"text":"@张三 看一下"}}}` | `{"ctrl":{"id":"pub-mention-1","code":202,"params":{"seq":18}}}` | +| @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` | +| 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 | + +##### 0.5.4.4 回执、正在输入、删除和清空记录 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 送达回执 | `{"note":{"topic":"p2p...","what":"recv","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"recv","seq":12}}` | +| 已读回执 | `{"note":{"topic":"p2p...","what":"read","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"read","seq":12}}`;客户端本地把该 topic `read` 更新为 12 | +| 正在输入 | `{"note":{"topic":"p2p...","what":"kp"}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"kp"}}`,本地超时消失 | +| 正在录音可选 | `{"note":{"topic":"grp...","what":"kpa"}}` | 对端收到 `{"info":{"topic":"grp...","from":"usr...","what":"kpa"}}`,不落库 | +| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` | +| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` | + +##### 0.5.4.5 Topic 资料、个人群设置和标签 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 更新群公开资料 | `{"set":{"id":"set-grp-1","topic":"grp...","desc":{"public":{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2}}}}` | `{"ctrl":{"id":"set-grp-1","code":200,"text":"ok"}}`;成员收到 `{"pres":{"topic":"grp...","what":"upd"}}` | +| 更新个人资料 | `{"set":{"id":"set-me-1","topic":"me","desc":{"public":{"fn":"新昵称","avatar_file_id":"file_avatar"}}}}` | `{"ctrl":{"id":"set-me-1","code":200,"text":"ok"}}` | +| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` | +| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` | + +##### 0.5.4.6 系统通知、好友和群业务下行 + +服务端主动下行分两类:MeetingIM 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。 + +用户维度业务事件统一格式: + +```json +{ + "info": { + "topic": "usr_target", + "from": "usr_actor", + "what": "im", + "event": "friend.request.accepted", + "payload": { + "user_id": "usr_actor" + }, + "ts": "2026-08-25T10:00:00Z" + } +} +``` + +群维度业务事件统一格式: + +```json +{ + "info": { + "topic": "grp_xxx", + "from": "usr_actor", + "what": "group", + "event": "group.member.joined", + "payload": { + "topic": "grp_xxx", + "user_id": "usr_xxx" + }, + "ts": "2026-08-25T10:00:00Z" + } +} +``` + +| 事件 | 接收方 | 服务端下行示例 | 客户端动作 | +| --- | --- | --- | --- | +| 好友申请创建 | 被申请人 | `{"info":{"topic":"usr_target","from":"usr_applicant","what":"im","event":"friend.request.created","payload":{"user_id":"usr_applicant"}}}` | 刷新 `GET /v1/im/friend-requests` 和 `GET /v1/im/bootstrap` 的新朋友未处理数;系统通知列表也会新增记录 | +| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 | +| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 | +| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 | +| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context | +| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 | +| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 | +| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 | +| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 | +| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 | +| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 | + +业务事件写入 outbox 后会立即尝试投递;Hub 不可用或队列满时保持 pending,后台 poller 按在线租户重试。`registration.invite_used` 和 `security.password_reset` 是服务端内部消费事件,不作为普通业务事件下发给 App。 + +##### 0.5.4.7 WebRTC 通话信令 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 发起语音通话 | `{"pub":{"id":"call-pub-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call","x-im-client-mid":"mid_call_1"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-pub-1","code":202,"text":"accepted","params":{"seq":30}}}`;被叫收到同一 `{data seq=30}` | +| 被叫响铃 | `{"note":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | 主叫收到 `{"info":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | +| 被叫接听 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":30,"payload":{"callId":"call_1"}}}` | 双方收到通话状态 `accept`;原消息可被服务端替换为 `head.webrtc:"accepted"` | +| SDP offer | `{"note":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | +| SDP answer | `{"note":{"topic":"p2p...","what":"call","event":"answer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `event:"answer"` | +| ICE candidate | `{"note":{"topic":"p2p...","what":"call","event":"ice-candidate","seq":30,"payload":{"callId":"call_1","candidate":{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}}}}` | 对端收到 `event:"ice-candidate"` | +| 挂断/取消 | `{"note":{"topic":"p2p...","what":"call","event":"hang-up","seq":30,"payload":{"callId":"call_1","reason":"cancelled"}}}` | 服务端替换原通话消息:`{"data":{"topic":"p2p...","seq":30,"head":{"replace":":30","webrtc":"missed","webrtc-duration":0,"x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","status":"missed"}}}` | +| 异常事件 | `{"note":{"topic":"p2p...","what":"call","event":"media-state","seq":30,"payload":{"callId":"call_1"}}}` | 当前未开放时返回 `{"ctrl":{"code":400,"text":"malformed","params":{"reason":"CALL_EVENT_UNSUPPORTED"}}}` 或直接忽略,客户端不要提前依赖 | + +##### 0.5.4.8 WSS 错误、重试和客户端处理 + +| 场景 | 服务端返回示例 | 客户端处理 | +| --- | --- | --- | +| API Key 无效 | Upgrade HTTP `403 {"ctrl":{"code":403,"text":"valid API key required"}}` | 停止重连,上报环境配置错误 | +| 未先 `hi` 就登录 | `{"ctrl":{"id":"login-1","code":400,"text":"tenant required","params":{"reason":"TENANT_REQUIRED"}}}` | 关闭连接,从 Upgrade + `hi` 重来 | +| 登录失败 | `{"ctrl":{"id":"login-1","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | 清密码,保留账号,显示统一账号或密码错误 | +| Token 过期 | `{"ctrl":{"id":"login-token-1","code":401,"text":"authentication failed","params":{"reason":"TOKEN_EXPIRED"}}}` | 清当前租户 token,回登录页 | +| 无 Topic 权限 | `{"ctrl":{"id":"pub-1","code":403,"text":"permission denied","params":{"reason":"MESSAGE_NOT_ALLOWED"}}}` | 发送气泡置为失败,不伪装已发送 | +| 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 | +| 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 | + + +#### 0.5.5 Long Polling 与文件基础示例 + +##### 0.5.5.1 WebSocket 与文件基础接口 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` | +| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | +| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | +| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token `;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | +| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token `,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | ### 0.6 Topic 与产品消息 @@ -294,7 +459,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 -3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 +3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。 4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 @@ -304,19 +469,19 @@ X-Tinode-APIKey: <预置的普通 API Key> 企业码登录 -**状态:新增企业解析接口;WebSocket 租户握手已实现。** +**状态:企业解析接口与 WebSocket 租户握手均已实现。** **当前系统的执行方式:** - 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 -- 客户端从构建/环境配置读取 `host` 和普通 API Key,使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 +- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 - Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 - 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 - 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 -**目标产品新增方式:** +**企业解析接口:** -- 新增 `POST /v1/im/tenants/resolve`,未登录但必须带 `X-Tinode-APIKey`,Header 传 `X-Tenant-Code: acme`;Body 为 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 +- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 @@ -342,16 +507,121 @@ X-Tinode-APIKey: <预置的普通 API Key> } ``` +### 1.1.1 企业注册-登录入口.png + +企业注册-登录入口 + +**状态:已实现。** + +- 页面入口:企业码登录页右上角展示“企业注册”,底部展示“遇到问题?”。两个入口均为未登录入口,但必须使用客户端预置的普通 API Key,不需要用户 Token。 +- 点击“企业注册”:进入 1.1.2 企业资料提交流程。客户端保留当前已输入的 `tenant_code` 作为可选线索,但不得把它当成已解析企业身份。 +- 点击“遇到问题?”:进入 1.1.4 问题反馈页。若用户已输入企业码,提交反馈时带 `tenant_code_hint`;未输入时可为空。 +- 埋点和日志:可记录入口点击事件,但不得记录 API Key、完整联系方式、证照图片 URL 或上传文件真实地址。 +- 入口展示:入口文案必须避开系统状态栏、安全区域和键盘遮挡;网络不可用时仍允许进入填写页,提交时再提示网络错误。 + +### 1.1.2 企业注册-填写注册信息.png + +企业注册-填写注册信息 + +**状态:已实现。** + +- 页面步骤:展示“填写企业信息、确定信息、提交审核”三步,当前处于第 1 步。提交前客户端在本页完成格式校验,服务端仍做最终校验。 +- 必填字段:`credit_code` 18 位统一社会信用代码,客户端仅去掉首尾空格并转大写展示;提交值必须匹配 `^[0-9A-Z]{18}$`。不得提交中文、空格或短码。 +- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。 +- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。 +- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。 +- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 +- 请求体: + +```json +{ + "credit_code": "91310000MA1K000000", + "enterprise_email": "admin@example.com", + "license_file_id": "file_01K...", + "tenant_code_hint": "acme", + "client_context": { + "platform": "android", + "app_version": "1.0.0", + "device_model": "Pixel 8" + } +} +``` + +- 成功响应:服务端创建企业注册申请单,状态为 `received` 或 `pending_review`。客户端进入 1.1.3,不得重复提交同一个幂等键。 + +```json +{ + "application_id": "entapp_01K...", + "status": "received", + "estimated_review_hours": 24, + "submitted_at": "2026-08-24T08:30:15.123Z" +} +``` + +- 失败处理:`INVALID_CREDIT_CODE` 显示统一社会信用代码格式错误;`INVALID_EMAIL` 定位邮箱输入;`FILE_UNAVAILABLE` 允许重新上传;`APPLICATION_DUPLICATED` 提示已有申请正在审核;`RATE_LIMITED` 按服务端退避时间禁用提交按钮。 + +### 1.1.3 企业注册-提交成功提示.png + +企业注册-提交成功提示 + +**状态:已实现。** + +- 触发条件:1.1.2 提交接口返回成功后,在当前页展示 Toast:“提交成功,我们会尽快联系你”。 +- 状态保存:客户端保存 `application_id,status,submitted_at,estimated_review_hours` 到本地短期缓存,用于返回页面时展示“已提交”状态。该缓存不代表审核结果,最终状态以服务端和邮件/通知为准。 +- 防重复:提交成功后禁用提交按钮;用户返回后再次进入企业注册页,可显示已提交提示并允许重新填写新申请,但必须使用新的 `Idempotency-Key`。 +- 审核通知:审核结果默认通过 `enterprise_email` 触达;如果后续后台接入站内通知或短信,客户端只按服务端下发的公开状态展示,不自行推断审核通过。 +- 文案规则:页面底部审核时长应使用服务端返回的 `estimated_review_hours` 或后台配置,不写死。 + +### 1.1.4 企业注册-问题反馈.png + +企业注册-问题反馈 + +**状态:已实现。** + +- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。 +- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。 +- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。 +- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 +- 请求体: + +```json +{ + "description": "企业码 acme 提示不可用,无法进入企业。", + "contact": "user@example.com", + "tenant_code_hint": "acme", + "screenshot_file_ids": ["file_01K..."], + "client_context": { + "platform": "ios", + "app_version": "1.0.0", + "network": "wifi" + } +} +``` + +- 成功响应:客户端展示“已收到反馈,我们会尽快处理”,并清空草稿和已提交截图引用。 + +```json +{ + "feedback_id": "entryfb_01K...", + "status": "received", + "submitted_at": "2026-08-24T08:35:15.123Z" +} +``` + +- 失败处理:`DESCRIPTION_REQUIRED` 或 `DESCRIPTION_TOO_LONG` 定位描述框;`TOO_MANY_FILES` 提示最多 9 张;`FILE_UNAVAILABLE` 提示重新上传失效图片;`RATE_LIMITED` 禁用提交并展示稍后重试。 + ### 1.2 登录页面.png 登录页面 -**状态:基本账号密码登录已实现;协议勾选和安全 Token 生命周期需改造。** +**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** - 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 -- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 -- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token。 -- 待改造:登录成功后签发短期 access token 与可轮换 refresh token;Token 绑定 `tenant_id/user_id/auth_version/device_id`。修改密码、封禁账号后旧 Token 失效。 +- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 +- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 +- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer `;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 +- Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 +- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 登录成功示例: @@ -389,17 +659,42 @@ X-Tinode-APIKey: <预置的普通 API Key> 注册页面 -**状态:Tinode `{acc}` 基础注册已实现;短信预校验与租户配额需改造。** +**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** -- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138****0000"}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 -- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 -- WebSocket:把一次性验证结果放在 `{acc}.cred`,不得写入 `desc.private`。服务端必须在当前 Session 租户内消费该 Token。 +- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 +- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 +- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 +- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 +- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 +- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 +- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 +- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。 +- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。 +- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 +- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 +- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 +- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。 +- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 + +用户名方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(username:password)","login":true,"tags":["tel:+86138..."],"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} ``` -- 成功:返回新 `user` 和登录 Token,自动 `sub me`。创建用户前原子校验 `im_tenant_config.max_user_count`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 -- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 + +手机号验证方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` + +仅手机号注册方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` +- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 +- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 ### 1.5 注册页面-倒计时.png @@ -417,11 +712,14 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** +- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。 - 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。 - 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。 - 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。 -- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 +- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。 +- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。 - 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 +- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。 ### 1.7 身份验证(安卓).png @@ -442,7 +740,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。 - 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。 -- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,通过 `GET /v1/im/security/realname` 或 `security.realname.updated` 系统事件更新状态。 +- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,当前以 `GET /v1/im/security/realname` 查询状态为准。后续如启用 `security.realname.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。 - 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 ### 1.9 实名认证-已实名.png @@ -476,6 +774,33 @@ X-Tinode-APIKey: <预置的普通 API Key> - 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 - 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 +### 1.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 1.12.1 未登录、企业与账号安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` | +| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` | +| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` | +| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` | +| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | +| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | +| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` | +| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` | + ## 2. 消息首页、全局导航与系统通知 ### 2.1 消息.png @@ -486,7 +811,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。 - 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。 -- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限和系统 Topic;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 +- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限、系统 Topic、`entry_badges` 和 `entries`;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 - 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 ### 2.2 消息-无网络状态.png @@ -517,7 +842,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:基础消息操作部分已实现;产品权限矩阵需改造。** - 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 -- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 +- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 MeetingIM `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 - 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 - 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 @@ -527,11 +852,30 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:需改造。** -- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 +- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。 - 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 - 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 +### 2.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 2.6.1 登录后启动、消息首页与系统通知 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | +| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` | + ## 3. 单聊会话、会话建立与消息状态 ### 3.1 个人对话框(对方的状态).png @@ -552,7 +896,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 - `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 -- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 +- 好友关系变化由服务端生成 0.5.4.6 定义的 WS 业务事件,客户端不得根据本地动作自行永久插入系统通知记录。 ### 3.3 个人对话框(我的状态).png @@ -580,7 +924,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增好友审批与 Topic 联动。** -- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 +- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。 - 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 - 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 @@ -651,7 +995,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。 - `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。 -- 点击卡片调用 `GET /v1/im/users/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 +- 点击卡片调用 `GET /v1/im/users/{identifier}/profile`;`identifier` 可传 `user_id`、`username` 或 `public_account`,用户不可见或已离职时显示失效卡片,不跨租户查询。 ## 4. 消息输入、媒体内容与消息操作 @@ -846,6 +1190,36 @@ X-Tinode-APIKey: <预置的普通 API Key> - 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 - 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 +### 4.21 会话与消息接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 4.21.1 会话上下文、消息操作与检索 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` | +| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | +| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | +| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | +| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | +| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","file":{"file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf"},"availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` | +| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | + +消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。 + ## 5. 通讯录、好友关系与黑名单 ### 5.1 通讯录.png @@ -855,8 +1229,9 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增产品通讯录接口。** - 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 - 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 -- 新朋友未处理数、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 返回。列表缓存以租户和用户隔离。 +- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 ### 5.2 新朋友.png @@ -867,6 +1242,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 +- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 ### 5.3 添加好友.png @@ -885,7 +1261,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 -- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 +- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。 - 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 ### 5.5 添加好友-输入精准用户ID.png @@ -914,7 +1290,8 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** -- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 +- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。 +- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 @@ -984,21 +1361,54 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** -- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 +- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 - 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 +### 5.15 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 5.15.1 通讯录、好友、黑名单与二维码 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/friends?query=&initial=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","initial":"Z","topic":"p2p...","relationship":"friend","online_visibility":"unknown","capabilities":{"can_send":true,"can_call":true}}],"next_cursor":""}` | +| `GET /v1/im/friends?query=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","topic":"p2p...","relationship":"friend"}],"next_cursor":""}` | +| `GET /v1/im/friend-requests?direction=inbound&status=all&cursor=` | Query only | `{"items":[{"request_id":"frq_1","direction":"inbound","applicant_id":"usr_a","target_user_id":"usr_b","peer":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","source":"search","status":"pending","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/friend-requests` | `{"target_user_id":"usr...","message":"验证留言","source":"search","source_topic":""}` | `{"request_id":"frq_1","applicant_id":"usr_me","target_user_id":"usr...","message":"验证留言","source":"search","status":"pending","topic":"p2p...","created_at":"2026-08-20T09:00:00Z","expires_at":"2026-08-27T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/accept` | `{"version":1}` | `{"request_id":"frq_1","status":"accepted","topic":"p2p...","handled_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | +| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` | +| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `PUT /v1/im/blocks/{user_id}` | `{}` | `{"user_id":"usr...","blocked":true,"blocked_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/blocks/{user_id}` | Path user ID | `{"user_id":"usr...","blocked":false}` | +| `POST /v1/im/blocks/batch-delete` | `{"user_ids":["usr_a","usr_b"]}` | `{"items":[{"user_id":"usr_a","blocked":false}],"failed":[]}` | +| `POST /v1/im/me/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三"}}` | +| `POST /v1/im/users/{identifier}/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三","public_account":"zhangsan"}}` | +| `POST /v1/im/qrcodes/resolve` | `{"token":"token...","purpose":"add_friend"}` | `{"purpose":"add_friend","user":{"user_id":"usr...","display_name":"张三"},"relationship":"none","can_add":true}` | + ## 6. 群聊创建、资料维护与成员治理 ### 6.1 群聊.png 群聊 -**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。** +**状态:MeetingIM 群 Topic 已实现基础能力;产品群列表与配额需改造。** - 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 -- 创建:`POST /v1/im/groups`,Body `{"name":"...","member_user_ids":[],"avatar_file_id":"..."}`;原子校验租户群数上限和群成员上限,返回 `topic`。 -- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 +- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。 ### 6.2 空白页.png @@ -1015,7 +1425,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增邀请卡片和加入接口。** -- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 +- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。 - 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 - 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 @@ -1026,8 +1436,8 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增群成员事件。** - 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 -- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 -- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 +- 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。 +- 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 ### 6.5 邀请进群申请.png @@ -1056,7 +1466,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 - 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 -- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 +- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 ### 6.8 群公告状态/邀请进群申请同时存在.png @@ -1240,7 +1650,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 -- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 +- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成 `group.owner.transferred` 群业务事件。 - 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 ### 6.27 群文件.png @@ -1263,6 +1673,61 @@ X-Tinode-APIKey: <预置的普通 API Key> - 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 +### 6.29 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 6.29.1 群聊、群成员与群治理 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/groups?category=owned&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"owner","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `GET /v1/im/groups?category=owned\|managed\|joined&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"admin","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` | +| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` | +| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` | +| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` | +| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` | +| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | +| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | +| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | +| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | +| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | +| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | +| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` | +| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` | +| `GET /v1/im/groups/{topic}/announcements/latest` | Path topic | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":1,"published_at":"2026-08-20T09:00:00Z","permissions":{"can_edit":true}}` | +| `PUT /v1/im/groups/{topic}/announcements/current` | `{"content":"新的公告","version":1}` | `{"announcement_id":"ann_1","topic":"grp...","content":"新的公告","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/announcements/{announcement_id}` | Path announcement | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/announcements/{id}/read` | `{}` | `{"announcement_id":"ann_1","read_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/governance` | Path topic | `{"join_approval":false,"mute_all":false,"member_upload_files":true,"member_private_chat":true,"member_invite":true,"version":1}` | +| `PATCH /v1/im/groups/{topic}/governance` | `{"join_approval":true,"member_invite":false,"version":1}` | `{"join_approval":true,"member_invite":false,"version":2}` | +| `GET /v1/im/groups/{topic}/role-limits` | Path topic | `{"max_admin_count":10,"current_admin_count":1}` | +| `GET /v1/im/groups/{topic}/members?role=admin&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr_admin","display_name":"管理员","role":"admin"}],"next_cursor":""}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","role":"member","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/admins/batch` | `{"user_ids":["usr..."],"version":1}` | `{"succeeded":[{"user_id":"usr...","role":"admin"}],"admins":[{"user_id":"usr...","role":"admin"}],"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/admins/{user_id}` | Header `If-Match: 2` 或 Body version | `{"user_id":"usr...","role":"member","updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/mutes/batch` | `{"members":[{"user_id":"usr...","expires_at":null}],"reason":"刷屏"}` | `{"topic":"grp...","items":[{"user_id":"usr...","muted":true,"muted_at":"2026-08-20T09:00:00Z","expires_at":null}]}` | +| `GET /v1/im/groups/{topic}/mutes?cursor=` | Query only | `{"topic":"grp...","items":[{"user":{"user_id":"usr...","display_name":"张三"},"muted_by":"usr_admin","muted_at":"2026-08-20T09:00:00Z","expires_at":null}],"next_cursor":""}` | +| `DELETE /v1/im/groups/{topic}/mutes/{user_id}` | Path user | `{"user_id":"usr...","muted":false}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/owner-transfer` | Header `X-Action-Token: action_token`;Body `{"target_user_id":"usr...","version":2}` | `{"owner":"usr...","previous_owner":"usr_old","version":3}` | +| `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 待修复 | Query only | `{"topic":"grp...","items":[{"group_file_id":"gfile_1","file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf","uploader":{"user_id":"usr...","display_name":"张三"},"created_at":"2026-08-20T09:00:00Z","download_state":"available","permissions":{"can_delete":true}}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/files` 待修复 | `{"file_id":"file_1"}` | `{"group_file_id":"gfile_1","file_id":"file_1","topic":"grp...","created_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 待修复 | Path group file ID | `{"group_file_id":"gfile_1","deleted":true}` | + ## 7. 文件下载、收藏管理与聊天记录检索 ### 7.1 我的收藏.png @@ -1347,7 +1812,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:底层下载已实现租户校验;资源 ACL 需改造。** -- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token `;禁止 `?auth=&secret=`。 +- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token `;禁止 `?auth=&secret=`。 - 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 @@ -1359,7 +1824,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: `。 - ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 -- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 +- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。 ### 7.11 文件-下载完成.png @@ -1416,6 +1881,32 @@ X-Tinode-APIKey: <预置的普通 API Key> - 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 - 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 +### 7.17 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 7.17.1 收藏、转发与文件安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/favorites?type=all&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"text","snapshot":{"text":"收藏内容"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/favorites?type=all\|text\|image\|video\|voice\|file\|link&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"image","snapshot":{"thumbnail_file_id":"file_thumb","file_id":"file_img"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `POST /v1/im/favorites/batch` | `{"messages":[{"topic":"grp...","seq":"12"}]}` | `{"created_count":1,"existing_count":0,"failed":[]}` | +| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` | +| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` | +| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` | +| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` | +| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/messages/forward` 待实现 | `{"mode":"separate","sources":[{"topic":"grp...","seq":"12"}],"target_topics":["p2p..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":"12"},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}` | +| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` | +| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` | + ## 8. 实时语音与视频通话 ### 8.1 各种通话提示状态.png @@ -1444,6 +1935,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** +- 音视频入口先看 `GET /v1/im/bootstrap` 的 `features.audio_call/video_call` 和 `permissions.audio_call/video_call`;这两个值来源于 Admin 后台现有配置读取链路 `im_client_feature` 分组中的 `audio_call_enabled`、`video_call_enabled`,与声网参数使用同一个配置读取方法,但不放在声网分组。具体到某个会话或用户时,再看 `capabilities.can_call`。任一为 `false` 时前端不展示对应语音/视频按钮。 - 发起前查询 `capabilities.can_call`,再发布持久邀请消息: ```json @@ -1527,6 +2019,25 @@ X-Tinode-APIKey: <预置的普通 API Key> - 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 - 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 +### 8.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 8.12.1 实时通话与通话记录 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` | +| WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | +| `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` | +| `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | + ## 9. 企业工作台与办公协同 ### 9.1 工作台.png @@ -1536,8 +2047,9 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 -- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 +- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 ### 9.2 部门组织框架.png @@ -1548,6 +2060,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 - 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 - 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 +- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。 ### 9.3 会议通知-发起人状态.png @@ -1558,6 +2071,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 - 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。 - 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。 +- 主动下行:参会人收到 `info.what="im"`,事件为 `meeting.created/updated/canceled/reminded/response.updated`,payload 包含 `meeting_id,version,state`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。 ### 9.4 会议通知-接收人状态.png @@ -1566,7 +2080,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 -- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 +- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。 - 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 ### 9.5 日报.png @@ -1657,6 +2171,51 @@ X-Tinode-APIKey: <预置的普通 API Key> - 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 - 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 +### 9.14 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 9.14.1 工作台、组织、会议、日报、月报、信箱 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"meetings":true}}}` | +| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` | +| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` | +| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` | +| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` | +| `PATCH /v1/im/meetings/{id}` | `{"title":"新标题","version":1}` | `{"meeting_id":"mtg_1","title":"新标题","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` | +| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | +| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | +| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | +| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | +| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` | +| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"本月完成客户拜访和合同整理...","next_plan_short":"下月推进回款和方案评审","status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"完成项目阶段验收...","next_plan_short":"继续推进上线准备","status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` | +| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` | +| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/monthly/statistics?month=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `GET /v1/im/reports/monthly/{report_id}` | Path report ID | `{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"work_summary":"总结","next_plan":"计划","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `POST /v1/im/reports/monthly/{id}/reopen` 待实现 | `{"version":3,"reason":"补充内容"}` | `{"report_id":"mr_1","status":"draft","version":4}` | +| `GET /v1/im/executive-mailbox/config` | Query only | `{"enabled":true,"allow_anonymous":true,"required_fields":["subject","content"],"max_length":1000,"attachment_policy":{"max_files":5,"purpose":"executive_mailbox"},"notice":{"title":"总经理信箱","content":"请如实反馈","updated_at":"2026-08-20T09:00:00Z"}}` | +| `POST /v1/im/executive-mailbox/submissions` | Header `Idempotency-Key`;Body `{"subject":"建议","content":"建议内容","anonymous":false,"attachment_file_ids":[]}` | `{"submission_id":"mail_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | + ## 10. 个人资料、通用设置、隐私与青少年模式 ### 10.1 我的.png @@ -1673,10 +2232,10 @@ X-Tinode-APIKey: <预置的普通 API Key> 个人信息 -**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** +**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 -- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 +- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 +- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 ### 10.3 设置昵称.png @@ -1723,7 +2282,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:菜单纯客户端;数据接口部分新增。** - 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 -- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 ### 10.8 设置(安卓).png @@ -1859,6 +2418,38 @@ X-Tinode-APIKey: <预置的普通 API Key> - 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 - 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 +### 10.22 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 10.22.1 个人资料、设置、隐私、青少年模式与设备 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | +| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | +| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` | +| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode"}` | +| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/security/challenges` | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/security/challenges/{id}/verify` | `{"code":"123456"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | +| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | +| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"fcm","token":"push_token","platform":"android","app_version":"1.0.0"}` | `{"device_id":"device_1","provider":"fcm","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | + ## 11. 反诈安全、举报与意见反馈 ### 11.1 反诈弹窗(安卓).png @@ -1869,7 +2460,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 - 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 -- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 +- 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。 ### 11.2 反诈弹窗(二次提示安卓).png @@ -1911,13 +2502,34 @@ X-Tinode-APIKey: <预置的普通 API Key> - 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。 - 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。 + +### 11.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 11.6.1 反诈、举报与意见反馈 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm` | `{"decision":"continue"}` | `{"risk_action_token":"risk_token","action":"send_message","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/reports/abuse/reasons?target_type=user\|group\|message` 需修复 | Query only | `{"items":[{"reason_code":"fraud","label":"诈骗","requires_description":true,"allows_evidence":true},{"reason_code":"harassment","label":"骚扰","requires_description":false,"allows_evidence":true}]}` | +| `POST /v1/im/reports/abuse` | `{"target":{"type":"message","topic":"grp...","seq":"12"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file_1"]}` | `{"report_id":"abr_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/feedback/config` | Query only | `{"categories":["bug","suggestion","complaint","other"],"max_length":1000,"allow_contact":true,"attachment_policy":{"max_files":5,"purpose":"feedback"},"diagnostics_notice":"仅在你同意时上传脱敏诊断信息。","updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/feedback` | `{"category":"bug","content":"页面异常","contact":"15500000000","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"14"},"include_diagnostics":false}` | `{"feedback_id":"fb_1","status":"received","created_at":"2026-08-20T09:00:00Z"}` | + ## 12. 联调实施清单 ### 12.1 当前可直接联调的基础能力 1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 -3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。 +3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 MeetingIM 协议。 4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 @@ -1926,8 +2538,8 @@ X-Tinode-APIKey: <预置的普通 API Key> | 优先级 | 改造项 | 阻塞图片章节 | | --- | --- | --- | | P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | -| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 | -| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | +| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 | +| P0 | 普通用户禁止发布系统 Topic;系统通知和业务事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | | P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | | P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | | P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | @@ -1950,7 +2562,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | | T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | | T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | -| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | | T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | | T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 |