DatabaseInitializer.java 964 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497849884998500850185028503850485058506850785088509851085118512851385148515851685178518851985208521852285238524852585268527852885298530853185328533853485358536853785388539854085418542854385448545854685478548854985508551855285538554855585568557855885598560856185628563856485658566856785688569857085718572857385748575857685778578857985808581858285838584858585868587858885898590859185928593859485958596859785988599860086018602860386048605860686078608860986108611861286138614861586168617861886198620862186228623862486258626862786288629863086318632863386348635863686378638863986408641864286438644864586468647864886498650865186528653865486558656865786588659866086618662866386648665866686678668866986708671867286738674867586768677867886798680868186828683868486858686868786888689869086918692869386948695869686978698869987008701870287038704870587068707870887098710871187128713871487158716871787188719872087218722872387248725872687278728872987308731873287338734873587368737873887398740874187428743874487458746874787488749875087518752875387548755875687578758875987608761876287638764876587668767876887698770877187728773877487758776877787788779878087818782878387848785878687878788878987908791879287938794879587968797879887998800880188028803880488058806880788088809881088118812881388148815881688178818881988208821882288238824882588268827882888298830883188328833883488358836883788388839884088418842884388448845884688478848884988508851885288538854885588568857885888598860886188628863886488658866886788688869887088718872887388748875887688778878887988808881888288838884888588868887888888898890889188928893889488958896889788988899890089018902890389048905890689078908890989108911891289138914891589168917891889198920892189228923892489258926892789288929893089318932893389348935893689378938893989408941894289438944894589468947894889498950895189528953895489558956895789588959896089618962896389648965896689678968896989708971897289738974897589768977897889798980898189828983898489858986898789888989899089918992899389948995899689978998899990009001900290039004900590069007900890099010901190129013901490159016901790189019902090219022902390249025902690279028902990309031903290339034903590369037903890399040904190429043904490459046904790489049905090519052905390549055905690579058905990609061906290639064906590669067906890699070907190729073907490759076907790789079908090819082908390849085908690879088908990909091909290939094909590969097909890999100910191029103910491059106910791089109911091119112911391149115911691179118911991209121912291239124912591269127912891299130913191329133913491359136913791389139914091419142914391449145914691479148914991509151915291539154915591569157915891599160916191629163916491659166916791689169917091719172917391749175917691779178917991809181918291839184918591869187918891899190919191929193919491959196919791989199920092019202920392049205920692079208920992109211921292139214921592169217921892199220922192229223922492259226922792289229923092319232923392349235923692379238923992409241924292439244924592469247924892499250925192529253925492559256925792589259926092619262926392649265926692679268926992709271927292739274927592769277927892799280928192829283928492859286928792889289929092919292929392949295929692979298929993009301930293039304930593069307930893099310931193129313931493159316931793189319932093219322932393249325932693279328932993309331933293339334933593369337933893399340934193429343934493459346934793489349935093519352935393549355935693579358935993609361936293639364936593669367936893699370937193729373937493759376937793789379938093819382938393849385938693879388938993909391939293939394939593969397939893999400940194029403940494059406940794089409941094119412941394149415941694179418941994209421942294239424942594269427942894299430943194329433943494359436943794389439944094419442944394449445944694479448944994509451945294539454945594569457945894599460946194629463946494659466946794689469947094719472947394749475947694779478947994809481948294839484948594869487948894899490949194929493949494959496949794989499950095019502950395049505950695079508950995109511951295139514951595169517951895199520952195229523952495259526952795289529953095319532953395349535953695379538953995409541954295439544954595469547954895499550955195529553955495559556955795589559956095619562956395649565956695679568956995709571957295739574957595769577957895799580958195829583958495859586958795889589959095919592959395949595959695979598959996009601960296039604960596069607960896099610961196129613961496159616961796189619962096219622962396249625962696279628962996309631963296339634963596369637963896399640964196429643964496459646964796489649965096519652965396549655965696579658965996609661966296639664966596669667966896699670967196729673967496759676967796789679968096819682968396849685968696879688968996909691969296939694969596969697969896999700970197029703970497059706970797089709971097119712971397149715971697179718971997209721972297239724972597269727972897299730973197329733973497359736973797389739974097419742974397449745974697479748974997509751975297539754975597569757975897599760976197629763976497659766976797689769977097719772977397749775977697779778977997809781978297839784978597869787978897899790979197929793979497959796979797989799980098019802980398049805980698079808980998109811981298139814981598169817981898199820982198229823982498259826982798289829983098319832983398349835983698379838983998409841984298439844984598469847984898499850985198529853985498559856985798589859986098619862986398649865986698679868986998709871987298739874987598769877987898799880988198829883988498859886988798889889989098919892989398949895989698979898989999009901990299039904990599069907990899099910991199129913991499159916991799189919992099219922992399249925992699279928992999309931993299339934993599369937993899399940994199429943994499459946994799489949995099519952995399549955995699579958995999609961996299639964996599669967996899699970997199729973997499759976997799789979998099819982998399849985998699879988998999909991999299939994999599969997999899991000010001100021000310004100051000610007100081000910010100111001210013100141001510016100171001810019100201002110022100231002410025100261002710028100291003010031100321003310034100351003610037100381003910040100411004210043100441004510046100471004810049100501005110052100531005410055100561005710058100591006010061100621006310064100651006610067100681006910070100711007210073100741007510076100771007810079100801008110082100831008410085100861008710088100891009010091100921009310094100951009610097100981009910100101011010210103101041010510106101071010810109101101011110112101131011410115101161011710118101191012010121101221012310124101251012610127101281012910130101311013210133101341013510136101371013810139101401014110142101431014410145101461014710148101491015010151101521015310154101551015610157101581015910160101611016210163101641016510166101671016810169101701017110172101731017410175101761017710178101791018010181101821018310184101851018610187101881018910190101911019210193101941019510196101971019810199102001020110202102031020410205102061020710208102091021010211102121021310214102151021610217102181021910220102211022210223102241022510226102271022810229102301023110232102331023410235102361023710238102391024010241102421024310244102451024610247102481024910250102511025210253102541025510256102571025810259102601026110262102631026410265102661026710268102691027010271102721027310274102751027610277102781027910280102811028210283102841028510286102871028810289102901029110292102931029410295102961029710298102991030010301103021030310304103051030610307103081030910310103111031210313103141031510316103171031810319103201032110322103231032410325103261032710328103291033010331103321033310334103351033610337103381033910340103411034210343103441034510346103471034810349103501035110352103531035410355103561035710358103591036010361103621036310364103651036610367103681036910370103711037210373103741037510376103771037810379103801038110382103831038410385103861038710388103891039010391103921039310394103951039610397103981039910400104011040210403104041040510406104071040810409104101041110412104131041410415104161041710418104191042010421104221042310424104251042610427104281042910430104311043210433104341043510436104371043810439104401044110442104431044410445104461044710448104491045010451104521045310454104551045610457104581045910460104611046210463104641046510466104671046810469104701047110472104731047410475104761047710478104791048010481104821048310484104851048610487104881048910490104911049210493104941049510496104971049810499105001050110502105031050410505105061050710508105091051010511105121051310514105151051610517105181051910520105211052210523105241052510526105271052810529105301053110532105331053410535105361053710538105391054010541105421054310544105451054610547105481054910550105511055210553105541055510556105571055810559105601056110562105631056410565105661056710568105691057010571105721057310574105751057610577105781057910580105811058210583105841058510586105871058810589105901059110592105931059410595105961059710598105991060010601106021060310604106051060610607106081060910610106111061210613106141061510616106171061810619106201062110622106231062410625106261062710628106291063010631106321063310634106351063610637106381063910640106411064210643106441064510646106471064810649106501065110652106531065410655106561065710658106591066010661106621066310664106651066610667106681066910670106711067210673106741067510676106771067810679106801068110682106831068410685106861068710688106891069010691106921069310694106951069610697106981069910700107011070210703107041070510706107071070810709107101071110712107131071410715107161071710718107191072010721107221072310724107251072610727107281072910730107311073210733107341073510736107371073810739107401074110742107431074410745107461074710748107491075010751107521075310754107551075610757107581075910760107611076210763107641076510766107671076810769107701077110772107731077410775107761077710778107791078010781107821078310784107851078610787107881078910790107911079210793107941079510796107971079810799108001080110802108031080410805108061080710808108091081010811108121081310814108151081610817108181081910820108211082210823108241082510826108271082810829108301083110832108331083410835108361083710838108391084010841108421084310844108451084610847108481084910850108511085210853108541085510856108571085810859108601086110862108631086410865108661086710868108691087010871108721087310874108751087610877108781087910880108811088210883108841088510886108871088810889108901089110892108931089410895108961089710898108991090010901109021090310904109051090610907109081090910910109111091210913109141091510916109171091810919109201092110922109231092410925109261092710928109291093010931109321093310934109351093610937109381093910940109411094210943109441094510946109471094810949109501095110952109531095410955109561095710958109591096010961109621096310964109651096610967109681096910970109711097210973109741097510976109771097810979109801098110982109831098410985109861098710988109891099010991109921099310994109951099610997109981099911000110011100211003110041100511006110071100811009110101101111012110131101411015110161101711018110191102011021110221102311024110251102611027110281102911030110311103211033110341103511036110371103811039110401104111042110431104411045110461104711048110491105011051110521105311054110551105611057110581105911060110611106211063110641106511066110671106811069110701107111072110731107411075110761107711078110791108011081110821108311084110851108611087110881108911090110911109211093110941109511096110971109811099111001110111102111031110411105111061110711108111091111011111111121111311114111151111611117111181111911120111211112211123111241112511126111271112811129111301113111132111331113411135111361113711138111391114011141111421114311144111451114611147111481114911150111511115211153111541115511156111571115811159111601116111162111631116411165111661116711168111691117011171111721117311174111751117611177111781117911180111811118211183111841118511186111871118811189111901119111192111931119411195111961119711198111991120011201112021120311204112051120611207112081120911210112111121211213112141121511216112171121811219112201122111222112231122411225112261122711228112291123011231112321123311234112351123611237112381123911240112411124211243112441124511246112471124811249112501125111252112531125411255112561125711258112591126011261112621126311264112651126611267112681126911270112711127211273112741127511276112771127811279112801128111282112831128411285112861128711288112891129011291112921129311294112951129611297112981129911300113011130211303113041130511306113071130811309113101131111312113131131411315113161131711318113191132011321113221132311324113251132611327113281132911330113311133211333113341133511336113371133811339113401134111342113431134411345113461134711348113491135011351113521135311354113551135611357113581135911360113611136211363113641136511366113671136811369113701137111372113731137411375113761137711378113791138011381113821138311384113851138611387113881138911390113911139211393113941139511396113971139811399114001140111402114031140411405114061140711408114091141011411114121141311414114151141611417114181141911420114211142211423114241142511426114271142811429114301143111432114331143411435114361143711438114391144011441114421144311444114451144611447114481144911450114511145211453114541145511456114571145811459114601146111462114631146411465114661146711468114691147011471114721147311474114751147611477114781147911480114811148211483114841148511486114871148811489114901149111492114931149411495114961149711498114991150011501115021150311504115051150611507115081150911510115111151211513115141151511516115171151811519115201152111522115231152411525115261152711528115291153011531115321153311534115351153611537115381153911540115411154211543115441154511546115471154811549115501155111552115531155411555115561155711558115591156011561115621156311564115651156611567115681156911570115711157211573115741157511576115771157811579115801158111582115831158411585115861158711588115891159011591115921159311594115951159611597115981159911600116011160211603116041160511606116071160811609116101161111612116131161411615116161161711618116191162011621116221162311624116251162611627116281162911630116311163211633116341163511636116371163811639116401164111642116431164411645116461164711648116491165011651116521165311654116551165611657116581165911660116611166211663116641166511666116671166811669116701167111672116731167411675116761167711678116791168011681116821168311684116851168611687116881168911690116911169211693116941169511696116971169811699117001170111702117031170411705117061170711708117091171011711117121171311714117151171611717117181171911720117211172211723117241172511726117271172811729117301173111732117331173411735117361173711738117391174011741117421174311744117451174611747117481174911750117511175211753117541175511756117571175811759117601176111762117631176411765117661176711768117691177011771117721177311774117751177611777117781177911780117811178211783117841178511786117871178811789117901179111792117931179411795117961179711798117991180011801118021180311804118051180611807118081180911810118111181211813118141181511816118171181811819118201182111822118231182411825118261182711828118291183011831118321183311834118351183611837118381183911840118411184211843118441184511846118471184811849118501185111852118531185411855118561185711858118591186011861118621186311864118651186611867118681186911870118711187211873118741187511876118771187811879118801188111882118831188411885118861188711888118891189011891118921189311894118951189611897118981189911900119011190211903119041190511906119071190811909119101191111912119131191411915119161191711918119191192011921119221192311924119251192611927119281192911930119311193211933119341193511936119371193811939119401194111942119431194411945119461194711948119491195011951119521195311954119551195611957119581195911960119611196211963119641196511966119671196811969119701197111972119731197411975119761197711978119791198011981119821198311984119851198611987119881198911990119911199211993119941199511996119971199811999120001200112002120031200412005120061200712008120091201012011120121201312014120151201612017120181201912020120211202212023120241202512026120271202812029120301203112032120331203412035120361203712038120391204012041120421204312044120451204612047120481204912050120511205212053120541205512056120571205812059120601206112062120631206412065120661206712068120691207012071120721207312074120751207612077120781207912080120811208212083120841208512086120871208812089120901209112092120931209412095120961209712098120991210012101121021210312104121051210612107121081210912110121111211212113121141211512116121171211812119121201212112122121231212412125121261212712128121291213012131121321213312134121351213612137121381213912140121411214212143121441214512146121471214812149121501215112152121531215412155121561215712158121591216012161121621216312164121651216612167121681216912170121711217212173121741217512176121771217812179121801218112182121831218412185121861218712188121891219012191121921219312194121951219612197121981219912200122011220212203122041220512206122071220812209122101221112212122131221412215122161221712218122191222012221122221222312224122251222612227122281222912230122311223212233122341223512236122371223812239122401224112242122431224412245122461224712248122491225012251122521225312254122551225612257122581225912260122611226212263122641226512266122671226812269122701227112272122731227412275122761227712278122791228012281122821228312284122851228612287122881228912290122911229212293122941229512296122971229812299123001230112302123031230412305123061230712308123091231012311123121231312314123151231612317123181231912320123211232212323123241232512326123271232812329123301233112332123331233412335123361233712338123391234012341123421234312344123451234612347123481234912350123511235212353123541235512356123571235812359123601236112362123631236412365123661236712368123691237012371123721237312374123751237612377123781237912380123811238212383123841238512386123871238812389123901239112392123931239412395123961239712398123991240012401124021240312404124051240612407124081240912410124111241212413124141241512416124171241812419124201242112422124231242412425124261242712428124291243012431124321243312434124351243612437124381243912440124411244212443124441244512446124471244812449124501245112452124531245412455124561245712458124591246012461124621246312464124651246612467124681246912470124711247212473124741247512476124771247812479124801248112482124831248412485124861248712488124891249012491124921249312494124951249612497124981249912500125011250212503125041250512506125071250812509125101251112512125131251412515125161251712518125191252012521125221252312524125251252612527125281252912530125311253212533125341253512536125371253812539125401254112542125431254412545125461254712548125491255012551125521255312554125551255612557125581255912560125611256212563125641256512566125671256812569125701257112572125731257412575125761257712578125791258012581125821258312584125851258612587125881258912590125911259212593125941259512596125971259812599126001260112602126031260412605126061260712608126091261012611126121261312614126151261612617126181261912620126211262212623126241262512626126271262812629126301263112632126331263412635126361263712638126391264012641126421264312644126451264612647126481264912650126511265212653126541265512656126571265812659126601266112662126631266412665126661266712668126691267012671126721267312674126751267612677126781267912680126811268212683126841268512686126871268812689126901269112692126931269412695126961269712698126991270012701127021270312704127051270612707127081270912710127111271212713127141271512716127171271812719127201272112722127231272412725127261272712728127291273012731127321273312734127351273612737127381273912740127411274212743127441274512746127471274812749127501275112752127531275412755127561275712758127591276012761127621276312764127651276612767127681276912770127711277212773127741277512776127771277812779127801278112782127831278412785127861278712788127891279012791127921279312794127951279612797127981279912800128011280212803128041280512806128071280812809128101281112812128131281412815128161281712818128191282012821128221282312824128251282612827128281282912830128311283212833128341283512836128371283812839128401284112842128431284412845128461284712848128491285012851128521285312854128551285612857128581285912860128611286212863128641286512866128671286812869128701287112872128731287412875128761287712878128791288012881128821288312884128851288612887128881288912890128911289212893128941289512896128971289812899129001290112902129031290412905129061290712908129091291012911129121291312914129151291612917129181291912920129211292212923129241292512926129271292812929129301293112932129331293412935129361293712938129391294012941129421294312944129451294612947129481294912950129511295212953129541295512956129571295812959129601296112962129631296412965129661296712968129691297012971129721297312974129751297612977129781297912980129811298212983129841298512986129871298812989129901299112992129931299412995129961299712998129991300013001130021300313004130051300613007130081300913010130111301213013130141301513016130171301813019130201302113022130231302413025130261302713028130291303013031130321303313034130351303613037130381303913040130411304213043130441304513046130471304813049130501305113052130531305413055130561305713058130591306013061130621306313064130651306613067130681306913070130711307213073130741307513076130771307813079130801308113082130831308413085130861308713088130891309013091130921309313094130951309613097130981309913100131011310213103131041310513106131071310813109131101311113112131131311413115131161311713118131191312013121131221312313124131251312613127131281312913130131311313213133131341313513136131371313813139131401314113142131431314413145131461314713148131491315013151131521315313154131551315613157131581315913160131611316213163131641316513166131671316813169131701317113172131731317413175131761317713178131791318013181131821318313184131851318613187131881318913190131911319213193131941319513196131971319813199132001320113202132031320413205132061320713208132091321013211132121321313214132151321613217132181321913220132211322213223132241322513226132271322813229132301323113232132331323413235132361323713238132391324013241132421324313244132451324613247132481324913250132511325213253132541325513256132571325813259132601326113262132631326413265132661326713268132691327013271132721327313274132751327613277132781327913280132811328213283132841328513286132871328813289132901329113292132931329413295132961329713298132991330013301133021330313304133051330613307133081330913310133111331213313133141331513316133171331813319133201332113322133231332413325133261332713328133291333013331133321333313334133351333613337133381333913340133411334213343133441334513346133471334813349133501335113352133531335413355133561335713358133591336013361133621336313364133651336613367133681336913370133711337213373133741337513376133771337813379133801338113382133831338413385133861338713388133891339013391133921339313394133951339613397133981339913400134011340213403134041340513406134071340813409134101341113412134131341413415134161341713418134191342013421134221342313424134251342613427134281342913430134311343213433134341343513436134371343813439134401344113442134431344413445134461344713448134491345013451134521345313454134551345613457134581345913460134611346213463134641346513466134671346813469134701347113472134731347413475134761347713478134791348013481134821348313484134851348613487134881348913490134911349213493134941349513496134971349813499135001350113502135031350413505135061350713508135091351013511135121351313514135151351613517135181351913520135211352213523135241352513526135271352813529135301353113532135331353413535135361353713538135391354013541135421354313544135451354613547135481354913550135511355213553135541355513556135571355813559135601356113562135631356413565135661356713568135691357013571135721357313574135751357613577135781357913580135811358213583135841358513586135871358813589135901359113592135931359413595135961359713598135991360013601136021360313604136051360613607136081360913610136111361213613136141361513616136171361813619136201362113622136231362413625136261362713628136291363013631136321363313634136351363613637136381363913640136411364213643136441364513646136471364813649136501365113652136531365413655136561365713658136591366013661136621366313664136651366613667136681366913670136711367213673136741367513676136771367813679136801368113682136831368413685136861368713688136891369013691136921369313694136951369613697136981369913700137011370213703137041370513706137071370813709137101371113712137131371413715137161371713718137191372013721137221372313724137251372613727137281372913730137311373213733137341373513736137371373813739137401374113742137431374413745137461374713748137491375013751137521375313754137551375613757137581375913760137611376213763137641376513766137671376813769137701377113772137731377413775137761377713778137791378013781137821378313784137851378613787137881378913790137911379213793137941379513796137971379813799138001380113802138031380413805138061380713808138091381013811138121381313814138151381613817138181381913820138211382213823138241382513826138271382813829138301383113832138331383413835138361383713838138391384013841138421384313844138451384613847138481384913850138511385213853138541385513856138571385813859138601386113862138631386413865138661386713868138691387013871138721387313874138751387613877138781387913880138811388213883138841388513886138871388813889138901389113892138931389413895138961389713898138991390013901139021390313904139051390613907139081390913910139111391213913139141391513916139171391813919139201392113922139231392413925139261392713928139291393013931139321393313934139351393613937139381393913940139411394213943139441394513946139471394813949139501395113952139531395413955139561395713958139591396013961139621396313964139651396613967139681396913970139711397213973139741397513976139771397813979139801398113982139831398413985139861398713988139891399013991139921399313994139951399613997139981399914000140011400214003140041400514006140071400814009140101401114012140131401414015140161401714018140191402014021140221402314024140251402614027140281402914030140311403214033140341403514036140371403814039140401404114042140431404414045140461404714048140491405014051140521405314054140551405614057140581405914060140611406214063140641406514066140671406814069140701407114072140731407414075140761407714078140791408014081140821408314084140851408614087140881408914090140911409214093140941409514096140971409814099141001410114102141031410414105141061410714108141091411014111141121411314114141151411614117141181411914120141211412214123141241412514126141271412814129141301413114132141331413414135141361413714138141391414014141141421414314144141451414614147141481414914150141511415214153141541415514156141571415814159141601416114162141631416414165141661416714168141691417014171141721417314174141751417614177141781417914180141811418214183141841418514186141871418814189141901419114192141931419414195141961419714198141991420014201142021420314204142051420614207142081420914210142111421214213142141421514216142171421814219142201422114222142231422414225142261422714228142291423014231142321423314234142351423614237142381423914240142411424214243142441424514246142471424814249142501425114252142531425414255142561425714258142591426014261142621426314264142651426614267142681426914270142711427214273142741427514276142771427814279142801428114282142831428414285142861428714288142891429014291142921429314294142951429614297142981429914300143011430214303143041430514306143071430814309143101431114312143131431414315143161431714318143191432014321143221432314324143251432614327143281432914330143311433214333143341433514336143371433814339143401434114342143431434414345143461434714348143491435014351143521435314354143551435614357143581435914360143611436214363143641436514366143671436814369143701437114372143731437414375143761437714378143791438014381143821438314384143851438614387143881438914390143911439214393143941439514396143971439814399144001440114402144031440414405144061440714408144091441014411144121441314414144151441614417144181441914420144211442214423144241442514426144271442814429144301443114432144331443414435144361443714438144391444014441144421444314444144451444614447144481444914450144511445214453144541445514456144571445814459144601446114462144631446414465144661446714468144691447014471144721447314474144751447614477144781447914480144811448214483144841448514486144871448814489144901449114492144931449414495144961449714498144991450014501145021450314504145051450614507145081450914510145111451214513145141451514516145171451814519145201452114522145231452414525145261452714528145291453014531145321453314534145351453614537145381453914540145411454214543145441454514546145471454814549145501455114552145531455414555145561455714558145591456014561145621456314564145651456614567145681456914570145711457214573145741457514576145771457814579145801458114582145831458414585145861458714588145891459014591145921459314594145951459614597145981459914600146011460214603146041460514606146071460814609146101461114612146131461414615146161461714618146191462014621146221462314624146251462614627146281462914630146311463214633146341463514636146371463814639146401464114642146431464414645146461464714648146491465014651146521465314654146551465614657146581465914660146611466214663146641466514666146671466814669146701467114672146731467414675146761467714678146791468014681146821468314684146851468614687146881468914690146911469214693146941469514696146971469814699147001470114702147031470414705147061470714708147091471014711147121471314714147151471614717147181471914720147211472214723147241472514726147271472814729147301473114732147331473414735147361473714738147391474014741147421474314744147451474614747147481474914750147511475214753147541475514756147571475814759147601476114762147631476414765147661476714768147691477014771147721477314774147751477614777147781477914780147811478214783147841478514786147871478814789147901479114792147931479414795147961479714798147991480014801148021480314804148051480614807148081480914810148111481214813148141481514816148171481814819148201482114822148231482414825148261482714828148291483014831148321483314834148351483614837148381483914840148411484214843148441484514846148471484814849148501485114852148531485414855148561485714858148591486014861148621486314864148651486614867148681486914870148711487214873148741487514876148771487814879148801488114882148831488414885148861488714888148891489014891148921489314894148951489614897148981489914900149011490214903149041490514906149071490814909149101491114912149131491414915149161491714918149191492014921149221492314924149251492614927149281492914930149311493214933149341493514936149371493814939149401494114942149431494414945149461494714948149491495014951149521495314954149551495614957149581495914960149611496214963149641496514966149671496814969149701497114972149731497414975149761497714978149791498014981149821498314984149851498614987149881498914990149911499214993149941499514996149971499814999150001500115002150031500415005150061500715008150091501015011150121501315014150151501615017150181501915020150211502215023150241502515026150271502815029150301503115032150331503415035150361503715038150391504015041150421504315044150451504615047150481504915050
  1. package com.etotem.cfc.config;
  2. import com.etotem.cfc.mapper.*;
  3. import com.etotem.cfc.service.CfLegacyLedgerMigrationService;
  4. import com.fasterxml.jackson.databind.JsonNode;
  5. import com.fasterxml.jackson.databind.ObjectMapper;
  6. import javax.annotation.Resource;
  7. import org.slf4j.Logger;
  8. import org.slf4j.LoggerFactory;
  9. import lombok.var;
  10. import org.springframework.beans.factory.annotation.Value;
  11. import org.springframework.boot.CommandLineRunner;
  12. import org.springframework.core.io.ClassPathResource;
  13. import org.springframework.core.io.ResourceLoader;
  14. import org.springframework.jdbc.core.JdbcTemplate;
  15. import org.springframework.stereotype.Component;
  16. import org.springframework.util.StreamUtils;
  17. import java.nio.charset.StandardCharsets;
  18. import java.sql.Connection;
  19. import java.sql.PreparedStatement;
  20. import java.sql.SQLException;
  21. import java.util.ArrayList;
  22. import java.util.HashMap;
  23. import java.util.List;
  24. import java.util.Map;
  25. import java.util.regex.Pattern;
  26. @Component
  27. public class DatabaseInitializer implements CommandLineRunner {
  28. private static final Logger log = LoggerFactory.getLogger(DatabaseInitializer.class);
  29. /** 迁移是否已全部完成(供定时任务判断,避免启动期时序竞态) */
  30. private static volatile boolean migrationsComplete = false;
  31. public static boolean isMigrationsComplete() {
  32. return migrationsComplete;
  33. }
  34. @Resource
  35. private JdbcTemplate jdbcTemplate;
  36. @Resource
  37. private ResourceLoader resourceLoader;
  38. @Resource
  39. private CfLegacyLedgerMigrationService cfLegacyLedgerMigrationService;
  40. @Value("${app.init-test-data:false}")
  41. private boolean initTestData;
  42. @Override
  43. public void run(String... args) throws Exception {
  44. log.info("开始初始化数据库...");
  45. // 1. 检查并创建数据库
  46. initializeDatabase();
  47. // 2. 检查并创建表
  48. initializeTables();
  49. // 3. 执行数据库迁移(添加新列)
  50. runMigrations();
  51. // 4. 初始化食品相关表(DDL,始终执行)
  52. initFoodTables();
  53. // 5. 初始化测试/种子数据(仅测试/开发环境)
  54. if (initTestData) {
  55. log.info("init-test-data=true,开始初始化测试/种子数据...");
  56. initializeDefaultData();
  57. log.info("测试/种子数据初始化完成!");
  58. } else {
  59. log.info("init-test-data=false,跳过测试/种子数据初始化(生产模式)");
  60. }
  61. migrationsComplete = true;
  62. log.info("数据库初始化完成!");
  63. }
  64. private void initializeDatabase() {
  65. try {
  66. // 尝试连接数据库,如果不存在则创建
  67. jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS zxyj DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
  68. log.info("数据库检查完成");
  69. } catch (Exception e) {
  70. log.warn("创建数据库失败: {}", e.getMessage());
  71. }
  72. }
  73. private void initializeTables() {
  74. try {
  75. org.springframework.core.io.Resource resource = resourceLoader.getResource("classpath:schema.sql");
  76. String content;
  77. try (java.io.InputStream is = resource.getInputStream()) {
  78. content = new String(StreamUtils.copyToByteArray(is), StandardCharsets.UTF_8);
  79. }
  80. // Remove SQL single-line comments
  81. content = content.replaceAll("(?m)^\\s*--.*$", "");
  82. // 提取所有 CREATE TABLE IF NOT EXISTS 语句。
  83. // 不能用非贪婪 ".*?;" 匹配:COMMENT 字符串字面量中可能含分号(如
  84. // task_reminder_config.offset_minutes 的注释 "截止前N分钟; after_timeout..."),
  85. // 会被提前截断导致语句不完整而建表失败。此处按字符扫描,跳过引号内部的分号。
  86. List<String> createTableSQLs = new ArrayList<>();
  87. Pattern createTableHead = Pattern.compile(
  88. "^\\s*CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\b", Pattern.CASE_INSENSITIVE);
  89. StringBuilder stmt = new StringBuilder();
  90. boolean inSingleQuote = false;
  91. boolean inBacktick = false;
  92. for (int i = 0; i < content.length(); i++) {
  93. char c = content.charAt(i);
  94. if (c == '\'' && !inBacktick) {
  95. if (inSingleQuote && i + 1 < content.length() && content.charAt(i + 1) == '\'') {
  96. stmt.append("''");
  97. i++;
  98. continue;
  99. }
  100. inSingleQuote = !inSingleQuote;
  101. stmt.append(c);
  102. } else if (c == '`' && !inSingleQuote) {
  103. inBacktick = !inBacktick;
  104. stmt.append(c);
  105. } else if (c == ';' && !inSingleQuote && !inBacktick) {
  106. String sql = stmt.toString().trim();
  107. if (!sql.isEmpty() && createTableHead.matcher(sql).find()) {
  108. createTableSQLs.add(sql + ";");
  109. }
  110. stmt.setLength(0);
  111. } else {
  112. stmt.append(c);
  113. }
  114. }
  115. log.info("从 schema.sql 加载了 {} 个建表语句", createTableSQLs.size());
  116. for (String sql : createTableSQLs) {
  117. try {
  118. jdbcTemplate.execute(sql);
  119. } catch (Exception e) {
  120. log.warn("创建表失败: {}", e.getMessage());
  121. }
  122. }
  123. log.info("数据表检查完成");
  124. } catch (Exception e) {
  125. log.error("读取 schema.sql 失败", e);
  126. }
  127. }
  128. private void runMigrations() {
  129. // 迁移1: 任务表添加执行者字段
  130. try {
  131. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN executor_type ENUM('child', 'parent') DEFAULT 'child' COMMENT '执行者类型'");
  132. log.info("已添加executor_type列到tasks表");
  133. } catch (Exception e) {
  134. // 列已存在,忽略错误
  135. }
  136. try {
  137. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN executor_id BIGINT COMMENT '执行者ID'");
  138. log.info("已添加executor_id列到tasks表");
  139. } catch (Exception e) {
  140. // 列已存在,忽略错误
  141. }
  142. try {
  143. jdbcTemplate.execute("ALTER TABLE tasks ADD INDEX idx_executor (executor_type, executor_id)");
  144. log.info("已添加idx_executor索引到tasks表");
  145. } catch (Exception e) {
  146. // 索引已存在,忽略错误
  147. }
  148. // 迁移2: 用户表添加角色和积分字段
  149. try {
  150. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN roles VARCHAR(100) COMMENT '角色列表'");
  151. log.info("已添加roles列到users表");
  152. } catch (Exception e) {
  153. // 列已存在,忽略错误
  154. }
  155. try {
  156. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN total_points INT DEFAULT 0 COMMENT '家长总积分'");
  157. log.info("已添加total_points列到users表");
  158. } catch (Exception e) {
  159. // 列已存在,忽略错误
  160. }
  161. try {
  162. jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_roles (roles(20))");
  163. log.info("已添加idx_roles索引到users表");
  164. } catch (Exception e) {
  165. // 索引已存在,忽略错误
  166. }
  167. // 迁移3: users表添加新字段
  168. try {
  169. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN id_card VARCHAR(18) COMMENT '身份证号'");
  170. log.info("已添加id_card列到users表");
  171. } catch (Exception e) {
  172. // 列已存在,忽略错误
  173. }
  174. try {
  175. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN real_name VARCHAR(50) COMMENT '真实姓名'");
  176. log.info("已添加real_name列到users表");
  177. } catch (Exception e) {
  178. // 列已存在,忽略错误
  179. }
  180. try {
  181. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN phone VARCHAR(11) COMMENT '手机号'");
  182. log.info("已添加phone列到users表");
  183. } catch (Exception e) {
  184. // 列已存在,忽略错误
  185. }
  186. try {
  187. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN birthday DATE COMMENT '生日'");
  188. log.info("已添加birthday列到users表");
  189. } catch (Exception e) {
  190. // 列已存在,忽略错误
  191. }
  192. try {
  193. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN gender ENUM('male', 'female') COMMENT '性别'");
  194. log.info("已添加gender列到users表");
  195. } catch (Exception e) {
  196. // 列已存在,忽略错误
  197. }
  198. try {
  199. jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_phone (phone)");
  200. log.info("已添加idx_phone索引到users表");
  201. } catch (Exception e) {
  202. }
  203. try {
  204. jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role ENUM('parent', 'child', 'teacher') NOT NULL");
  205. log.info("已更新users.role字段支持teacher角色");
  206. } catch (Exception e) {
  207. // 修改失败,忽略错误
  208. }
  209. // 迁移5.1: 修改users.role为支持admin
  210. try {
  211. jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role ENUM('parent', 'child', 'teacher', 'admin') NOT NULL");
  212. log.info("已更新users.role字段支持admin角色");
  213. } catch (Exception e) {
  214. // 修改失败,忽略错误
  215. }
  216. // 迁移5.2: 确保管理员角色为admin(修复旧数据遗留问题)
  217. try {
  218. int updated = jdbcTemplate.update("UPDATE admins SET role = 'admin' WHERE username = 'admin'");
  219. if (updated > 0) {
  220. log.info("已修正管理员角色为admin");
  221. }
  222. } catch (Exception e) {
  223. // 更新失败,忽略错误
  224. }
  225. // 迁移6: 创建streets表(街道地址维度表)
  226. try {
  227. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS streets (" +
  228. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  229. "province VARCHAR(50) COMMENT '省份', " +
  230. "province_code VARCHAR(20) COMMENT '省份编码', " +
  231. "city VARCHAR(50) COMMENT '城市', " +
  232. "city_code VARCHAR(20) COMMENT '城市编码', " +
  233. "district VARCHAR(50) COMMENT '区县', " +
  234. "district_code VARCHAR(20) COMMENT '区县编码', " +
  235. "street VARCHAR(100) COMMENT '街道', " +
  236. "street_code VARCHAR(20) COMMENT '街道编码', " +
  237. "full_name VARCHAR(200) COMMENT '完整地址', " +
  238. "level INT COMMENT '层级:1省2市3区4街道', " +
  239. "parent_id BIGINT COMMENT '父级ID', " +
  240. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  241. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  242. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  243. "INDEX idx_level (level), " +
  244. "INDEX idx_parent_id (parent_id), " +
  245. "INDEX idx_street_code (street_code), " +
  246. "INDEX idx_full_name (full_name(50))" +
  247. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  248. log.info("已创建streets表");
  249. } catch (Exception e) {
  250. // 表已存在,忽略错误
  251. }
  252. // 迁移7: families表添加地址字段
  253. try {
  254. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN street_id BIGINT COMMENT '街道ID'");
  255. log.info("已添加street_id列到families表");
  256. } catch (Exception e) {
  257. // 列已存在,忽略错误
  258. }
  259. try {
  260. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN province VARCHAR(50) COMMENT '省份'");
  261. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN city VARCHAR(50) COMMENT '城市'");
  262. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN district VARCHAR(50) COMMENT '区县'");
  263. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN street VARCHAR(100) COMMENT '街道'");
  264. log.info("已添加地址字段到families表");
  265. } catch (Exception e) {
  266. // 列已存在,忽略错误
  267. }
  268. try {
  269. jdbcTemplate.execute("ALTER TABLE families ADD INDEX idx_street_id (street_id)");
  270. log.info("已添加idx_street_id索引到families表");
  271. } catch (Exception e) {
  272. // 索引已存在,忽略错误
  273. }
  274. // 迁移7b: families表添加teacher_id和creator_id字段
  275. try {
  276. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN teacher_id BIGINT COMMENT '绑定的成长规划师ID'");
  277. log.info("已添加teacher_id列到families表");
  278. } catch (Exception e) {
  279. // 列已存在,忽略错误
  280. }
  281. try {
  282. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN creator_id BIGINT COMMENT '家庭创建者ID'");
  283. log.info("已添加creator_id列到families表");
  284. } catch (Exception e) {
  285. // 列已存在,忽略错误
  286. }
  287. // 迁移8: teachers表添加地址字段
  288. try {
  289. jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN street_id BIGINT COMMENT '街道ID'");
  290. log.info("已添加street_id列到teachers表");
  291. } catch (Exception e) {
  292. // 列已存在,忽略错误
  293. }
  294. try {
  295. jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN province VARCHAR(50) COMMENT '省份'");
  296. jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN city VARCHAR(50) COMMENT '城市'");
  297. jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN district VARCHAR(50) COMMENT '区县'");
  298. jdbcTemplate.execute("ALTER TABLE teachers ADD COLUMN street VARCHAR(100) COMMENT '街道'");
  299. log.info("已添加地址字段到teachers表");
  300. } catch (Exception e) {
  301. // 列已存在,忽略错误
  302. }
  303. try {
  304. jdbcTemplate.execute("ALTER TABLE teachers ADD INDEX idx_street_id (street_id)");
  305. log.info("已添加idx_street_id索引到teachers表");
  306. } catch (Exception e) {
  307. // 索引已存在,忽略错误
  308. }
  309. // 迁移9: users表添加成长规划师相关字段
  310. try {
  311. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_no VARCHAR(50) COMMENT '成长规划师证书号'");
  312. log.info("已添加teacher_no列到users表");
  313. } catch (Exception e) {
  314. // 列已存在,忽略错误
  315. }
  316. try {
  317. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_photo VARCHAR(500) COMMENT '成长规划师照片'");
  318. log.info("已添加teacher_photo列到users表");
  319. } catch (Exception e) {
  320. // 列已存在,忽略错误
  321. }
  322. try {
  323. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN certificate_image VARCHAR(500) COMMENT '成长规划师证书照片'");
  324. log.info("已添加certificate_image列到users表");
  325. } catch (Exception e) {
  326. // 列已存在,忽略错误
  327. }
  328. try {
  329. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_status VARCHAR(20) DEFAULT NULL COMMENT '成长规划师审核状态'");
  330. log.info("已添加teacher_status列到users表");
  331. } catch (Exception e) {
  332. // 列已存在,忽略错误
  333. }
  334. try {
  335. jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_teacher_status (teacher_status)");
  336. log.info("已添加idx_teacher_status索引到users表");
  337. } catch (Exception e) {
  338. // 索引已存在,忽略错误
  339. }
  340. // 迁移10: users表添加teacherFamilyIds字段(成长规划师关联的家庭ID列表)
  341. try {
  342. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN teacher_family_ids VARCHAR(500) COMMENT '成长规划师关联的家庭ID列表(逗号分隔)'");
  343. log.info("已添加teacher_family_ids列到users表");
  344. } catch (Exception e) {
  345. // 列已存在,忽略错误
  346. }
  347. // 迁移11: families表添加teacherId字段(绑定的成长规划师ID)
  348. try {
  349. jdbcTemplate.execute("ALTER TABLE families ADD COLUMN teacher_id BIGINT COMMENT '绑定的成长规划师ID'");
  350. log.info("已添加teacher_id列到families表");
  351. } catch (Exception e) {
  352. // 列已存在,忽略错误
  353. }
  354. try {
  355. jdbcTemplate.execute("ALTER TABLE families ADD INDEX idx_teacher_id (teacher_id)");
  356. log.info("已添加idx_teacher_id索引到families表");
  357. } catch (Exception e) {
  358. // 索引已存在,忽略错误
  359. }
  360. // 迁移12: 创建 teacher_messages 表(家长咨询成长规划师)
  361. try {
  362. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS teacher_messages (" +
  363. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  364. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  365. "parent_id BIGINT NOT NULL COMMENT '家长用户ID', " +
  366. "teacher_id BIGINT NOT NULL COMMENT '成长规划师用户ID', " +
  367. "content TEXT NOT NULL COMMENT '消息内容', " +
  368. "reply_content TEXT COMMENT '回复内容', " +
  369. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态:pending(待回复)/replied(已回复)', " +
  370. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  371. "replied_at DATETIME COMMENT '回复时间', " +
  372. "INDEX idx_family_id (family_id), " +
  373. "INDEX idx_teacher_id (teacher_id), " +
  374. "INDEX idx_parent_id (parent_id), " +
  375. "INDEX idx_status (status)" +
  376. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  377. log.info("已创建teacher_messages表");
  378. } catch (Exception e) {
  379. // 表已存在,忽略错误
  380. }
  381. // 迁移13: admin_task_templates表添加teacher_id字段
  382. try {
  383. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN teacher_id BIGINT COMMENT '成长规划师ID'");
  384. log.info("已添加teacher_id列到admin_task_templates表");
  385. } catch (Exception e) {
  386. // 列已存在,忽略错误
  387. }
  388. // 迁移13b: admin_task_templates表添加新字段
  389. try {
  390. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN complete_types VARCHAR(100) COMMENT '完成方式JSON'");
  391. log.info("已添加complete_types列到admin_task_templates表");
  392. } catch (Exception e) {
  393. // 列已存在,忽略错误
  394. }
  395. try {
  396. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN duration INT COMMENT '预计完成时长(分钟)'");
  397. log.info("已添加duration列到admin_task_templates表");
  398. } catch (Exception e) {
  399. // 列已存在,忽略错误
  400. }
  401. try {
  402. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN review_type VARCHAR(20) COMMENT '审核人类型: creator/parent/teacher/ai'");
  403. log.info("已添加review_type列到admin_task_templates表");
  404. } catch (Exception e) {
  405. // 列已存在,忽略错误
  406. }
  407. try {
  408. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN task_type VARCHAR(20) COMMENT '任务性质: onetime/recurring'");
  409. log.info("已添加task_type列到admin_task_templates表");
  410. } catch (Exception e) {
  411. // 列已存在,忽略错误
  412. }
  413. try {
  414. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN frequency VARCHAR(20) COMMENT '频率: daily/weekly/monthly'");
  415. log.info("已添加frequency列到admin_task_templates表");
  416. } catch (Exception e) {
  417. // 列已存在,忽略错误
  418. }
  419. try {
  420. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN max_frequency INT COMMENT '周期内最多完成次数'");
  421. log.info("已添加max_frequency列到admin_task_templates表");
  422. } catch (Exception e) {
  423. // 列已存在,忽略错误
  424. }
  425. // 迁移14: rewards表child_id字段允许NULL(模板奖励不需要child_id)
  426. try {
  427. jdbcTemplate.execute("ALTER TABLE rewards MODIFY COLUMN child_id BIGINT NULL COMMENT '孩子ID,模板奖励为NULL'");
  428. log.info("已修改rewards.child_id字段允许NULL");
  429. } catch (Exception e) {
  430. // 修改失败,忽略错误
  431. }
  432. // 迁移15: admins表添加缺失列
  433. try {
  434. jdbcTemplate.execute("ALTER TABLE admins ADD COLUMN nickname VARCHAR(50) DEFAULT NULL AFTER password");
  435. log.info("已添加nickname列到admins表");
  436. } catch (Exception e) {
  437. // 列已存在,忽略错误
  438. }
  439. try {
  440. jdbcTemplate.execute("ALTER TABLE admins ADD COLUMN email VARCHAR(100) DEFAULT NULL AFTER phone");
  441. log.info("已添加email列到admins表");
  442. } catch (Exception e) {
  443. // 列已存在,忽略错误
  444. }
  445. try {
  446. jdbcTemplate.execute("ALTER TABLE admins MODIFY COLUMN status VARCHAR(20) DEFAULT 'active'");
  447. log.info("已修改admins.status字段类型");
  448. } catch (Exception e) {
  449. // 修改失败,忽略错误
  450. }
  451. // 迁移16: 创建用户操作日志表
  452. try {
  453. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_operation_logs (" +
  454. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  455. "user_id BIGINT COMMENT \'用户ID\', " +
  456. "nickname VARCHAR(50) COMMENT \'用户昵称\', " +
  457. "operation_type VARCHAR(20) NOT NULL COMMENT \'操作类型: login/logout/create/update/delete/query等\', " +
  458. "description VARCHAR(500) COMMENT \'操作描述\', " +
  459. "request_path VARCHAR(200) COMMENT \'请求路径\', " +
  460. "request_method VARCHAR(10) COMMENT \'请求方法\', " +
  461. "request_params TEXT COMMENT \'请求参数(脱敏)\', " +
  462. "response_status INT COMMENT \'响应状态码\', " +
  463. "client_ip VARCHAR(50) COMMENT \'客户端IP\', " +
  464. "user_agent VARCHAR(500) COMMENT \'User-Agent\', " +
  465. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  466. "INDEX idx_user_id (user_id), " +
  467. "INDEX idx_operation_type (operation_type), " +
  468. "INDEX idx_created_at (created_at)" +
  469. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  470. log.info("已创建user_operation_logs表");
  471. } catch (Exception e) {
  472. // 表已存在,忽略错误
  473. }
  474. // 迁移16: tasks表添加bonus_rule字段(POINT-002: 超额积分规则)
  475. try {
  476. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN bonus_rule VARCHAR(500) COMMENT \'超额积分规则JSON\'");
  477. log.info("已添加bonus_rule列到tasks表");
  478. } catch (Exception e) {
  479. // 列已存在,忽略错误
  480. }
  481. // 迁移17: tasks表添加points_expire_days字段(POINT-009: 积分有效期)
  482. try {
  483. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN points_expire_days INT DEFAULT 0 COMMENT \'积分有效期天数,0表示永久有效\'");
  484. log.info("已添加points_expire_days列到tasks表");
  485. } catch (Exception e) {
  486. // 列已存在,忽略错误
  487. }
  488. // 迁移18: points_log表添加expire_at字段(POINT-009: 积分有效期)
  489. try {
  490. jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN expire_at DATE COMMENT \'积分过期日期\'");
  491. log.info("已添加expire_at列到points_log表");
  492. } catch (Exception e) {
  493. // 列已存在,忽略错误
  494. }
  495. // 迁移19: points_log表添加expired字段(POINT-009: 过期状态标记)
  496. try {
  497. jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN expired TINYINT DEFAULT 0 COMMENT \'是否已过期: 0否1是\'");
  498. log.info("已添加expired列到points_log表");
  499. } catch (Exception e) {
  500. // 列已存在,忽略错误
  501. }
  502. // 迁移20: tasks表添加template_id字段(TASK-004: 重复任务模板追踪)
  503. try {
  504. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN template_id BIGINT COMMENT \'模板任务ID\'");
  505. log.info("已添加template_id列到tasks表");
  506. } catch (Exception e) {
  507. // 列已存在,忽略错误
  508. }
  509. // 迁移21: tasks表添加is_template字段(TASK-004: 标记模板任务)
  510. try {
  511. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN is_template TINYINT DEFAULT 0 COMMENT '是否为模板任务'");
  512. log.info("已添加is_template列到tasks表");
  513. } catch (Exception e) {
  514. // 列已存在,忽略错误
  515. }
  516. // 迁移22: 修复tasks表child_id和deadline允许NULL(支持多孩子和家长任务)
  517. try {
  518. jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN child_id BIGINT NULL COMMENT '执行者ID'");
  519. log.info("已修改tasks.child_id允许NULL");
  520. } catch (Exception e) {
  521. // 修改失败,忽略错误
  522. }
  523. try {
  524. jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN deadline DATETIME NULL COMMENT '截止时间'");
  525. log.info("已修改tasks.deadline允许NULL");
  526. } catch (Exception e) {
  527. // 修改失败,忽略错误
  528. }
  529. // 迁移23: tasks表添加新字段
  530. try {
  531. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN task_type VARCHAR(20) COMMENT '任务性质: onetime/recurring'");
  532. log.info("已添加task_type列到tasks表");
  533. } catch (Exception e) {
  534. // 列已存在,忽略错误
  535. }
  536. // 迁移24: tasks表添加parent_task_id字段(购买任务→每日使用子任务链;子任务在父任务完成前不显示)
  537. try {
  538. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN parent_task_id BIGINT COMMENT '父任务ID(购买任务→每日使用子任务)'");
  539. log.info("已添加parent_task_id列到tasks表");
  540. } catch (Exception e) {
  541. // 列已存在,忽略错误
  542. }
  543. try {
  544. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN frequency VARCHAR(20) COMMENT '频率: daily/weekly/monthly'");
  545. log.info("已添加frequency列到tasks表");
  546. } catch (Exception e) {
  547. // 列已存在,忽略错误
  548. }
  549. try {
  550. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN max_frequency INT COMMENT '周期内最多完成次数'");
  551. log.info("已添加max_frequency列到tasks表");
  552. } catch (Exception e) {
  553. // 列已存在,忽略错误
  554. }
  555. try {
  556. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN complete_types VARCHAR(100) COMMENT '完成方式JSON'");
  557. log.info("已添加complete_types列到tasks表");
  558. } catch (Exception e) {
  559. // 列已存在,忽略错误
  560. }
  561. try {
  562. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN review_type VARCHAR(20) COMMENT '审核人类型: creator/parent/teacher/ai'");
  563. log.info("已添加review_type列到tasks表");
  564. } catch (Exception e) {
  565. // 列已存在,忽略错误
  566. }
  567. try {
  568. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN earliest_start DATETIME COMMENT '最早开始时间'");
  569. log.info("已添加earliest_start列到tasks表");
  570. } catch (Exception e) {
  571. // 列已存在,忽略错误
  572. }
  573. try {
  574. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN latest_end DATETIME COMMENT '最晚结束时间'");
  575. log.info("已添加latest_end列到tasks表");
  576. } catch (Exception e) {
  577. // 列已存在,忽略错误
  578. }
  579. // 迁移24: tasks表添加minigame_code字段(小游戏任务)
  580. try {
  581. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN minigame_code VARCHAR(50) DEFAULT NULL COMMENT '小游戏代码: schulte/1a2b/sudoku'");
  582. log.info("已添加minigame_code列到tasks表");
  583. } catch (Exception e) {
  584. // 列已存在,忽略错误
  585. }
  586. // 迁移25: tasks表添加duration字段(TASK-011: 任务预计时长)
  587. try {
  588. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN duration INT DEFAULT 0 COMMENT '预计时长(分钟)'");
  589. log.info("已添加duration列到tasks表");
  590. } catch (Exception e) {
  591. // 列已存在,忽略错误
  592. }
  593. try {
  594. jdbcTemplate.execute("ALTER TABLE tasks ADD INDEX idx_minigame (minigame_code) WHERE minigame_code IS NOT NULL");
  595. log.info("已添加idx_minigame索引到tasks表");
  596. } catch (Exception e) {
  597. // 索引已存在,忽略错误
  598. }
  599. try {
  600. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS trial_memberships (" +
  601. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  602. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  603. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  604. "start_date DATETIME NOT NULL COMMENT '试用开始时间', " +
  605. "end_date DATETIME NOT NULL COMMENT '试用结束时间', " +
  606. "status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态:PENDING/ACTIVE/EXPIRED/CONVERTED', " +
  607. "trial_days INT DEFAULT 7 COMMENT '试用天数', " +
  608. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  609. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  610. "INDEX idx_user_id (user_id), " +
  611. "INDEX idx_family_id (family_id), " +
  612. "INDEX idx_status (status), " +
  613. "INDEX idx_end_date (end_date)" +
  614. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  615. log.info("已创建trial_memberships表");
  616. } catch (Exception e) {
  617. log.warn("创建trial_memberships表可能已存在: {}", e.getMessage());
  618. }
  619. // assessment_appointments 新字段
  620. try {
  621. jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN user_id BIGINT COMMENT '用户ID'");
  622. } catch (Exception e) {
  623. }
  624. try {
  625. jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN guide_id BIGINT COMMENT '成长规划师ID'");
  626. } catch (Exception e) {
  627. }
  628. try {
  629. jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN package_id BIGINT COMMENT '套餐ID'");
  630. } catch (Exception e) {
  631. }
  632. try {
  633. jdbcTemplate.execute("ALTER TABLE assessment_appointments ADD COLUMN remark VARCHAR(500) COMMENT '备注'");
  634. } catch (Exception e) {
  635. }
  636. // assessment_orders 表
  637. try {
  638. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_orders (" +
  639. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  640. "order_no VARCHAR(64) NOT NULL UNIQUE, " +
  641. "family_id BIGINT NOT NULL, " +
  642. "user_id BIGINT NOT NULL, " +
  643. "child_id BIGINT, " +
  644. "guide_id BIGINT NOT NULL, " +
  645. "package_id BIGINT NOT NULL, " +
  646. "guide_name VARCHAR(50), " +
  647. "package_name VARCHAR(100), " +
  648. "total_price BIGINT DEFAULT 0, " +
  649. "discount_amount BIGINT DEFAULT 0, " +
  650. "actual_price BIGINT DEFAULT 0, " +
  651. "status VARCHAR(20) DEFAULT 'pending', " +
  652. "pay_time VARCHAR(50), " +
  653. "pay_type VARCHAR(20), " +
  654. "transaction_id VARCHAR(64), " +
  655. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  656. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  657. "INDEX idx_family (family_id), " +
  658. "INDEX idx_guide (guide_id), " +
  659. "INDEX idx_status (status)) " +
  660. "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评估订单表'");
  661. } catch (Exception e) {
  662. }
  663. // 迁移26: guide_applications表添加address和bank_card字段
  664. try {
  665. jdbcTemplate.execute("ALTER TABLE guide_applications ADD COLUMN address VARCHAR(500) COMMENT '地址'");
  666. log.info("已添加address列到guide_applications表");
  667. } catch (Exception e) {
  668. // 列已存在,忽略错误
  669. }
  670. try {
  671. jdbcTemplate.execute("ALTER TABLE guide_applications ADD COLUMN bank_card VARCHAR(50) COMMENT '银行卡号'");
  672. log.info("已添加bank_card列到guide_applications表");
  673. } catch (Exception e) {
  674. // 列已存在,忽略错误
  675. }
  676. try {
  677. jdbcTemplate.execute("ALTER TABLE guide_applications ADD COLUMN bank_name VARCHAR(100) COMMENT '开户行'");
  678. log.info("已添加bank_name列到guide_applications表");
  679. } catch (Exception e) {
  680. // 列已存在,忽略错误
  681. }
  682. // 迁移27: users表添加address字段(成长规划师地址)
  683. try {
  684. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN address VARCHAR(500) COMMENT '地址'");
  685. log.info("已添加address列到users表");
  686. } catch (Exception e) {
  687. // 列已存在,忽略错误
  688. }
  689. // 迁移28: 创建guide_application_records表(申请过程记录)
  690. try {
  691. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_application_records (" +
  692. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  693. "application_id BIGINT NOT NULL COMMENT '关联申请ID', " +
  694. "user_id BIGINT NOT NULL COMMENT '操作人用户ID', " +
  695. "action VARCHAR(50) NOT NULL COMMENT '操作: submit/approve/reject/withdraw/resubmit', " +
  696. "from_status VARCHAR(20) COMMENT '操作前状态', " +
  697. "to_status VARCHAR(20) NOT NULL COMMENT '操作后状态', " +
  698. "remark VARCHAR(500) COMMENT '备注/原因', " +
  699. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  700. "INDEX idx_application_id (application_id), " +
  701. "INDEX idx_user_id (user_id), " +
  702. "INDEX idx_action (action)" +
  703. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长规划师申请过程记录表'");
  704. log.info("已创建guide_application_records表");
  705. } catch (Exception e) {
  706. }
  707. try {
  708. Integer exists = jdbcTemplate.queryForObject(
  709. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'points_log' AND COLUMN_NAME = 'category'",
  710. Integer.class);
  711. if (exists == null || exists == 0) {
  712. jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN category VARCHAR(20) DEFAULT 'task' COMMENT '积分分类: task任务积分, system系统积分'");
  713. log.info("已添加category列到points_log表");
  714. } else {
  715. log.info("category列已存在,跳过");
  716. }
  717. } catch (Exception e) {
  718. log.warn("检查/添加category列失败: {}", e.getMessage());
  719. }
  720. // 迁移22: 创建invite_card表
  721. try {
  722. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS invite_card (" +
  723. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  724. "type VARCHAR(20) NOT NULL COMMENT 'child/family/guide', " +
  725. "code VARCHAR(64) NOT NULL UNIQUE COMMENT '邀请码', " +
  726. "ref_id BIGINT NOT NULL COMMENT '关联ID(childId/familyId)', " +
  727. "creator_id BIGINT NOT NULL COMMENT '创建人', " +
  728. "expires_at DATETIME NOT NULL, " +
  729. "used TINYINT DEFAULT 0, " +
  730. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  731. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  732. "INDEX idx_code (code), " +
  733. "INDEX idx_ref_type (ref_id, type)" +
  734. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邀请卡片'");
  735. log.info("已创建invite_card表");
  736. } catch (Exception e) {
  737. // 表已存在,忽略错误
  738. }
  739. // 迁移23: 创建wishes表
  740. try {
  741. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS wishes (" +
  742. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  743. "family_id BIGINT COMMENT '家庭ID', " +
  744. "child_id BIGINT COMMENT '目标孩子ID', " +
  745. "created_by BIGINT COMMENT '创建者用户ID', " +
  746. "creator_type VARCHAR(20) COMMENT '创建者类型: child/parent', " +
  747. "title VARCHAR(200) NOT NULL COMMENT '心愿标题', " +
  748. "description VARCHAR(500) COMMENT '心愿描述', " +
  749. "category VARCHAR(32) COMMENT '心愿分类', " +
  750. "points_required INT COMMENT '所需积分', " +
  751. "points_set_by BIGINT COMMENT '定价者ID', " +
  752. "points_set_at DATETIME COMMENT '定价时间', " +
  753. "status VARCHAR(32) DEFAULT 'active' COMMENT '状态: draft/active/pending_exchange/approved/exchanging/fulfilled/cancelled/rejected', " +
  754. "exchange_requested_at DATETIME COMMENT '申请兑换时间', " +
  755. "exchange_approved_by BIGINT COMMENT '批准者ID', " +
  756. "exchange_approved_at DATETIME COMMENT '批准时间', " +
  757. "points_deducted_at DATETIME COMMENT '扣积分时间', " +
  758. "fulfilled_by BIGINT COMMENT '兑现者ID', " +
  759. "fulfilled_at DATETIME COMMENT '兑现时间', " +
  760. "child_confirmed_at DATETIME COMMENT '孩子确认收货时间', " +
  761. "rejected_by BIGINT COMMENT '拒绝者ID', " +
  762. "rejected_at DATETIME COMMENT '拒绝时间', " +
  763. "reject_reason VARCHAR(500) COMMENT '拒绝原因', " +
  764. "reject_count INT DEFAULT 0 COMMENT '被拒绝次数', " +
  765. "cancelled_by BIGINT COMMENT '取消者ID', " +
  766. "cancelled_at DATETIME COMMENT '取消时间', " +
  767. "cancel_reason VARCHAR(500) COMMENT '取消原因', " +
  768. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  769. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  770. "INDEX idx_family_id (family_id), " +
  771. "INDEX idx_child_id (child_id), " +
  772. "INDEX idx_status (status)" +
  773. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='心愿单表'");
  774. log.info("已创建wishes表");
  775. } catch (Exception e) {
  776. log.warn("创建wishes表可能已存在: {}", e.getMessage());
  777. }
  778. // 迁移24: users表添加个人信息补充字段
  779. try {
  780. Integer exists = jdbcTemplate.queryForObject(
  781. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'ethnicity'",
  782. Integer.class);
  783. if (exists == null || exists == 0) {
  784. jdbcTemplate.execute("ALTER TABLE users " +
  785. "ADD COLUMN ethnicity VARCHAR(32) DEFAULT NULL COMMENT '民族', " +
  786. "ADD COLUMN blood_type VARCHAR(16) DEFAULT NULL COMMENT '血型', " +
  787. "ADD COLUMN highest_education VARCHAR(32) DEFAULT NULL COMMENT '最高学历', " +
  788. "ADD COLUMN marital_status VARCHAR(16) DEFAULT NULL COMMENT '婚姻状态', " +
  789. "ADD COLUMN family_role VARCHAR(16) DEFAULT NULL COMMENT '家长身份: 爸爸/妈妈/爷爷/奶奶/姥爷/姥姥/其他'");
  790. log.info("已添加个人信息补充字段到users表");
  791. } else {
  792. log.info("个人信息补充字段已存在,跳过");
  793. }
  794. } catch (Exception e) {
  795. log.warn("检查/添加个人信息补充字段失败: {}", e.getMessage());
  796. }
  797. // Phase 0: 浠艾福俱乐部 — 服务商字段
  798. try {
  799. Integer exists = jdbcTemplate.queryForObject(
  800. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'vendor_type'",
  801. Integer.class);
  802. if (exists == null || exists == 0) {
  803. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_type VARCHAR(32) DEFAULT NULL COMMENT '服务商类型: planner/activity_provider/product_supplier'");
  804. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_status VARCHAR(16) DEFAULT NULL COMMENT '服务商审核状态: pending/approved/rejected'");
  805. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_reject_reason VARCHAR(500) DEFAULT NULL COMMENT '审核拒绝原因'");
  806. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN is_family_admin TINYINT DEFAULT 0 COMMENT '是否家庭管理员'");
  807. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN vendor_info VARCHAR(2000) DEFAULT NULL COMMENT '服务商资质信息(JSON)'");
  808. log.info("已添加浠艾福俱乐部服务商字段到users表");
  809. } else {
  810. log.info("浠艾福俱乐部服务商字段已存在,跳过");
  811. }
  812. } catch (Exception e) {
  813. log.warn("检查/添加服务商字段失败: {}", e.getMessage());
  814. }
  815. // Phase 0: 迁移已有规划师到服务商体系
  816. try {
  817. int updated = jdbcTemplate.update(
  818. "UPDATE users SET vendor_type = 'planner', vendor_status = 'approved' WHERE teacher_status = 'approved' AND vendor_type IS NULL"
  819. );
  820. if (updated > 0) {
  821. log.info("已迁移 {} 名规划师到服务商体系", updated);
  822. }
  823. } catch (Exception e) {
  824. log.warn("迁移规划师到服务商体系失败: {}", e.getMessage());
  825. }
  826. // ==================== Phase 1: 浠艾福俱乐部商品系统 ====== // 商品表
  827. try {
  828. jdbcTemplate.execute(
  829. "CREATE TABLE IF NOT EXISTS products (" +
  830. " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  831. " name VARCHAR(200) NOT NULL," +
  832. " description VARCHAR(1000)," +
  833. " intro TEXT," +
  834. " cover_image VARCHAR(500)," +
  835. " images TEXT," +
  836. " price INT NOT NULL DEFAULT 0 COMMENT '价格(分)'," +
  837. " stock INT NOT NULL DEFAULT 0," +
  838. " sales_count INT NOT NULL DEFAULT 0," +
  839. " product_type VARCHAR(32) NOT NULL COMMENT 'activity/course/physical/digital/service'," +
  840. " vendor_id BIGINT," +
  841. " vendor_name VARCHAR(100)," +
  842. " status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/pending/approved/rejected/on_shelf/off_shelf'," +
  843. " reject_reason VARCHAR(500)," +
  844. " member_price INT COMMENT '会员价(分)'," +
  845. " profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)'," +
  846. " member_eligible VARCHAR(32) DEFAULT 'none' COMMENT 'all/budget/none'," +
  847. " domain VARCHAR(32) COMMENT 'action/mind/body/wisdom/wealth'," +
  848. " external_source VARCHAR(64)," +
  849. " external_id VARCHAR(128)," +
  850. " external_data TEXT," +
  851. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  852. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  853. ")"
  854. );
  855. } catch (Exception e) {
  856. log.warn("创建products表失败: {}", e.getMessage());
  857. }
  858. // 商品SKU表
  859. try {
  860. jdbcTemplate.execute(
  861. "CREATE TABLE IF NOT EXISTS product_skus (" +
  862. " id INT NOT NULL AUTO_INCREMENT," +
  863. " product_id INT NOT NULL COMMENT '商品ID'," +
  864. " sku_code VARCHAR(64) NOT NULL COMMENT 'SKU编码'," +
  865. " specs JSON NOT NULL COMMENT '规格JSON'," +
  866. " price DECIMAL(10,2) NOT NULL COMMENT 'SKU价格'," +
  867. " stock INT NOT NULL DEFAULT 0 COMMENT '库存'," +
  868. " original_price DECIMAL(10,2) COMMENT '原价/划线价'," +
  869. " image VARCHAR(512) COMMENT 'SKU图片'," +
  870. " enabled TINYINT(1) NOT NULL DEFAULT 1," +
  871. " create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP," +
  872. " update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  873. " PRIMARY KEY (id)," +
  874. " KEY idx_product_id (product_id)" +
  875. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU'"
  876. );
  877. log.info("已创建product_skus表");
  878. } catch (Exception e) {
  879. log.warn("创建product_skus表失败: {}", e.getMessage());
  880. }
  881. // 订单表
  882. try {
  883. jdbcTemplate.execute(
  884. "CREATE TABLE IF NOT EXISTS product_orders (" +
  885. " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  886. " order_no VARCHAR(64) NOT NULL UNIQUE," +
  887. " product_id BIGINT NOT NULL," +
  888. " product_name VARCHAR(200)," +
  889. " product_type VARCHAR(32)," +
  890. " buyer_id BIGINT NOT NULL," +
  891. " family_id BIGINT," +
  892. " quantity INT NOT NULL DEFAULT 1," +
  893. " unit_price INT NOT NULL COMMENT '单价(分)'," +
  894. " total_amount INT NOT NULL COMMENT '总金额(分)'," +
  895. " status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/completed/cancelled/refunded'," +
  896. " payment_method VARCHAR(32) COMMENT 'wechat/points/membership'," +
  897. " remark VARCHAR(500)," +
  898. " paid_at DATETIME," +
  899. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  900. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  901. ")"
  902. );
  903. } catch (Exception e) {
  904. log.warn("创建product_orders表失败: {}", e.getMessage());
  905. }
  906. // 商品浏览/停留时长埋点表(支撑运营看板「商品停留时长 Top」)
  907. try {
  908. jdbcTemplate.execute(
  909. "CREATE TABLE IF NOT EXISTS product_view_records (" +
  910. " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  911. " user_id BIGINT," +
  912. " product_id BIGINT NOT NULL," +
  913. " product_name VARCHAR(200)," +
  914. " product_type VARCHAR(32)," +
  915. " duration_seconds INT NOT NULL DEFAULT 0 COMMENT '本次浏览停留秒数'," +
  916. " source VARCHAR(32) COMMENT 'miniprogram/web'," +
  917. " view_at DATETIME," +
  918. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
  919. ")"
  920. );
  921. } catch (Exception e) {
  922. log.warn("创建product_view_records表失败: {}", e.getMessage());
  923. }
  924. // 系统配置表
  925. try {
  926. jdbcTemplate.execute(
  927. "CREATE TABLE IF NOT EXISTS sys_config (" +
  928. " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  929. " config_key VARCHAR(64) NOT NULL UNIQUE," +
  930. " config_value TEXT," +
  931. " description VARCHAR(500)," +
  932. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  933. ")"
  934. );
  935. } catch (Exception e) {
  936. log.warn("创建sys_config表失败: {}", e.getMessage());
  937. }
  938. // Migration: product_orders add logistics fields
  939. try {
  940. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN logistics_no VARCHAR(64) COMMENT '物流单号'");
  941. log.info("已添加logistics_no列到product_orders表");
  942. } catch (Exception e) {
  943. }
  944. try {
  945. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN logistics_company VARCHAR(100) COMMENT '物流公司'");
  946. log.info("已添加logistics_company列到product_orders表");
  947. } catch (Exception e) {
  948. }
  949. // SysConfig 种子数据
  950. try {
  951. insertSysConfigSeed("points_exchange_rate", "1", "积分兑换汇率(1积分=1分,即0.01元)");
  952. insertSysConfigSeed("points_deduct_cap", "0.5", "积分抵扣上限(50%)");
  953. insertSysConfigSeed("member_fee_standard", "1314", "标准会员年费(元)");
  954. insertSysConfigSeed("member_fee_premium", "1316800", "高级家庭会员年费(分)");
  955. insertSysConfigSeed("member_eligible_budget", "1314", "等值商品选择预算上限(元)");
  956. insertSysConfigSeed("renewal_discount_rate", "0.85", "续费折扣率");
  957. insertSysConfigSeed("member_discount_standard", "0.9", "标准会员折扣率");
  958. insertSysConfigSeed("member_discount_premium", "8000", "高级家庭会员商品折扣(bps)");
  959. insertSysConfigSeed("member_fee_family", "131400", "家庭会员年费(分)");
  960. insertSysConfigSeed("member_fee_family_yearly", "131400", "家庭会员年费(分)");
  961. insertSysConfigSeed("member_fee_family_monthly", "13140", "家庭会员月费(分)");
  962. insertSysConfigSeed("member_fee_family_quarterly", "32850", "家庭会员季费(分)");
  963. insertSysConfigSeed("member_upgrade_threshold", "131400", "自动升级家庭会员累计消费门槛(分)");
  964. insertSysConfigSeed("member_discount_family", "9000", "家庭会员商品折扣(bps)");
  965. insertSysConfigSeed("member_fee_premium_monthly", "131400", "高级家庭会员月费(分)");
  966. insertSysConfigSeed("member_fee_family_original_yearly", "199900", "家庭会员年费原价(分)");
  967. insertSysConfigSeed("member_fee_family_original", "199900", "家庭会员原价兜底(分)");
  968. insertSysConfigSeed("member_fee_premium_original", "1599900", "高级会员年费原价(分)");
  969. insertSysConfigSeed("member_fee_premium_original_monthly", "159900", "高级会员月费原价(分)");
  970. insertSysConfigSeed("commission_member_l1_rate", "2000", "推荐家庭会员L1佣金率(bps)");
  971. insertSysConfigSeed("commission_member_l2_rate", "500", "推荐家庭会员L2佣金率(bps)");
  972. insertSysConfigSeed("commission_product_profit_share", "1000", "下线商品购买利润分成(bps)");
  973. insertSysConfigSeed("commission_service_rate", "1000", "下线服务购买佣金率(bps)");
  974. insertSysConfigSeed("commission_event_fixed", "2000", "推荐新用户报名活动固定佣金(分)");
  975. insertSysConfigSeed("withdraw_min_amount", "10000", "最低提现金额(分)");
  976. insertSysConfigSeed("withdraw_tax_rate", "2000", "佣金税率(bps)");
  977. insertSysConfigSeed("withdraw_fee_free_threshold", "80000", "免税额度(分)");
  978. insertSysConfigSeed("article_energy_rate_normal", "1", "普通文章能量汇率(每100字)");
  979. insertSysConfigSeed("article_energy_rate_premium", "2", "优质文章能量汇率(每100字)");
  980. insertSysConfigSeed("article_energy_rate_original", "3", "原创文章能量汇率(每100字)");
  981. insertSysConfigSeed("article_reading_min_seconds", "60", "文章阅读达标最少秒数");
  982. insertSysConfigSeed("activity_checkin_default_points", "5", "活动签到默认积分");
  983. insertSysConfigSeed("health_checkin_points", "5", "健康打卡积分");
  984. insertSysConfigSeed("finance_checkin_points", "5", "理财打卡积分");
  985. insertSysConfigSeed("trial_enabled", "true", "是否启用会员试用");
  986. insertSysConfigSeed("trial_days", "7", "试用天数");
  987. insertSysConfigSeed("trial_features", "[\"free_activities\",\"free_courses\",\"teacher_consultation\"]", "试用期间可用功能");
  988. insertSysConfigSeed("member_pay_virtual_enabled", "1", "会员支付是否使用虚拟支付通道(1=虚拟,0=普通微信)");
  989. insertSysConfigSeed("gift_membership_threshold", "198000", "购买送会员商品累计达到该金额(分)赠送会员");
  990. insertSysConfigSeed("gift_membership_level", "FAMILY", "达到送会员额度后赠送的会员等级");
  991. insertSysConfigSeed("gift_membership_original_threshold", "1000000", "送会员商品原价达标阈值(分),达到此金额后送会员商品部分实付一口价");
  992. insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达标后一口实付价(分)");
  993. insertSysConfigSeed("health_dimension_weights", "{\"growth\":0.20,\"sleep\":0.15,\"vision\":0.10,\"immunity\":0.15,\"nutrition\":0.15,\"gut\":0.10,\"exercise\":0.15}", "7维健康→身能量权重配置");
  994. insertSysConfigSeed("health_dimension_sync_factor", "0.5", "7维健康同步至身能量系数");
  995. log.info("SysConfig 种子数据已加载");
  996. } catch (Exception e) {
  997. log.warn("SysConfig 种子数据初始化失败: {}", e.getMessage());
  998. }
  999. // Product 种子数据(5 个示例商品,覆盖全部 5 种 productType)
  1000. try {
  1001. insertProductSeed("亲子关系工作坊", "线下半日工作坊,重建家庭对话模式", "activity", 38800, "action", "all");
  1002. insertProductSeed("情绪管理21天训练营", "21天视频课程,识别并管理情绪", "course", 19900, "mind", "all");
  1003. insertProductSeed("肠道菌群检测套装", "家用检测 + 报告解读", "physical", 59900, "body", "budget");
  1004. insertProductSeed("儿童多模态认知测评", "在线测评 + 个性化建议", "digital", 9900, "wisdom", "none");
  1005. insertProductSeed("成长规划师1对1咨询", "60分钟专业规划", "service", 50000, "wealth", "budget");
  1006. log.info("Product 种子数据已加载");
  1007. } catch (Exception e) {
  1008. log.warn("Product 种子数据初始化失败: {}", e.getMessage());
  1009. }
  1010. // 活动种子数据(3 个示例活动,覆盖身/心/智三个维度)
  1011. try {
  1012. insertActivitySeed("亲子户外徒步探险", "周末亲子徒步活动,在自然中锻炼体魄、增进亲子关系,全程约5公里。",
  1013. "body", "offline", "published",
  1014. "2026-08-15 09:00:00", "2026-08-15 16:00:00", "北京奥林匹克森林公园", 50, 0, 0,
  1015. "{\"body\":80,\"mind\":0,\"wisdom\":0,\"action\":20,\"wealth\":0}");
  1016. insertActivitySeed("家庭正念静心工作坊", "专业导师带领的正念冥想体验,学习情绪调节和家庭沟通技巧。",
  1017. "mind", "offline", "published",
  1018. "2026-08-22 14:00:00", "2026-08-22 17:00:00", "北京市朝阳区心理成长中心", 30, 9900, 7900,
  1019. "{\"body\":0,\"mind\":80,\"wisdom\":10,\"action\":10,\"wealth\":0}");
  1020. insertActivitySeed("少儿科学思维探索营", "通过动手实验激发孩子对科学的兴趣,培养逻辑思维与创造力。",
  1021. "wisdom", "online", "published",
  1022. "2026-09-05 10:00:00", "2026-09-05 12:00:00", "线上直播", 200, 19900, 15900,
  1023. "{\"body\":0,\"mind\":0,\"wisdom\":70,\"action\":30,\"wealth\":0}");
  1024. log.info("Activity 种子数据已加载");
  1025. } catch (Exception e) {
  1026. log.warn("Activity 种子数据初始化失败: {}", e.getMessage());
  1027. }
  1028. // 方案规则种子数据(主链路 P0-3:模板包 + 方案生成规则)
  1029. try {
  1030. Long pkgFocus = ensureTemplatePackage("专注力提升方案模板",
  1031. "针对注意力维度低分(0-60)的14天专注力提升训练", 14, "专注力");
  1032. Long pkgKeep = ensureTemplatePackage("注意力巩固方案模板",
  1033. "针对注意力维度正常(61-100)的14天巩固训练", 14, "注意力");
  1034. ensureTemplateItems(pkgFocus, new String[][]{
  1035. {"舒尔特方格训练", "每日5分钟舒尔特方格,提升视觉专注", "5", "daily"},
  1036. {"静坐冥想", "每日10分钟静坐冥想,训练抗干扰能力", "3", "daily"},
  1037. {"番茄钟作业法", "使用番茄钟完成当日作业,25分钟一节", "5", "daily"}
  1038. });
  1039. ensureTemplateItems(pkgKeep, new String[][]{
  1040. {"专注力保持打卡", "每日记录专注时长,保持良好习惯", "3", "daily"},
  1041. {"优质睡眠打卡", "每日21:30前入睡,保障注意力恢复", "3", "daily"},
  1042. {"亲子共读", "每周3次亲子共读,培养持续专注", "5", "weekly"}
  1043. });
  1044. ensurePlanRule("attention", 0, 60, pkgFocus, 1);
  1045. ensurePlanRule("attention", 61, 100, pkgKeep, 2);
  1046. log.info("方案规则种子数据已加载: 模板包 focus={}, keep={}", pkgFocus, pkgKeep);
  1047. } catch (Exception e) {
  1048. log.warn("方案规则种子数据初始化失败: {}", e.getMessage());
  1049. }
  1050. // 报告解析中台:注册菌群检测报告类型(主链路 P0-4)
  1051. try {
  1052. Integer count = jdbcTemplate.queryForObject(
  1053. "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, "gut_flora");
  1054. if (count == null || count == 0) {
  1055. jdbcTemplate.update(
  1056. "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " +
  1057. "min_confidence, is_active, source, review_status, version, created_at, updated_at) " +
  1058. "VALUES ('gut_flora', 'gut_flora', '菌群检测', '募极生物/北京肠道菌群检测报告', " +
  1059. "'gut_flora/extract_gut_flora.py', 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())");
  1060. log.info("已注册报告类型 gut_flora");
  1061. } else {
  1062. log.info("报告类型 gut_flora 已存在,跳过");
  1063. }
  1064. } catch (Exception e) {
  1065. log.warn("报告类型注册失败: {}", e.getMessage());
  1066. }
  1067. // 文章种子数据(3 个示例文章,覆盖学习力/情绪管理/亲子教育分类)
  1068. try {
  1069. insertArticleSeed("如何培养孩子的自主学习力",
  1070. "从内在动机出发,帮助孩子建立自主学习习惯的实用方法指南。",
  1071. "article", 4L, "wisdom", "published", "曹老师",
  1072. "normal", 5, 1800,
  1073. "{\"body\":0,\"mind\":0,\"wisdom\":60,\"action\":40,\"wealth\":0}");
  1074. insertArticleSeed("儿童情绪管理的五个关键步骤",
  1075. "当孩子情绪爆发时,家长应该如何应对?本文提供一套可操作的情绪管理框架。",
  1076. "article", 2L, "mind", "published", "李老师",
  1077. "premium", 4, 2200,
  1078. "{\"body\":0,\"mind\":80,\"wisdom\":10,\"action\":10,\"wealth\":0}");
  1079. insertArticleSeed("亲子运动的正确打开方式",
  1080. "不同年龄段孩子的运动建议,让全家人在快乐中收获健康。",
  1081. "article", 3L, "action", "published", "王老师",
  1082. "normal", 3, 1500,
  1083. "{\"body\":40,\"mind\":0,\"wisdom\":0,\"action\":60,\"wealth\":0}");
  1084. log.info("Article 种子数据已加载");
  1085. } catch (Exception e) {
  1086. log.warn("Article 种子数据初始化失败: {}", e.getMessage());
  1087. }
  1088. // 补充商品种子数据(2 个,丰富商品库)
  1089. try {
  1090. insertProductSeed("少儿专注力训练教具盒", "含舒尔特方格、记忆卡片等8种专注力训练工具,适合5-12岁儿童。", "physical", 16800, "wisdom", "all");
  1091. insertProductSeed("家庭年度VIP成长会员", "一年内畅享全部线上课程、活动折扣及规划师咨询服务。", "digital", 298000, "action", "all");
  1092. log.info("补充 Product 种子数据已加载");
  1093. } catch (Exception e) {
  1094. log.warn("补充 Product 种子数据初始化失败: {}", e.getMessage());
  1095. }
  1096. // ==================== Phase 2: 内容区块可见性配置 ====== // 内容区块配置表
  1097. try {
  1098. jdbcTemplate.execute(
  1099. "CREATE TABLE IF NOT EXISTS content_sections (" +
  1100. " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  1101. " page_key VARCHAR(50) NOT NULL," +
  1102. " section_key VARCHAR(50) NOT NULL," +
  1103. " title VARCHAR(100)," +
  1104. " allowed_roles VARCHAR(255) DEFAULT '[\"anonymous\"]'," +
  1105. " sort_order INT DEFAULT 0," +
  1106. " status VARCHAR(20) DEFAULT 'active'," +
  1107. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  1108. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  1109. ")"
  1110. );
  1111. log.info("content_sections 表已创建");
  1112. } catch (Exception e) {
  1113. log.warn("创建 content_sections 表失败: {}", e.getMessage());
  1114. }
  1115. // ContentSection 种子数据
  1116. try {
  1117. insertContentSectionSeed("mind", "func_entries", "功能入口", "[\"anonymous\"]", 1);
  1118. insertContentSectionSeed("mind", "daily_tip", "每日心理", "[\"anonymous\"]", 2);
  1119. insertContentSectionSeed("mind", "recommended_reading", "推荐阅读", "[\"anonymous\"]", 3);
  1120. insertContentSectionSeed("mind", "premium_content", "会员专属内容", "[\"parent\",\"teacher\"]", 4);
  1121. insertContentSectionSeed("mind", "mind_tasks", "心智任务", "[\"parent\",\"child\"]", 5);
  1122. insertContentSectionSeed("mind", "related_activities", "相关活动", "[\"parent\",\"child\"]", 6);
  1123. insertContentSectionSeed("mind", "purchased_courses", "已购课程", "[\"parent\",\"child\"]", 7);
  1124. insertContentSectionSeed("body", "func_entries", "功能入口", "[\"anonymous\"]", 1);
  1125. insertContentSectionSeed("body", "daily_stats", "今日数据", "[\"anonymous\"]", 2);
  1126. insertContentSectionSeed("body", "health_tips", "健康小贴士", "[\"anonymous\"]", 3);
  1127. insertContentSectionSeed("body", "teacher_tips", "规划师推荐", "[\"teacher\"]", 4);
  1128. log.info("ContentSection 种子数据已加载");
  1129. } catch (Exception e) {
  1130. log.warn("ContentSection 种子数据初始化失败: {}", e.getMessage());
  1131. }
  1132. // ==================== 文章内容系统种子数据 ====== // 文章分类种子数据
  1133. try {
  1134. Integer catCount = jdbcTemplate.queryForObject(
  1135. "SELECT COUNT(*) FROM article_categories WHERE id IN (1,2,3,4,5)", Integer.class);
  1136. if (catCount == null || catCount == 0) {
  1137. jdbcTemplate.execute("INSERT IGNORE INTO article_categories (id, name, icon, color, sort_order, status) VALUES " +
  1138. "(1, '心理健康', '🧠', '#5B9BD5', 1, 1), " +
  1139. "(2, '情绪管理', '💖', '#FF6B35', 2, 1), " +
  1140. "(3, '亲子教育', '👨‍👩‍👧‍👦', '#4CAF50', 3, 1), " +
  1141. "(4, '学习力', '📚', '#FFD700', 4, 1), " +
  1142. "(5, '专注力', '🎯', '#8D6E63', 5, 1)");
  1143. log.info("文章分类种子数据已加载");
  1144. }
  1145. } catch (Exception e) {
  1146. log.warn("文章分类种子数据初始化失败: {}", e.getMessage());
  1147. }
  1148. // ==================== 佣金系统 ====== // 迁移: users表添加推荐人字段
  1149. try {
  1150. Integer exists = jdbcTemplate.queryForObject(
  1151. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'referrer_id'",
  1152. Integer.class);
  1153. if (exists == null || exists == 0) {
  1154. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN referrer_id BIGINT DEFAULT NULL COMMENT '推荐人ID'");
  1155. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN referral_code VARCHAR(32) DEFAULT NULL COMMENT '个人邀请码'");
  1156. jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_referral_code (referral_code)");
  1157. log.info("已添加佣金字段到users表");
  1158. } else {
  1159. log.info("佣金字段已存在,跳过");
  1160. }
  1161. } catch (Exception e) {
  1162. log.warn("检查/添加佣金字段失败: {}", e.getMessage());
  1163. }
  1164. // 迁移: products表添加利润率字段
  1165. try {
  1166. Integer exists = jdbcTemplate.queryForObject(
  1167. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'products' AND COLUMN_NAME = 'profit_rate'",
  1168. Integer.class);
  1169. if (exists == null || exists == 0) {
  1170. jdbcTemplate.execute("ALTER TABLE products ADD COLUMN profit_rate INT DEFAULT 0 COMMENT '平台利润率(bps,100=1%)'");
  1171. log.info("已添加profit_rate列到products表");
  1172. } else {
  1173. log.info("profit_rate列已存在,跳过");
  1174. }
  1175. } catch (Exception e) {
  1176. log.warn("检查/添加profit_rate列失败: {}", e.getMessage());
  1177. }
  1178. // 新表: commission_records (佣金记录)
  1179. try {
  1180. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS commission_records (" +
  1181. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1182. "order_id BIGINT NOT NULL COMMENT '订单ID', " +
  1183. "order_type VARCHAR(32) NOT NULL COMMENT '订单类型: membership/product/assessment/package', " +
  1184. "referrer_id BIGINT NOT NULL COMMENT '推荐人ID', " +
  1185. "buyer_id BIGINT NOT NULL COMMENT '购买人ID', " +
  1186. "commission_type VARCHAR(16) NOT NULL COMMENT '佣金类型: member/profit', " +
  1187. "order_amount INT NOT NULL COMMENT '订单金额(分)', " +
  1188. "profit_rate INT DEFAULT 0 COMMENT '利润率(bps,100=1%)', " +
  1189. "commission_amount INT NOT NULL COMMENT '佣金金额(分)', " +
  1190. "status VARCHAR(16) NOT NULL DEFAULT 'settled' COMMENT '状态: pending/settled', " +
  1191. "remark VARCHAR(255) DEFAULT NULL, " +
  1192. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  1193. "INDEX idx_referrer (referrer_id), " +
  1194. "INDEX idx_buyer (buyer_id), " +
  1195. "INDEX idx_order (order_id, order_type)" +
  1196. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  1197. log.info("已创建commission_records表");
  1198. } catch (Exception e) {
  1199. log.warn("创建commission_records表失败: {}", e.getMessage());
  1200. }
  1201. // 新表: withdrawal_requests (提现申请)
  1202. try {
  1203. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS withdrawal_requests (" +
  1204. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1205. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  1206. "amount INT NOT NULL COMMENT '提现金额(分)', " +
  1207. "account_info TEXT COMMENT '账户信息(JSON)', " +
  1208. "status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/approved/rejected', " +
  1209. "audit_by BIGINT DEFAULT NULL COMMENT '审核人ID', " +
  1210. "audit_at DATETIME DEFAULT NULL COMMENT '审核时间', " +
  1211. "remark VARCHAR(255) DEFAULT NULL COMMENT '审核备注', " +
  1212. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  1213. "INDEX idx_user (user_id), " +
  1214. "INDEX idx_status (status)" +
  1215. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  1216. log.info("已创建withdrawal_requests表");
  1217. } catch (Exception e) {
  1218. log.warn("创建withdrawal_requests表失败: {}", e.getMessage());
  1219. }
  1220. // ==================== 会员体系 & 佣金扩展 ====== // 迁移: users表添加会员字段
  1221. try {
  1222. Integer exists = jdbcTemplate.queryForObject(
  1223. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'member_level'",
  1224. Integer.class);
  1225. if (exists == null || exists == 0) {
  1226. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN member_level VARCHAR(20) DEFAULT 'FREE' COMMENT '会员等级: FREE/FAMILY/PROVIDER'");
  1227. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN member_expire_time DATETIME DEFAULT NULL COMMENT '家庭会员到期时间'");
  1228. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN total_consumption BIGINT DEFAULT 0 COMMENT '累计消费金额(分)'");
  1229. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN upgrade_from_consumption TINYINT DEFAULT 0 COMMENT '是否通过累计消费自动升级'");
  1230. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN direct_count INT DEFAULT 0 COMMENT '直接推荐人数'");
  1231. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN indirect_count INT DEFAULT 0 COMMENT '间接推荐人数'");
  1232. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN total_commission_earned BIGINT DEFAULT 0 COMMENT '累计获得佣金(分)'");
  1233. log.info("已添加会员字段到users表");
  1234. } else {
  1235. log.info("会员字段已存在,跳过");
  1236. }
  1237. } catch (Exception e) {
  1238. log.warn("检查/添加会员字段失败: {}", e.getMessage());
  1239. }
  1240. // 迁移: commission_records表添加扩展字段
  1241. try {
  1242. Integer exists = jdbcTemplate.queryForObject(
  1243. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'commission_records' AND COLUMN_NAME = 'level'",
  1244. Integer.class);
  1245. if (exists == null || exists == 0) {
  1246. jdbcTemplate.execute("ALTER TABLE commission_records ADD COLUMN level INT DEFAULT 1 COMMENT '佣金层级: 1=直接推荐, 2=间接推荐'");
  1247. jdbcTemplate.execute("ALTER TABLE commission_records ADD COLUMN settled_at DATETIME DEFAULT NULL COMMENT '结算时间'");
  1248. jdbcTemplate.execute("ALTER TABLE commission_records ADD COLUMN withdrawn_at DATETIME DEFAULT NULL COMMENT '提现时间'");
  1249. log.info("已添加扩展字段到commission_records表");
  1250. } else {
  1251. log.info("commission_records扩展字段已存在,跳过");
  1252. }
  1253. } catch (Exception e) {
  1254. log.warn("检查/添加commission_records扩展字段失败: {}", e.getMessage());
  1255. }
  1256. // 新表: member_upgrade_records (会员升级记录)
  1257. try {
  1258. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_upgrade_records (" +
  1259. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1260. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  1261. "from_level VARCHAR(20) NOT NULL COMMENT '升级前等级', " +
  1262. "to_level VARCHAR(20) NOT NULL COMMENT '升级后等级', " +
  1263. "upgrade_type VARCHAR(20) NOT NULL COMMENT '升级类型: pay/consumption/admin', " +
  1264. "order_id BIGINT DEFAULT NULL COMMENT '关联支付订单ID', " +
  1265. "consumption_amount BIGINT DEFAULT NULL COMMENT '触发升级的累计消费金额', " +
  1266. "expire_time DATETIME DEFAULT NULL COMMENT '家庭会员到期时间', " +
  1267. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  1268. "INDEX idx_user (user_id), " +
  1269. "INDEX idx_order (order_id)" +
  1270. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员升级记录'");
  1271. log.info("已创建member_upgrade_records表");
  1272. } catch (Exception e) {
  1273. log.warn("创建member_upgrade_records表失败: {}", e.getMessage());
  1274. }
  1275. // ==================== 文章内容系统 ====== // 文章分类表
  1276. try {
  1277. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_categories (" +
  1278. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1279. "name VARCHAR(50) NOT NULL COMMENT '分类名', " +
  1280. "icon VARCHAR(20) DEFAULT '' COMMENT '图标emoji', " +
  1281. "color VARCHAR(20) DEFAULT '#5B9BD5' COMMENT '标识色', " +
  1282. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  1283. "status TINYINT DEFAULT 1 COMMENT '1启用/0禁用'" +
  1284. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章分类'");
  1285. log.info("已创建article_categories表");
  1286. } catch (Exception e) {
  1287. log.warn("创建article_categories表失败: {}", e.getMessage());
  1288. }
  1289. // 文章表
  1290. try {
  1291. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS articles (" +
  1292. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1293. "category_id BIGINT DEFAULT 0 COMMENT '所属分类ID', " +
  1294. "title VARCHAR(200) NOT NULL COMMENT '标题', " +
  1295. "summary VARCHAR(500) DEFAULT '' COMMENT '摘要', " +
  1296. "cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图URL', " +
  1297. "content LONGTEXT COMMENT '富文本内容', " +
  1298. "tags VARCHAR(200) DEFAULT '' COMMENT '标签JSON数组', " +
  1299. "author VARCHAR(100) DEFAULT '浠艾福' COMMENT '作者', " +
  1300. "read_time INT DEFAULT 0 COMMENT '预计阅读分钟数', " +
  1301. "related_dimensions VARCHAR(100) DEFAULT '' COMMENT '关联五维JSON数组', " +
  1302. "visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private', " +
  1303. "visible_to TEXT COMMENT '私密指定人群JSON', " +
  1304. "status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/published/archived', " +
  1305. "is_featured TINYINT DEFAULT 0 COMMENT '1精选/0普通', " +
  1306. "published_at DATETIME DEFAULT NULL COMMENT '发布时间', " +
  1307. "view_count INT DEFAULT 0 COMMENT '浏览次数', " +
  1308. "created_by BIGINT DEFAULT 0 COMMENT '发布人adminID', " +
  1309. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1310. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  1311. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章'");
  1312. log.info("已创建articles表");
  1313. } catch (Exception e) {
  1314. log.warn("创建articles表失败: {}", e.getMessage());
  1315. }
  1316. // 迁移: articles表补充新字段(兼容旧表)
  1317. try {
  1318. Integer colExists = jdbcTemplate.queryForObject(
  1319. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'visibility'",
  1320. Integer.class);
  1321. if (colExists == null || colExists == 0) {
  1322. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private' AFTER related_dimensions");
  1323. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN visible_to TEXT COMMENT '私密指定人群JSON' AFTER visibility");
  1324. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN tags VARCHAR(200) DEFAULT '' COMMENT '标签JSON数组' AFTER content");
  1325. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN related_dimensions VARCHAR(100) DEFAULT '' COMMENT '关联五维JSON数组' AFTER read_time");
  1326. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN is_featured TINYINT DEFAULT 0 COMMENT '1精选/0普通' AFTER status");
  1327. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN published_at DATETIME DEFAULT NULL COMMENT '发布时间' AFTER is_featured");
  1328. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN view_count INT DEFAULT 0 COMMENT '浏览次数' AFTER published_at");
  1329. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN created_by BIGINT DEFAULT 0 COMMENT '发布人adminID' AFTER view_count");
  1330. log.info("已迁移articles表补充字段");
  1331. } else {
  1332. log.info("articles表字段已完整,跳过迁移");
  1333. }
  1334. } catch (Exception e) {
  1335. log.warn("articles表字段迁移失败: {}", e.getMessage());
  1336. }
  1337. // 迁移: articles表补充知识中心新字段(content_type/difficulty_level/dimension_ids)
  1338. try {
  1339. Integer colExists = jdbcTemplate.queryForObject(
  1340. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'content_type'",
  1341. Integer.class);
  1342. if (colExists == null || colExists == 0) {
  1343. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN content_type VARCHAR(20) DEFAULT 'article' COMMENT '内容类型: article/knowledge/course/tip' AFTER article_type");
  1344. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN difficulty_level TINYINT DEFAULT 0 COMMENT '难度等级 1-5' AFTER content_type");
  1345. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN dimension_ids TEXT COMMENT '关联维度ID列表JSON' AFTER related_dimensions");
  1346. log.info("已迁移articles表知识中心新字段");
  1347. } else {
  1348. log.info("articles表知识中心字段已完整,跳过迁移");
  1349. }
  1350. } catch (Exception e) {
  1351. log.warn("articles表知识中心字段迁移失败: {}", e.getMessage());
  1352. }
  1353. // 文章标签关联表
  1354. try {
  1355. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_tags (" +
  1356. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1357. "article_id BIGINT NOT NULL COMMENT '文章ID', " +
  1358. "tag_id BIGINT NOT NULL COMMENT '标签ID(关联dan_knowledge_tag.id)', " +
  1359. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1360. "UNIQUE KEY uk_article_tag (article_id, tag_id)" +
  1361. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章-标签关联表'");
  1362. log.info("已创建article_tags表");
  1363. } catch (Exception e) {
  1364. log.warn("创建article_tags表失败: {}", e.getMessage());
  1365. }
  1366. // ==================== 五维能量系统(账本模式) ====== // 维度定义表
  1367. try {
  1368. jdbcTemplate.execute(
  1369. "CREATE TABLE IF NOT EXISTS energy_dimension (" +
  1370. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1371. "code VARCHAR(16) NOT NULL UNIQUE COMMENT '维度代码: body/mind/wisdom/action/wealth', " +
  1372. "name VARCHAR(20) NOT NULL COMMENT '维度名称: 身/心/智/行/富', " +
  1373. "icon VARCHAR(16) COMMENT '图标emoji', " +
  1374. "element VARCHAR(10) COMMENT '五行元素: 土/火/金/木/水', " +
  1375. "sort_order INT DEFAULT 0 COMMENT '显示顺序', " +
  1376. "status TINYINT DEFAULT 1 COMMENT '1启用/0禁用', " +
  1377. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
  1378. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量维度定义表'"
  1379. );
  1380. log.info("已创建energy_dimension表");
  1381. } catch (Exception e) {
  1382. log.warn("创建energy_dimension表失败: {}", e.getMessage());
  1383. }
  1384. // 服务-维度比例配置表
  1385. try {
  1386. jdbcTemplate.execute(
  1387. "CREATE TABLE IF NOT EXISTS energy_source_config (" +
  1388. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1389. "source_type VARCHAR(32) NOT NULL COMMENT '来源类型: task/activity/course/product/consultation', " +
  1390. "source_id BIGINT NOT NULL COMMENT '来源业务表ID', " +
  1391. "source_name VARCHAR(200) COMMENT '冗余名称', " +
  1392. "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
  1393. "ratio INT NOT NULL COMMENT '占比(bps,10000=100%)', " +
  1394. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1395. "INDEX idx_source (source_type, source_id)" +
  1396. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务-维度比例配置表'"
  1397. );
  1398. log.info("已创建energy_source_config表");
  1399. } catch (Exception e) {
  1400. log.warn("创建energy_source_config表失败: {}", e.getMessage());
  1401. }
  1402. // 购物车
  1403. try {
  1404. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cart_items (" +
  1405. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  1406. "user_id BIGINT NOT NULL COMMENT '用户ID'," +
  1407. "product_id BIGINT NOT NULL COMMENT '商品ID'," +
  1408. "quantity INT DEFAULT 1 COMMENT '数量'," +
  1409. "selected TINYINT(1) DEFAULT 1 COMMENT '是否选中'," +
  1410. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  1411. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  1412. "INDEX idx_user (user_id)," +
  1413. "INDEX idx_product (product_id)," +
  1414. "UNIQUE KEY uk_user_product (user_id, product_id)" +
  1415. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车'");
  1416. log.info("已创建cart_items表");
  1417. } catch (Exception e) {
  1418. log.warn("创建cart_items表失败: {}", e.getMessage());
  1419. }
  1420. try {
  1421. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shop_categories (" +
  1422. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  1423. "name VARCHAR(64) NOT NULL COMMENT '分类名称'," +
  1424. "parent_id BIGINT DEFAULT 0 COMMENT '父分类ID(0=根节点)'," +
  1425. "level INT DEFAULT 0 COMMENT '层级(0/1/2/3)'," +
  1426. "sort INT DEFAULT 0 COMMENT '排序'," +
  1427. "image VARCHAR(512) COMMENT '分类图标'," +
  1428. "enabled TINYINT(1) DEFAULT 1 COMMENT '启用'," +
  1429. "dimension_codes VARCHAR(128) COMMENT '维度编码(逗号分隔: body,mind,heart)'," +
  1430. "product_types VARCHAR(128) COMMENT '商品类型(逗号分隔: PHYSICAL,COURSE,ASSESSMENT)'," +
  1431. "min_visible_level VARCHAR(50) COMMENT '最低可见会员级别编码(NULL=全部可见)'," +
  1432. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  1433. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  1434. "INDEX idx_parent (parent_id)," +
  1435. "INDEX idx_sort (sort)" +
  1436. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品类目'");
  1437. log.info("已创建shop_categories表");
  1438. } catch (Exception e) {
  1439. log.warn("创建shop_categories表失败: {}", e.getMessage());
  1440. }
  1441. try {
  1442. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS distribution_systems (" +
  1443. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  1444. "name VARCHAR(64) NOT NULL COMMENT '体系名称'," +
  1445. "description VARCHAR(255) COMMENT '描述'," +
  1446. "owner_id BIGINT COMMENT '创建者ID'," +
  1447. "profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)'," +
  1448. "enabled TINYINT(1) DEFAULT 1 COMMENT '启用'," +
  1449. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  1450. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  1451. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售体系'");
  1452. log.info("已创建distribution_systems表");
  1453. } catch (Exception e) {
  1454. log.warn("创建distribution_systems表失败: {}", e.getMessage());
  1455. }
  1456. try {
  1457. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS distribution_relations (" +
  1458. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  1459. "system_id BIGINT NOT NULL COMMENT '体系ID'," +
  1460. "user_id BIGINT NOT NULL COMMENT '用户ID'," +
  1461. "parent_id BIGINT COMMENT '上级用户ID(null=根)'," +
  1462. "depth INT DEFAULT 0 COMMENT '层级深度'," +
  1463. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  1464. "INDEX idx_system (system_id)," +
  1465. "INDEX idx_user (user_id)," +
  1466. "INDEX idx_parent (parent_id)" +
  1467. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售关系'");
  1468. log.info("已创建distribution_relations表");
  1469. } catch (Exception e) {
  1470. log.warn("创建distribution_relations表失败: {}", e.getMessage());
  1471. }
  1472. try {
  1473. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS after_sales_requests (" +
  1474. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  1475. "order_id BIGINT NOT NULL COMMENT '订单ID'," +
  1476. "order_no VARCHAR(32) NOT NULL COMMENT '订单号'," +
  1477. "user_id BIGINT NOT NULL COMMENT '用户ID'," +
  1478. "type VARCHAR(32) NOT NULL COMMENT '类型: REFUND/RETURN_REFUND'," +
  1479. "reason VARCHAR(255) COMMENT '原因'," +
  1480. "description TEXT COMMENT '描述'," +
  1481. "images TEXT COMMENT '凭证图片(JSON数组)'," +
  1482. "refund_amount INT DEFAULT 0 COMMENT '退款金额(分)'," +
  1483. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected/completed'," +
  1484. "reject_reason VARCHAR(255) COMMENT '驳回原因'," +
  1485. "handled_by BIGINT COMMENT '处理人ID'," +
  1486. "handled_at DATETIME COMMENT '处理时间'," +
  1487. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  1488. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  1489. "INDEX idx_order (order_id)," +
  1490. "INDEX idx_user (user_id)," +
  1491. "INDEX idx_status (status)" +
  1492. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='售后申请'");
  1493. log.info("已创建after_sales_requests表");
  1494. } catch (Exception e) {
  1495. log.warn("创建after_sales_requests表失败: {}", e.getMessage());
  1496. }
  1497. try {
  1498. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shop_discount_rules (" +
  1499. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  1500. "name VARCHAR(64) NOT NULL COMMENT '规则名称'," +
  1501. "type VARCHAR(32) NOT NULL COMMENT '类型: PERCENT/FIXED/THRESHOLD'," +
  1502. "value INT NOT NULL COMMENT '折扣值(PERCENT=百分比数字, FIXED/THRESHOLD=金额分)'," +
  1503. "threshold_amount INT COMMENT '满减门槛(分,仅THRESHOLD)'," +
  1504. "category_id BIGINT COMMENT '适用类目ID(null=全部)'," +
  1505. "distribution_system_id BIGINT COMMENT '适用体系ID(null=全部)'," +
  1506. "product_ids TEXT COMMENT '适用商品ID(逗号分隔,null=全部)'," +
  1507. "start_time DATETIME COMMENT '开始时间'," +
  1508. "end_time DATETIME COMMENT '结束时间'," +
  1509. "max_uses INT DEFAULT 0 COMMENT '最大使用次数(0=不限)'," +
  1510. "used_count INT DEFAULT 0 COMMENT '已使用次数'," +
  1511. "enabled TINYINT(1) DEFAULT 1 COMMENT '启用'," +
  1512. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  1513. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  1514. "INDEX idx_category (category_id)," +
  1515. "INDEX idx_enabled (enabled)" +
  1516. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='折扣规则'");
  1517. log.info("已创建shop_discount_rules表");
  1518. } catch (Exception e) {
  1519. log.warn("创建shop_discount_rules表失败: {}", e.getMessage());
  1520. }
  1521. // 电商扩展字段 - products表
  1522. try {
  1523. jdbcTemplate.execute("ALTER TABLE products ADD COLUMN category_id BIGINT COMMENT '商品类目ID'");
  1524. log.info("已添加category_id列到products表");
  1525. } catch (Exception e) {
  1526. // 列已存在,忽略错误
  1527. }
  1528. try {
  1529. jdbcTemplate.execute("ALTER TABLE products ADD COLUMN distribution_system_id BIGINT COMMENT '销售体系ID'");
  1530. log.info("已添加distribution_system_id列到products表");
  1531. } catch (Exception e) {
  1532. // 列已存在,忽略错误
  1533. }
  1534. // 电商扩展字段 - product_orders表
  1535. try {
  1536. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN discount_amount INT DEFAULT 0 COMMENT '优惠金额(分)'");
  1537. log.info("已添加discount_amount列到product_orders表");
  1538. } catch (Exception e) {
  1539. // 列已存在,忽略错误
  1540. }
  1541. try {
  1542. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN coupon_id BIGINT COMMENT '优惠券ID'");
  1543. log.info("已添加coupon_id列到product_orders表");
  1544. } catch (Exception e) {
  1545. // 列已存在,忽略错误
  1546. }
  1547. try {
  1548. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN cancel_at DATETIME COMMENT '取消时间'");
  1549. log.info("已添加cancel_at列到product_orders表");
  1550. } catch (Exception e) {
  1551. // 列已存在,忽略错误
  1552. }
  1553. try {
  1554. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN transaction_id VARCHAR(64) COMMENT '微信支付交易号'");
  1555. log.info("已添加transaction_id列到product_orders表");
  1556. } catch (Exception e) {
  1557. // 列已存在,忽略错误
  1558. }
  1559. try {
  1560. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN cover_image VARCHAR(512) COMMENT '商品封面图快照'");
  1561. log.info("已添加cover_image列到product_orders表");
  1562. } catch (Exception e) {
  1563. // 列已存在,忽略错误
  1564. }
  1565. try {
  1566. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN address_snapshot TEXT COMMENT '收货地址快照(JSON)'");
  1567. log.info("已添加address_snapshot列到product_orders表");
  1568. } catch (Exception e) {
  1569. // 列已存在,忽略错误
  1570. }
  1571. // 能量流水表
  1572. try {
  1573. jdbcTemplate.execute(
  1574. "CREATE TABLE IF NOT EXISTS energy_log (" +
  1575. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1576. "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
  1577. "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
  1578. "amount INT NOT NULL COMMENT '变动量(正=获得,负=消耗)', " +
  1579. "balance_after INT NOT NULL COMMENT '变动后余额', " +
  1580. "source_type VARCHAR(32) COMMENT '来源类型: task/product/medical_report/daily_record', " +
  1581. "source_id BIGINT COMMENT '来源ID', " +
  1582. "ref_id BIGINT COMMENT '关联旧流水ID(覆盖时标记)', " +
  1583. "expires_at DATETIME COMMENT '过期时间(空=永不过期)', " +
  1584. "description VARCHAR(500) COMMENT '描述', " +
  1585. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1586. "INDEX idx_child_dim (child_id, dimension_id), " +
  1587. "INDEX idx_created (created_at), " +
  1588. "INDEX idx_source (source_type, source_id)" +
  1589. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量流水表'"
  1590. );
  1591. log.info("已创建energy_log表");
  1592. } catch (Exception e) {
  1593. log.warn("创建energy_log表失败: {}", e.getMessage());
  1594. }
  1595. // 维度余额表
  1596. try {
  1597. jdbcTemplate.execute(
  1598. "CREATE TABLE IF NOT EXISTS energy_balance (" +
  1599. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1600. "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
  1601. "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
  1602. "balance INT DEFAULT 0 COMMENT '当前能量值(可为负)', " +
  1603. "total_earned INT DEFAULT 0 COMMENT '累计获得', " +
  1604. "total_spent INT DEFAULT 0 COMMENT '累计消耗', " +
  1605. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  1606. "UNIQUE KEY uk_child_dim (child_id, dimension_id)" +
  1607. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度余额表'"
  1608. );
  1609. log.info("已创建energy_balance表");
  1610. } catch (Exception e) {
  1611. log.warn("创建energy_balance表失败: {}", e.getMessage());
  1612. }
  1613. // 维度种子数据(按相生链: 智→富→行→心→身)
  1614. try {
  1615. Integer dimCount = jdbcTemplate.queryForObject(
  1616. "SELECT COUNT(*) FROM energy_dimension", Integer.class);
  1617. if (dimCount == null || dimCount == 0) {
  1618. jdbcTemplate.execute("INSERT INTO energy_dimension (code, name, icon, element, sort_order, status) VALUES " +
  1619. "('mind', '心', '🔥', '火', 4, 1), " +
  1620. "('body', '身', '🌏', '土', 5, 1), " +
  1621. "('wisdom', '智', '⚔️', '金', 1, 1), " +
  1622. "('action', '行', '🌿', '木', 3, 1), " +
  1623. "('wealth', '富', '💧', '水', 2, 1)");
  1624. log.info("能量维度种子数据已初始化");
  1625. }
  1626. } catch (Exception e) {
  1627. log.warn("初始化能量维度种子数据失败: {}", e.getMessage());
  1628. }
  1629. // 为已有商品插入默认比例配置(与Product.domain一致)
  1630. try {
  1631. Integer productConfigCount = jdbcTemplate.queryForObject(
  1632. "SELECT COUNT(*) FROM energy_source_config WHERE source_type = 'product'", Integer.class);
  1633. if (productConfigCount == null || productConfigCount == 0) {
  1634. String[][] domainMapping = {
  1635. {"action", "action"}, {"mind", "mind"}, {"body", "body"},
  1636. {"wisdom", "wisdom"}, {"wealth", "wealth"}
  1637. };
  1638. for (String[] mapping : domainMapping) {
  1639. jdbcTemplate.execute(
  1640. "INSERT INTO energy_source_config (source_type, source_id, source_name, dimension_id, ratio) " +
  1641. "SELECT 'product', p.id, p.name, d.id, 10000 " +
  1642. "FROM products p " +
  1643. "JOIN energy_dimension d ON d.code = '" + mapping[1] + "' " +
  1644. "WHERE p.domain = '" + mapping[0] + "' " +
  1645. "AND NOT EXISTS (" +
  1646. " SELECT 1 FROM energy_source_config esc " +
  1647. " WHERE esc.source_type = 'product' AND esc.source_id = p.id" +
  1648. ")"
  1649. );
  1650. }
  1651. log.info("已有商品能量比例配置已初始化");
  1652. }
  1653. } catch (Exception e) {
  1654. log.warn("初始化商品能量比例配置失败: {}", e.getMessage());
  1655. }
  1656. // ==================== 精准营养 P0: health_report_survey =============
  1657. try {
  1658. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_report_survey (" +
  1659. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1660. "report_id BIGINT NOT NULL COMMENT '关联报告ID', " +
  1661. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  1662. "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
  1663. "survey_data JSON COMMENT '调研问卷结果JSON', " +
  1664. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/completed', " +
  1665. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1666. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  1667. "UNIQUE KEY uk_report (report_id)" +
  1668. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告调研问卷'");
  1669. log.info("已创建 health_report_survey 表");
  1670. } catch (Exception e) {
  1671. log.warn("创建 health_report_survey 表失败: {}", e.getMessage());
  1672. }
  1673. // ==================== 精准营养 P0: health_gut_flora + health_disease_risk =============
  1674. try {
  1675. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_gut_flora (" +
  1676. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1677. "report_id BIGINT NOT NULL COMMENT '关联报告ID', " +
  1678. "bacteria_name VARCHAR(100) NULL COMMENT '菌种名称', " +
  1679. "bacteria_value VARCHAR(50) COMMENT '菌种丰度值', " +
  1680. "normal_range VARCHAR(100) COMMENT '正常范围', " +
  1681. "status VARCHAR(20) COMMENT '状态: 正常/偏低/偏高', " +
  1682. "category VARCHAR(50) COMMENT '分类: 有益菌/有害菌/中性菌', " +
  1683. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  1684. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1685. "INDEX idx_report (report_id)" +
  1686. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菌群检测专项数据'");
  1687. log.info("已创建 health_gut_flora 表");
  1688. } catch (Exception e) {
  1689. log.warn("创建 health_gut_flora 表失败: {}", e.getMessage());
  1690. }
  1691. try {
  1692. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_disease_risk (" +
  1693. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1694. "report_id BIGINT NOT NULL COMMENT '关联报告ID', " +
  1695. "disease_name VARCHAR(100) NOT NULL COMMENT '疾病名称', " +
  1696. "risk_value VARCHAR(50) COMMENT '风险值(0-1)', " +
  1697. "risk_level VARCHAR(20) COMMENT '风险等级: 高风险/需注意/低风险', " +
  1698. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1699. "INDEX idx_report (report_id)" +
  1700. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病风险评估数据'");
  1701. log.info("已创建 health_disease_risk 表");
  1702. } catch (Exception e) {
  1703. log.warn("创建 health_disease_risk 表失败: {}", e.getMessage());
  1704. }
  1705. // ==================== 迁移: health_reports 表添加新字段 =============
  1706. try {
  1707. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN balance_score INT DEFAULT NULL COMMENT '菌群平衡分'");
  1708. log.info("已添加balance_score列到health_reports表");
  1709. } catch (Exception e) {
  1710. // 列已存在,忽略错误
  1711. }
  1712. try {
  1713. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN diversity_score INT DEFAULT NULL COMMENT '菌群多样性分'");
  1714. log.info("已添加diversity_score列到health_reports表");
  1715. } catch (Exception e) {
  1716. // 列已存在,忽略错误
  1717. }
  1718. try {
  1719. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN beneficial_score INT DEFAULT NULL COMMENT '有益菌评分'");
  1720. log.info("已添加beneficial_score列到health_reports表");
  1721. } catch (Exception e) {
  1722. // 列已存在,忽略错误
  1723. }
  1724. try {
  1725. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN harmful_score INT DEFAULT NULL COMMENT '有害菌评分'");
  1726. log.info("已添加harmful_score列到health_reports表");
  1727. } catch (Exception e) {
  1728. // 列已存在,忽略错误
  1729. }
  1730. try {
  1731. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN core_genus_score INT DEFAULT NULL COMMENT '核心菌属评分'");
  1732. log.info("已添加core_genus_score列到health_reports表");
  1733. } catch (Exception e) {
  1734. // 列已存在,忽略错误
  1735. }
  1736. // 迁移: health_reports 表添加报告编号和姓名字段
  1737. try {
  1738. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN report_number VARCHAR(50) DEFAULT NULL COMMENT '报告编号'");
  1739. log.info("已添加report_number列到health_reports表");
  1740. } catch (Exception e) {
  1741. // 列已存在,忽略错误
  1742. }
  1743. try {
  1744. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN person_name VARCHAR(50) DEFAULT NULL COMMENT '被检测人姓名'");
  1745. log.info("已添加person_name列到health_reports表");
  1746. } catch (Exception e) {
  1747. // 列已存在,忽略错误
  1748. }
  1749. // ==================== 迁移: health_gut_flora 表添加新字段 =============
  1750. try {
  1751. jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN population_level VARCHAR(50) DEFAULT NULL COMMENT '处于人群%水平'");
  1752. log.info("已添加population_level列到health_gut_flora表");
  1753. } catch (Exception e) {
  1754. // 列已存在,忽略错误
  1755. }
  1756. try {
  1757. jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN detection_rate VARCHAR(50) DEFAULT NULL COMMENT '%正常人有检出'");
  1758. log.info("已添加detection_rate列到health_gut_flora表");
  1759. } catch (Exception e) {
  1760. // 列已存在,忽略错误
  1761. }
  1762. try {
  1763. jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN description TEXT DEFAULT NULL COMMENT '菌种说明'");
  1764. log.info("已添加description列到health_gut_flora表");
  1765. } catch (Exception e) {
  1766. // 列已存在,忽略错误
  1767. }
  1768. try {
  1769. jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN `level` VARCHAR(20) DEFAULT 'GENUS' COMMENT '级别: GENUS/SPECIES'");
  1770. log.info("已添加level列到health_gut_flora表");
  1771. } catch (Exception e) {
  1772. // 列已存在,忽略错误
  1773. }
  1774. // ==================== 迁移: guides 表统一 ====== // 迁移: guides 添加 type 字段(区分服务商类型)
  1775. try {
  1776. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN type VARCHAR(32) DEFAULT 'teacher' COMMENT '服务商类型: teacher/planner/activity_provider/product_supplier'");
  1777. log.info("已添加type列到guides表");
  1778. } catch (Exception e) {
  1779. // 列已存在,忽略错误
  1780. }
  1781. // 迁移: guides 添加 teachers 合并字段
  1782. try {
  1783. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN teacher_no VARCHAR(20) COMMENT '服务商编号'");
  1784. log.info("已添加teacher_no列到guides表");
  1785. } catch (Exception e) {
  1786. }
  1787. try {
  1788. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN title VARCHAR(50) COMMENT '职称/称号'");
  1789. } catch (Exception e) {
  1790. }
  1791. try {
  1792. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN specialty VARCHAR(200) COMMENT '专长领域'");
  1793. } catch (Exception e) {
  1794. }
  1795. try {
  1796. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN bio TEXT COMMENT '简介'");
  1797. } catch (Exception e) {
  1798. }
  1799. try {
  1800. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN experience_years TINYINT DEFAULT 0 COMMENT '从业年限'");
  1801. } catch (Exception e) {
  1802. }
  1803. try {
  1804. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN rating INT DEFAULT 500 COMMENT '评分(百分位,500=5.00)'");
  1805. } catch (Exception e) {
  1806. }
  1807. try {
  1808. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN total_appointments INT DEFAULT 0 COMMENT '总服务次数'");
  1809. } catch (Exception e) {
  1810. }
  1811. try {
  1812. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN street_id BIGINT COMMENT '街道ID'");
  1813. } catch (Exception e) {
  1814. }
  1815. try {
  1816. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN province VARCHAR(50) COMMENT '省份'");
  1817. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN city VARCHAR(50) COMMENT '城市'");
  1818. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN district VARCHAR(50) COMMENT '区县'");
  1819. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN street VARCHAR(100) COMMENT '街道'");
  1820. log.info("已添加地址字段到guides表");
  1821. } catch (Exception e) {
  1822. }
  1823. try {
  1824. jdbcTemplate.execute("ALTER TABLE guides ADD COLUMN verified_at DATETIME COMMENT '审核通过时间'");
  1825. } catch (Exception e) {
  1826. }
  1827. // 迁移: 将 teachers 表数据合并到 guides(type='teacher')
  1828. try {
  1829. int migrated = jdbcTemplate.update(
  1830. "INSERT IGNORE INTO guides (user_id, type, teacher_no, title, specialty, bio, experience_years, rating, total_appointments, " +
  1831. "street_id, province, city, district, street, verified_at, status, level, created_at, updated_at) " +
  1832. "SELECT t.user_id, 'teacher', t.teacher_no, t.title, t.specialty, t.bio, t.experience_years, t.rating, t.total_appointments, " +
  1833. "t.street_id, t.province, t.city, t.district, t.street, t.verified_at, " +
  1834. "CASE WHEN t.status = 'active' THEN 'active' ELSE 'inactive' END, " +
  1835. "CASE WHEN t.verification_status = 'approved' THEN 'senior' WHEN t.verification_status = 'pending' THEN 'junior' ELSE 'junior' END, " +
  1836. "t.created_at, t.updated_at " +
  1837. "FROM teachers t " +
  1838. "LEFT JOIN guides g ON g.user_id = t.user_id " +
  1839. "WHERE g.id IS NULL"
  1840. );
  1841. if (migrated > 0) {
  1842. log.info("已从 teachers 表迁移 {} 条数据到 guides(type=teacher)", migrated);
  1843. }
  1844. } catch (Exception e) {
  1845. log.warn("迁移 teachers → guides 数据失败: {}", e.getMessage());
  1846. }
  1847. // 迁移: dan_assessment_results 表添加认知维度字段(6维认知体系扩展)
  1848. try {
  1849. Integer colExists = jdbcTemplate.queryForObject(
  1850. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'perception_score'",
  1851. Integer.class);
  1852. if (colExists == null || colExists == 0) {
  1853. jdbcTemplate.execute("ALTER TABLE dan_assessment_results " +
  1854. "ADD COLUMN perception_score INT COMMENT '感知得分', " +
  1855. "ADD COLUMN spatial_score INT COMMENT '空间思维得分', " +
  1856. "ADD COLUMN processing_speed_score INT COMMENT '加工速度得分'");
  1857. log.info("已为 dan_assessment_results 表添加认知维度字段");
  1858. } else {
  1859. log.info("dan_assessment_results 认知维度字段已存在,跳过");
  1860. }
  1861. } catch (Exception e) {
  1862. log.warn("检查/添加 dan_assessment_results 认知维度字段失败: {}", e.getMessage());
  1863. }
  1864. // 迁移: dan_assessment_results 表添加 EMI 维度字段(v1.2 心智模型重构)
  1865. try {
  1866. Integer colExists = jdbcTemplate.queryForObject(
  1867. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'emotion_management_score'",
  1868. Integer.class);
  1869. if (colExists == null || colExists == 0) {
  1870. jdbcTemplate.execute("ALTER TABLE dan_assessment_results " +
  1871. "ADD COLUMN emotion_management_score INT COMMENT '情绪管理得分(EMI)', " +
  1872. "ADD COLUMN empathy_score INT COMMENT '同理心得分(EMI)', " +
  1873. "ADD COLUMN social_adaptability_score INT COMMENT '社会适应得分(EMI)', " +
  1874. "ADD COLUMN self_motivation_score INT COMMENT '自我激励得分(EMI)'");
  1875. log.info("已为 dan_assessment_results 表添加 EMI 维度字段");
  1876. } else {
  1877. log.info("dan_assessment_results EMI 维度字段已存在,跳过");
  1878. }
  1879. } catch (Exception e) {
  1880. log.warn("检查/添加 dan_assessment_results EMI 维度字段失败: {}", e.getMessage());
  1881. }
  1882. // 迁移: dan_assessment_results 表添加 EMI 心理子维度字段
  1883. try {
  1884. Integer colExists = jdbcTemplate.queryForObject(
  1885. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'emotion_score'",
  1886. Integer.class);
  1887. if (colExists == null || colExists == 0) {
  1888. jdbcTemplate.execute("ALTER TABLE dan_assessment_results " +
  1889. "ADD COLUMN emotion_score INT COMMENT '情绪商数(0-100)', " +
  1890. "ADD COLUMN resilience_score INT COMMENT '心理韧性(0-100)', " +
  1891. "ADD COLUMN stress_coping_score INT COMMENT '压力应对(0-100)', " +
  1892. "ADD COLUMN self_awareness_score INT COMMENT '自我认知(0-100)'");
  1893. log.info("已为 dan_assessment_results 表添加 EMI 心理子维度字段");
  1894. } else {
  1895. log.info("dan_assessment_results EMI 心理子维度字段已存在,跳过");
  1896. }
  1897. } catch (Exception e) {
  1898. log.warn("检查/添加 dan_assessment_results EMI 心理子维度字段失败: {}", e.getMessage());
  1899. }
  1900. // 迁移: dan_assessment_results 表添加大五人格维度字段
  1901. try {
  1902. Integer colExists = jdbcTemplate.queryForObject(
  1903. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = 'openness_score'",
  1904. Integer.class);
  1905. if (colExists == null || colExists == 0) {
  1906. jdbcTemplate.execute("ALTER TABLE dan_assessment_results " +
  1907. "ADD COLUMN openness_score INT COMMENT '开放性(0-100)', " +
  1908. "ADD COLUMN conscientiousness_score INT COMMENT '尽责性(0-100)', " +
  1909. "ADD COLUMN extraversion_score INT COMMENT '外向性(0-100)', " +
  1910. "ADD COLUMN agreeableness_score INT COMMENT '宜人性(0-100)', " +
  1911. "ADD COLUMN neuroticism_score INT COMMENT '神经质(0-100)', " +
  1912. "ADD COLUMN big_five_overall_score INT COMMENT '大五人格综合分'");
  1913. log.info("已为 dan_assessment_results 表添加大五人格维度字段");
  1914. } else {
  1915. log.info("dan_assessment_results 大五人格维度字段已存在,跳过");
  1916. }
  1917. } catch (Exception e) {
  1918. log.warn("检查/添加 dan_assessment_results 大五人格维度字段失败: {}", e.getMessage());
  1919. }
  1920. // 迁移: users 表添加出生时辰字段
  1921. try {
  1922. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥'");
  1923. log.info("已添加birth_hour列到users表");
  1924. } catch (Exception e) {
  1925. // 列已存在,忽略错误
  1926. }
  1927. // 迁移: 重建 family_member_attributes 表结构(v1.2 心智模型)
  1928. try {
  1929. Integer colExists = jdbcTemplate.queryForObject(
  1930. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_member_attributes' AND COLUMN_NAME = 'member_id'",
  1931. Integer.class);
  1932. if (colExists == null || colExists == 0) {
  1933. // 旧表存在但结构不同,先删除旧表重建
  1934. jdbcTemplate.execute("DROP TABLE IF EXISTS family_member_attributes_old");
  1935. jdbcTemplate.execute("RENAME TABLE family_member_attributes TO family_member_attributes_old");
  1936. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_attributes (" +
  1937. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  1938. "member_id BIGINT NOT NULL COMMENT '家庭成员ID(children表或users表)', " +
  1939. "member_type VARCHAR(10) NOT NULL COMMENT '成员类型: child/parent', " +
  1940. "birth_datetime DATETIME COMMENT '出生日期(精确到分钟,用于八字排盘)', " +
  1941. "eight_characters JSON COMMENT '八字四柱JSON', " +
  1942. "wuxing_elements JSON COMMENT '五行星盘元素JSON', " +
  1943. "zodiac VARCHAR(16) COMMENT '生肖: rat/ox/tiger/rabbit/dragon/snake/horse/sheep/monkey/rooster/dog/pig', " +
  1944. "blood_type VARCHAR(5) COMMENT '血型: A/B/AB/O', " +
  1945. "mind_base_score INT DEFAULT 0 COMMENT '心维度基础分(百分位,500=5.00)', " +
  1946. "wisdom_base_score INT DEFAULT 0 COMMENT '智维度基础分(百分位,500=5.00)', " +
  1947. "behavior_modifier JSON COMMENT '行为修正因子JSON', " +
  1948. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  1949. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  1950. "UNIQUE KEY uk_member (member_id, member_type)" +
  1951. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员先天属性表'");
  1952. log.info("已重建 family_member_attributes 表(v1.2 心智模型)");
  1953. } else {
  1954. log.info("family_member_attributes 表已为 v1.2 结构,跳过");
  1955. }
  1956. } catch (Exception e) {
  1957. log.warn("重建 family_member_attributes 表失败: {}", e.getMessage());
  1958. }
  1959. try {
  1960. Integer colExists = jdbcTemplate.queryForObject(
  1961. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'show_to_family'",
  1962. Integer.class);
  1963. if (colExists == null || colExists == 0) {
  1964. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN show_to_family TINYINT DEFAULT 1 COMMENT '是否对家庭成员可见: 1=是, 0=否'");
  1965. log.info("已为 users 表添加 show_to_family 列");
  1966. } else {
  1967. log.info("users 表 show_to_family 列已存在,跳过");
  1968. }
  1969. } catch (Exception e) {
  1970. log.warn("检查/添加 users 表 show_to_family 列失败: {}", e.getMessage());
  1971. }
  1972. // 迁移: tasks 表添加 family_member_id 列(FamilyMember 迁移)
  1973. try {
  1974. Integer colExists = jdbcTemplate.queryForObject(
  1975. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'family_member_id'",
  1976. Integer.class);
  1977. if (colExists == null || colExists == 0) {
  1978. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN family_member_id BIGINT COMMENT '家庭成员ID(关联 family_members.id)'");
  1979. log.info("已为 tasks 表添加 family_member_id 列");
  1980. } else {
  1981. log.info("tasks 表 family_member_id 列已存在,跳过");
  1982. }
  1983. } catch (Exception e) {
  1984. log.warn("检查/添加 tasks 表 family_member_id 列失败: {}", e.getMessage());
  1985. }
  1986. // 迁移: family_members 表添加 role_override 列(FamilyMember 迁移)
  1987. try {
  1988. Integer colExists = jdbcTemplate.queryForObject(
  1989. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_members' AND COLUMN_NAME = 'role_override'",
  1990. Integer.class);
  1991. if (colExists == null || colExists == 0) {
  1992. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN role_override VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly,空=auto'");
  1993. log.info("已为 family_members 表添加 role_override 列");
  1994. } else {
  1995. log.info("family_members 表 role_override 列已存在,跳过");
  1996. }
  1997. } catch (Exception e) {
  1998. log.warn("检查/添加 family_members 表 role_override 列失败: {}", e.getMessage());
  1999. }
  2000. // 迁移: family_members 表添加 id_card 列(孩子身份证号)
  2001. try {
  2002. Integer colExists = jdbcTemplate.queryForObject(
  2003. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_members' AND COLUMN_NAME = 'id_card'",
  2004. Integer.class);
  2005. if (colExists == null || colExists == 0) {
  2006. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN id_card VARCHAR(18) DEFAULT NULL COMMENT '身份证号(孩子)'");
  2007. log.info("已为 family_members 表添加 id_card 列");
  2008. } else {
  2009. log.info("family_members 表 id_card 列已存在,跳过");
  2010. }
  2011. } catch (Exception e) {
  2012. log.warn("检查/添加 family_members 表 id_card 列失败: {}", e.getMessage());
  2013. }
  2014. // 迁移: family_members 表添加孩子专属字段(原children表合并)
  2015. String[][] childColumns = {
  2016. {"penalty_enabled", "TINYINT DEFAULT 1 COMMENT '是否扣减积分(0否1是)'"},
  2017. {"theme", "VARCHAR(32) DEFAULT 'default' COMMENT '主题'"},
  2018. {"system_points", "INT DEFAULT 0 COMMENT '系统积分(干预/平台奖励,仅只读历史;CF值见cf_balance)'"},
  2019. {"streak_days", "INT DEFAULT 0 COMMENT '连续打卡天数'"},
  2020. {"last_task_date", "DATE COMMENT '上次任务日期'"},
  2021. {"focus_max_daily", "TINYINT DEFAULT 3 COMMENT '每日专注上限'"},
  2022. {"focus_remaining", "TINYINT DEFAULT 3 COMMENT '今日剩余专注次数'"},
  2023. {"focus_reset_date", "DATE COMMENT '专注次数重置日期'"}
  2024. };
  2025. for (String[] col : childColumns) {
  2026. try {
  2027. Integer colExists = jdbcTemplate.queryForObject(
  2028. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_members' AND COLUMN_NAME = ?",
  2029. Integer.class, col[0]);
  2030. if (colExists == null || colExists == 0) {
  2031. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN " + col[0] + " " + col[1]);
  2032. log.info("已为 family_members 表添加 {} 列", col[0]);
  2033. }
  2034. } catch (Exception e) {
  2035. log.warn("检查/添加 family_members 表 {} 列失败: {}", col[0], e.getMessage());
  2036. }
  2037. }
  2038. // 家庭成员表添加 role_override 列(替换旧 capability_role)
  2039. ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'");
  2040. try {
  2041. jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN capability_role");
  2042. log.info("已删除 family_members.capability_role 旧列,已由 role_override 替代");
  2043. } catch (Exception e) {
  2044. // 列不存在或已删除,忽略错误
  2045. }
  2046. // activities 表添加报名/签到相关字段
  2047. ensureColumn("activities", "require_registration", "TINYINT DEFAULT 1 COMMENT '是否需要报名: 1=需要, 0=直接参与'");
  2048. ensureColumn("activities", "require_checkin_review", "TINYINT DEFAULT 1 COMMENT '打卡是否需要审核: 1=需要, 0=直接通过'");
  2049. ensureColumn("activities", "energy_penalty_unregistered", "INT DEFAULT 5 COMMENT '未报名打卡的行能量扣减值'");
  2050. // 2026-10-22 整改:报名审核开关 + 名额占用计数
  2051. ensureColumn("activities", "require_review", "TINYINT DEFAULT 0 COMMENT '报名是否需要审核: 1=需要, 0=免审即报名通过'");
  2052. ensureColumn("activities", "approved_count", "INT DEFAULT 0 COMMENT '已通过报名人数(名额占用统计)'");
  2053. ensureColumn("activities", "signed_count", "INT DEFAULT 0 COMMENT '签到人数(checkin 时 +1,区别于 approved_count 的报名口径)'");
  2054. // 修正 require_checkin_review 默认值并归一化存量(该字段此前未被读取,无业务依赖,避免存量活动突然进入待审核)
  2055. // 同时按已通过报名回填 approved_count,使名额统计与存量数据一致
  2056. try {
  2057. jdbcTemplate.execute("ALTER TABLE activities MODIFY COLUMN require_checkin_review TINYINT DEFAULT 0 COMMENT '打卡是否需要审核: 1=需要, 0=免审直接通过'");
  2058. jdbcTemplate.execute("UPDATE activities SET require_checkin_review = 0 WHERE require_checkin_review = 1");
  2059. jdbcTemplate.execute("UPDATE activities a SET a.approved_count = (SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM activity_registrations r WHERE r.activity_id = a.id AND r.status = 'approved') t)");
  2060. log.info("已修正 require_checkin_review 默认值并回填 approved_count");
  2061. } catch (Exception e) {
  2062. log.warn("修正 activities 报名/签到审核字段失败: {}", e.getMessage());
  2063. }
  2064. // family_members 表添加关系评分字段
  2065. ensureColumn("family_members", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度 0-100'");
  2066. ensureColumn("family_members", "intimacy_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度 0-100'");
  2067. ensureColumn("family_members", "communication_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量 0-100'");
  2068. ensureColumn("family_members", "last_interaction_at", "DATETIME COMMENT '最近互动时间'");
  2069. // 创建购物车表
  2070. try {
  2071. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cart (" +
  2072. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2073. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  2074. "product_id BIGINT NOT NULL COMMENT '商品ID', " +
  2075. "quantity INT NOT NULL DEFAULT 1 COMMENT '数量', " +
  2076. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2077. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  2078. "INDEX idx_user_product (user_id, product_id)" +
  2079. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车'");
  2080. log.info("已创建 cart 表");
  2081. } catch (Exception e) {
  2082. log.warn("创建 cart 表失败: {}", e.getMessage());
  2083. }
  2084. // ==================== 迁移: activities表添加visible_scope列 =============
  2085. try {
  2086. Integer colExists = jdbcTemplate.queryForObject(
  2087. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'visible_scope'",
  2088. Integer.class);
  2089. if (colExists == null || colExists == 0) {
  2090. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN visible_scope VARCHAR(20) DEFAULT NULL COMMENT '可见范围: family(家庭)/role(角色)/user(用户)' AFTER visibility");
  2091. log.info("已添加visible_scope列到activities表");
  2092. } else {
  2093. log.info("activities表visible_scope列已存在,跳过");
  2094. }
  2095. } catch (Exception e) {
  2096. log.warn("检查/添加activities表visible_scope列失败: {}", e.getMessage());
  2097. }
  2098. // ==================== 迁移: articles表添加article_type列 =============
  2099. try {
  2100. Integer colExists = jdbcTemplate.queryForObject(
  2101. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'article_type'",
  2102. Integer.class);
  2103. if (colExists == null || colExists == 0) {
  2104. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN article_type VARCHAR(20) DEFAULT 'normal' COMMENT '文章类型: normal(普通)/premium(优质)/original(原创)' AFTER read_time");
  2105. log.info("已添加article_type列到articles表");
  2106. } else {
  2107. log.info("articles表article_type列已存在,跳过");
  2108. }
  2109. } catch (Exception e) {
  2110. log.warn("检查/添加articles表article_type列失败: {}", e.getMessage());
  2111. }
  2112. // ==================== 迁移: articles表添加word_count列 =============
  2113. try {
  2114. Integer colExists = jdbcTemplate.queryForObject(
  2115. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'word_count'",
  2116. Integer.class);
  2117. if (colExists == null || colExists == 0) {
  2118. jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN word_count INT DEFAULT 0 COMMENT '文章字数' AFTER article_type");
  2119. log.info("已添加word_count列到articles表");
  2120. } else {
  2121. log.info("articles表word_count列已存在,跳过");
  2122. }
  2123. } catch (Exception e) {
  2124. log.warn("检查/添加articles表word_count列失败: {}", e.getMessage());
  2125. }
  2126. // ==================== 迁移: article_reading_records表添加task_id列 =============
  2127. try {
  2128. Integer colExists = jdbcTemplate.queryForObject(
  2129. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'article_reading_records' AND COLUMN_NAME = 'task_id'",
  2130. Integer.class);
  2131. if (colExists == null || colExists == 0) {
  2132. jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN task_id BIGINT DEFAULT NULL COMMENT '关联的任务ID' AFTER duration_seconds");
  2133. log.info("已添加task_id列到article_reading_records表");
  2134. } else {
  2135. log.info("article_reading_records表task_id列已存在,跳过");
  2136. }
  2137. } catch (Exception e) {
  2138. log.warn("检查/添加article_reading_records表task_id列失败: {}", e.getMessage());
  2139. }
  2140. // ==================== 迁移: tasks表添加source_type列 =============
  2141. try {
  2142. Integer colExists = jdbcTemplate.queryForObject(
  2143. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'source_type'",
  2144. Integer.class);
  2145. if (colExists == null || colExists == 0) {
  2146. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN source_type VARCHAR(20) DEFAULT NULL COMMENT '来源类型: article/activity/manual' AFTER minigame_code");
  2147. log.info("已添加source_type列到tasks表");
  2148. } else {
  2149. log.info("tasks表source_type列已存在,跳过");
  2150. }
  2151. } catch (Exception e) {
  2152. log.warn("检查/添加tasks表source_type列失败: {}", e.getMessage());
  2153. }
  2154. // ==================== 迁移: tasks表添加source_id列 =============
  2155. try {
  2156. Integer colExists = jdbcTemplate.queryForObject(
  2157. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'source_id'",
  2158. Integer.class);
  2159. if (colExists == null || colExists == 0) {
  2160. jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN source_id BIGINT DEFAULT NULL COMMENT '来源ID' AFTER source_type");
  2161. log.info("已添加source_id列到tasks表");
  2162. } else {
  2163. log.info("tasks表source_id列已存在,跳过");
  2164. }
  2165. } catch (Exception e) {
  2166. log.warn("检查/添加tasks表source_id列失败: {}", e.getMessage());
  2167. }
  2168. // ==================== 迁移: activities表添加checkin_points列 =============
  2169. try {
  2170. Integer colExists = jdbcTemplate.queryForObject(
  2171. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'checkin_points'",
  2172. Integer.class);
  2173. if (colExists == null || colExists == 0) {
  2174. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN checkin_points INT DEFAULT 0 COMMENT '签到积分(0=使用系统默认)' AFTER price");
  2175. log.info("已添加checkin_points列到activities表");
  2176. } else {
  2177. log.info("activities表checkin_points列已存在,跳过");
  2178. }
  2179. } catch (Exception e) {
  2180. log.warn("检查/添加activities表checkin_points列失败: {}", e.getMessage());
  2181. }
  2182. // 迁移: 创建family_invitations表(家庭邀请令牌)
  2183. try {
  2184. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_invitations (" +
  2185. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2186. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  2187. "token VARCHAR(64) NOT NULL UNIQUE COMMENT '邀请令牌(UUID)', " +
  2188. "created_by BIGINT NOT NULL COMMENT '创建人用户ID', " +
  2189. "status VARCHAR(16) DEFAULT 'active' COMMENT '状态: active/used/expired/revoked', " +
  2190. "expires_at DATETIME COMMENT '过期时间', " +
  2191. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2192. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  2193. "INDEX idx_family_id (family_id), " +
  2194. "INDEX idx_token (token), " +
  2195. "INDEX idx_status (status)" +
  2196. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭邀请令牌'");
  2197. log.info("已创建family_invitations表");
  2198. } catch (Exception e) {
  2199. log.warn("创建family_invitations表失败: {}", e.getMessage());
  2200. }
  2201. // ==================== P0 营养师: nutrition_indicator_mapping =============
  2202. try {
  2203. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_indicator_mapping (" +
  2204. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  2205. "indicator_name_pattern VARCHAR(200) NOT NULL COMMENT '指标名匹配模式(SQL LIKE语法,%为通配符)'," +
  2206. "nutrient VARCHAR(50) NOT NULL COMMENT '关联营养素名'," +
  2207. "severity_weight DECIMAL(3,2) DEFAULT 1.00 COMMENT '权重(0-1)'," +
  2208. "deficiency_symptoms TEXT COMMENT '缺乏症状描述'," +
  2209. "excess_symptoms TEXT COMMENT '过量症状描述'," +
  2210. "food_suggestions TEXT COMMENT '推荐食物(逗号分隔)'," +
  2211. "sort_order INT DEFAULT 0 COMMENT '排序(大值优先匹配)'," +
  2212. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  2213. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  2214. "INDEX idx_nutrient (nutrient)" +
  2215. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标-营养素映射表'");
  2216. log.info("已创建 nutrition_indicator_mapping 表");
  2217. } catch (Exception e) {
  2218. log.warn("创建 nutrition_indicator_mapping 表失败: {}", e.getMessage());
  2219. }
  2220. // P0 营养师: nutrition_deficiency_record
  2221. try {
  2222. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_deficiency_record (" +
  2223. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  2224. "child_id BIGINT NOT NULL COMMENT '孩子ID'," +
  2225. "report_id BIGINT NOT NULL COMMENT '关联报告ID'," +
  2226. "nutrient VARCHAR(50) NOT NULL COMMENT '营养素名'," +
  2227. "deficiency_type VARCHAR(20) NOT NULL COMMENT 'deficiency/excess/borderline/unclassified'," +
  2228. "severity DECIMAL(5,2) DEFAULT 0 COMMENT '严重程度(0-1)'," +
  2229. "indicator_name VARCHAR(100) COMMENT '关联指标名'," +
  2230. "indicator_value VARCHAR(50) COMMENT '指标检测值'," +
  2231. "ref_range VARCHAR(100) COMMENT '参考范围'," +
  2232. "symptoms TEXT COMMENT '症状描述'," +
  2233. "food_suggestions TEXT COMMENT '推荐食物'," +
  2234. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  2235. "INDEX idx_child (child_id)," +
  2236. "INDEX idx_report (report_id)," +
  2237. "INDEX idx_nutrient (nutrient)" +
  2238. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养缺乏分析记录'");
  2239. log.info("已创建 nutrition_deficiency_record 表");
  2240. } catch (Exception e) {
  2241. log.warn("创建 nutrition_deficiency_record 表失败: {}", e.getMessage());
  2242. }
  2243. // P0 营养师: 指标-营养素映射 种子数据
  2244. try {
  2245. Integer count = jdbcTemplate.queryForObject(
  2246. "SELECT COUNT(*) FROM nutrition_indicator_mapping", Integer.class);
  2247. if (count == null || count == 0) {
  2248. String[][] seedData = {
  2249. {"铁蛋白", "铁", "1.00", "乏力、面色苍白、注意力不集中", "铁过量可能损伤肝脏", "动物肝脏,红肉,菠菜,黑木耳", "10"},
  2250. {"25-羟基维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "20"},
  2251. {"维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育、免疫力下降", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "15"},
  2252. {"锌", "锌", "0.80", "食欲减退、味觉异常、免疫力降低", "锌过量影响铜吸收", "牡蛎,牛肉,南瓜子,扁豆", "10"},
  2253. {"钙", "钙", "0.80", "骨骼发育迟缓、牙齿问题", "高钙血症、肾结石", "牛奶,豆腐,虾皮,芝麻酱", "10"},
  2254. {"维生素B12", "维生素B12", "1.00", "贫血、神经发育迟缓", "一般不易过量", "牛肉,鸡蛋,牛奶,鱼肉", "10"},
  2255. {"维生素C", "维生素C", "0.60", "免疫力下降、牙龈出血", "腹泻、肾结石风险", "猕猴桃,橙子,草莓,青椒", "10"},
  2256. {"白蛋白", "蛋白质", "0.90", "发育迟缓、肌肉萎缩", "肾脏负担", "鸡蛋,鱼肉,牛奶,豆制品", "10"},
  2257. {"总蛋白", "蛋白质", "0.90", "营养不良、免疫力下降", "血液粘稠", "鸡蛋,鱼肉,牛奶,豆制品", "9"},
  2258. {"膳食纤维", "膳食纤维", "0.50", "便秘、肠道菌群失衡", "腹胀、排便过多", "全谷物,蔬菜,豆类,水果", "10"},
  2259. {"叶酸", "叶酸", "0.70", "贫血、发育迟缓", "一般不易过量", "菠菜,动物肝脏,豆类,柑橘", "10"},
  2260. {"维生素A", "维生素A", "0.70", "视力问题、皮肤干燥、免疫力下降", "头痛、肝脏损伤", "胡萝卜,动物肝脏,南瓜,菠菜", "10"},
  2261. {"硒", "硒", "0.50", "免疫力下降、甲状腺功能异常", "脱发、指甲脆裂", "巴西坚果,海鲜,蘑菇", "10"},
  2262. {"镁", "镁", "0.40", "肌肉痉挛、睡眠问题", "腹泻、低血压", "坚果,全谷物,深绿蔬菜", "10"},
  2263. {"益生菌%", "益生菌", "0.60", "肠道菌群失衡、消化问题", "一般不易过量", "酸奶,泡菜,味噌,康普茶", "10"}
  2264. };
  2265. for (String[] row : seedData) {
  2266. jdbcTemplate.update("INSERT IGNORE INTO nutrition_indicator_mapping " +
  2267. "(indicator_name_pattern, nutrient, severity_weight, deficiency_symptoms, excess_symptoms, food_suggestions, sort_order) " +
  2268. "VALUES (?, ?, ?, ?, ?, ?, ?)",
  2269. row[0], row[1], new java.math.BigDecimal(row[2]), row[3], row[4], row[5], Integer.parseInt(row[6]));
  2270. }
  2271. log.info("nutrition_indicator_mapping 种子数据已加载 (15条)");
  2272. } else {
  2273. log.info("nutrition_indicator_mapping 种子数据已存在 ({}条), 跳过", count);
  2274. }
  2275. } catch (Exception e) {
  2276. log.warn("nutrition_indicator_mapping 种子数据初始化失败: {}", e.getMessage());
  2277. }
  2278. // ==================== 心版块 Phase 1: emotion_checkin 表 =============
  2279. try {
  2280. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_checkin (" +
  2281. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2282. "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
  2283. "user_id BIGINT NOT NULL COMMENT '打卡人用户ID', " +
  2284. "emotion_type INT NOT NULL COMMENT '情绪类型: 1快乐/2平和平静/3平静/4有点不舒服/5难受/6烦躁/7生气/8焦虑/9恐惧', " +
  2285. "emotion_name VARCHAR(20) COMMENT '情绪名称: happy/calm/peaceful/uncomfortable/sad/irritable/angry/anxious/fearful', " +
  2286. "energy_level INT NOT NULL DEFAULT 3 COMMENT '能量状态: 1很低/2低/3中等/4高/5很高', " +
  2287. "note TEXT COMMENT '打卡备注', " +
  2288. "photo_url VARCHAR(500) COMMENT '照片URL', " +
  2289. "favorite_thing VARCHAR(500) COMMENT '今日最喜欢的一件事', " +
  2290. "points_awarded INT DEFAULT 0 COMMENT '奖励积分', " +
  2291. "parent_visible TINYINT DEFAULT 1 COMMENT '家长可见: 1可见/0不可见', " +
  2292. "checkin_date DATE NOT NULL COMMENT '打卡日期', " +
  2293. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2294. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  2295. "INDEX idx_child_id (child_id), " +
  2296. "INDEX idx_user_id (user_id), " +
  2297. "INDEX idx_checkin_date (checkin_date), " +
  2298. "INDEX idx_child_date (child_id, checkin_date)" +
  2299. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪打卡表'");
  2300. log.info("已创建 emotion_checkin 情绪打卡表");
  2301. } catch (Exception e) {
  2302. log.warn("创建 emotion_checkin 表失败: {}", e.getMessage());
  2303. }
  2304. // ==================== 1a-2: DAN Schema 修复 ============= // v1.2 EMI 心理子维度
  2305. String[] danEmiFields = {"emotion_score", "resilience_score", "stress_coping_score", "self_awareness_score"};
  2306. for (String field : danEmiFields) {
  2307. try {
  2308. Integer exists = jdbcTemplate.queryForObject(
  2309. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
  2310. Integer.class, field);
  2311. if (exists == null || exists == 0) {
  2312. jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI子维度 0-100'");
  2313. log.info("已添加 {} 列到 dan_assessment_results 表", field);
  2314. }
  2315. } catch (Exception e) {
  2316. log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
  2317. }
  2318. }
  2319. // 大五人格维度
  2320. String[] danBigFiveFields = {"openness_score", "conscientiousness_score", "extraversion_score", "agreeableness_score", "neuroticism_score", "big_five_overall_score"};
  2321. for (String field : danBigFiveFields) {
  2322. try {
  2323. Integer exists = jdbcTemplate.queryForObject(
  2324. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
  2325. Integer.class, field);
  2326. if (exists == null || exists == 0) {
  2327. jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT '大五人格 0-100'");
  2328. log.info("已添加 {} 列到 dan_assessment_results 表", field);
  2329. }
  2330. } catch (Exception e) {
  2331. log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
  2332. }
  2333. }
  2334. // EMI 维度(情绪商数评估字段)
  2335. String[] danEmiDimFields = {"emotion_management_score", "empathy_score", "social_adaptability_score", "self_motivation_score"};
  2336. for (String field : danEmiDimFields) {
  2337. try {
  2338. Integer exists = jdbcTemplate.queryForObject(
  2339. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
  2340. Integer.class, field);
  2341. if (exists == null || exists == 0) {
  2342. jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI维度 0-100'");
  2343. log.info("已添加 {} 列到 dan_assessment_results 表", field);
  2344. }
  2345. } catch (Exception e) {
  2346. log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
  2347. }
  2348. }
  2349. // ==================== 1b-1: bazi_reading_template 五行命理解读表 =============
  2350. try {
  2351. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS bazi_reading_template (" +
  2352. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2353. "element VARCHAR(20) NOT NULL COMMENT '五行: wood/fire/earth/metal/water', " +
  2354. "`condition` VARCHAR(20) NOT NULL COMMENT '状态: strong/weak/balanced', " +
  2355. "personal_trait TEXT COMMENT '性格特质描述', " +
  2356. "mind_growth_advice TEXT COMMENT '心理成长建议', " +
  2357. "daily_tip_template TEXT COMMENT '每日宜忌模板', " +
  2358. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2359. "UNIQUE KEY uk_element_condition (element, `condition`)" +
  2360. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五行命理解读模板'");
  2361. log.info("已创建 bazi_reading_template 表");
  2362. } catch (Exception e) {
  2363. log.warn("创建 bazi_reading_template 表失败: {}", e.getMessage());
  2364. }
  2365. // ==================== 1b-1: numsoul_config 生命灵数配置表 =============
  2366. try {
  2367. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS numsoul_config (" +
  2368. "number INT PRIMARY KEY COMMENT '灵数1-9', " +
  2369. "title VARCHAR(50) NOT NULL COMMENT '称号', " +
  2370. "keywords VARCHAR(200) COMMENT '性格关键词', " +
  2371. "mind_growth_advice TEXT COMMENT '心理成长建议', " +
  2372. "recommended_article_tags VARCHAR(200) COMMENT '关联文章标签', " +
  2373. "color_hex VARCHAR(7) COMMENT '代表色' " +
  2374. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生命灵数配置'");
  2375. log.info("已创建 numsoul_config 表");
  2376. } catch (Exception e) {
  2377. log.warn("创建 numsoul_config 表失败: {}", e.getMessage());
  2378. }
  2379. // ==================== 1b-1: bazi_reading_template 种子数据 =============
  2380. try {
  2381. Integer count = jdbcTemplate.queryForObject(
  2382. "SELECT COUNT(*) FROM bazi_reading_template", Integer.class);
  2383. if (count == null || count == 0) {
  2384. jdbcTemplate.batchUpdate(
  2385. "INSERT INTO bazi_reading_template (element, `condition`, personal_trait, mind_growth_advice, daily_tip_template) VALUES (?, ?, ?, ?, ?)",
  2386. java.util.Arrays.asList(new Object[][]{
  2387. {"wood", "strong", "性格坚韧、有进取心,具有领导力和开拓精神", "学会适当放松,避免过刚易折,培养弹性思维", "宜进行伸展运动,忌高强度冲突"},
  2388. {"wood", "weak", "温和有礼、善于合作,但决策时容易犹豫", "增强自信心训练,培养果断决策能力", "宜与自然相处,忌过度迁就他人"},
  2389. {"wood", "balanced", "性格中庸有度,既有韧性又有灵活性", "保持现状,适当拓展社交圈", "宜户外活动,忌久坐不动"},
  2390. {"fire", "strong", "热情开朗、充满活力,富有感染力和创造力", "注意情绪管理,避免急躁冲动", "宜冷水刺激冷静,忌暴怒争执"},
  2391. {"fire", "weak", "内敛含蓄、心思细腻,对艺术有敏锐感知", "适当表达情感,培养社交自信", "宜温暖陪伴,忌独处过久"},
  2392. {"fire", "balanced", "性格温润有度,既有热情又懂克制", "保持兴趣爱好,适度拓展表达", "宜参与社交活动,忌情绪压抑"},
  2393. {"earth", "strong", "稳重可靠、有责任心,是团队中的中流砥柱", "避免过度承担,学会说'不'", "宜适量运动,忌思虑过重"},
  2394. {"earth", "weak", "随和友善、适应性强,容易与他人相处", "增强主见性,培养独立思考能力", "宜接触新事物,忌固守不变"},
  2395. {"earth", "balanced", "性情敦厚、处事稳妥,人际关系和谐", "保持学习,适当挑战自我", "宜亲近自然,忌暴饮暴食"},
  2396. {"metal", "strong", "意志坚定、追求完美,具有卓越的执行力", "学会灵活变通,避免过于严苛", "宜艺术鉴赏,忌过度批评"},
  2397. {"metal", "weak", "温和寡言、善于倾听,是值得信赖的朋友", "增强表达勇气,培养自信心", "宜团体活动,忌过度自我封闭"},
  2398. {"metal", "balanced", "为人正直、做事有度,不乏柔韧与包容", "保持开放心态,适度多元发展", "宜阅读学习,忌钻牛角尖"},
  2399. {"water", "strong", "智慧深沉、善于谋略,具有出色洞察力", "避免过度缜密,适当放松心情", "宜静坐冥想,忌思虑过深"},
  2400. {"water", "weak", "敏感细腻、有同理心,富有创作天赋", "增强抵抗力,培养情绪韧性", "宜温暖环境,忌受寒受凉"},
  2401. {"water", "balanced", "性格圆融、随机应变,处事灵活得宜", "保持好奇心,适度扩展影响力", "宜游泳泡澡,忌情绪波动"},
  2402. })
  2403. );
  2404. log.info("bazi_reading_template 种子数据已加载 (15条)");
  2405. }
  2406. } catch (Exception e) {
  2407. log.warn("bazi_reading_template 种子数据初始化失败: {}", e.getMessage());
  2408. }
  2409. // ==================== 1b-1: numsoul_config 种子数据 =============
  2410. try {
  2411. Integer count = jdbcTemplate.queryForObject(
  2412. "SELECT COUNT(*) FROM numsoul_config", Integer.class);
  2413. if (count == null || count == 0) {
  2414. jdbcTemplate.batchUpdate(
  2415. "INSERT INTO numsoul_config (number, title, keywords, mind_growth_advice, recommended_article_tags, color_hex) VALUES (?, ?, ?, ?, ?, ?)",
  2416. java.util.Arrays.asList(new Object[][]{
  2417. {1, "开创者", "独立、领导、创新、自信", "培养团队协作意识,避免独断专行", "领导力,自信培养", "#FF4444"},
  2418. {2, "和平者", "温和、合作、敏感、耐心", "增强自我主张,避免过度迎合他人", "人际交往,情绪表达", "#FF8C00"},
  2419. {3, "表达者", "创意、社交、乐观、表达", "保持专注,避免浅尝辄止", "创造力,社交能力", "#FFD700"},
  2420. {4, "建设者", "务实、稳定、有序、勤奋", "学会变通,避免过于固执", "专注力,学习习惯", "#4CAF50"},
  2421. {5, "自由者", "冒险、变化、好奇、灵活", "培养持久力,避免缺乏恒心", "探索精神,适应能力", "#2196F3"},
  2422. {6, "关怀者", "责任、关爱、奉献、平衡", "学会自我关爱,避免过度付出", "家庭关系,责任心", "#9C27B0"},
  2423. {7, "探索者", "智慧、分析、内省、完美", "与人分享见解,避免过度封闭", "思维能力,学习力", "#673AB7"},
  2424. {8, "成就者", "雄心、管理、决策、权威", "关注内在成长,避免过于追求外在成就", "目标管理,理财思维", "#3F51B5"},
  2425. {9, "博爱者", "包容、智慧、善良、豁达", "设定个人边界,避免过度牺牲", "人际关系,情绪管理", "#E91E63"},
  2426. })
  2427. );
  2428. log.info("numsoul_config 种子数据已加载 (9条)");
  2429. }
  2430. } catch (Exception e) {
  2431. log.warn("numsoul_config 种子数据初始化失败: {}", e.getMessage());
  2432. }
  2433. try {
  2434. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_address (" +
  2435. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2436. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  2437. "receiver_name VARCHAR(50) NOT NULL COMMENT '收货人姓名', " +
  2438. "phone VARCHAR(20) NOT NULL COMMENT '收货人电话', " +
  2439. "province VARCHAR(50) COMMENT '省份', " +
  2440. "city VARCHAR(50) COMMENT '城市', " +
  2441. "district VARCHAR(50) COMMENT '区县', " +
  2442. "street VARCHAR(200) COMMENT '详细地址', " +
  2443. "is_default INT DEFAULT 0 COMMENT '是否默认 1=是 0=否', " +
  2444. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2445. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  2446. "INDEX idx_user_id (user_id)" +
  2447. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货地址表'");
  2448. log.info("已创建 user_address 表");
  2449. } catch (Exception e) {
  2450. log.warn("创建 user_address 表失败: {}", e.getMessage());
  2451. }
  2452. // ==================== 会员价格体系 ====== // 迁移: activities表添加member_price列
  2453. try {
  2454. Integer exists = jdbcTemplate.queryForObject(
  2455. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'member_price'",
  2456. Integer.class);
  2457. if (exists == null || exists == 0) {
  2458. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN member_price INT COMMENT '会员价(分)' AFTER price");
  2459. log.info("已添加member_price列到activities表");
  2460. } else {
  2461. log.info("member_price列已存在,跳过");
  2462. }
  2463. } catch (Exception e) {
  2464. log.warn("检查/添加activities.member_price失败: {}", e.getMessage());
  2465. }
  2466. // 迁移: 创建 member_discount_configs 表(分类级别折扣配置)
  2467. try {
  2468. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_discount_configs (" +
  2469. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2470. "level_code VARCHAR(20) NOT NULL COMMENT '会员等级: FAMILY/PROVIDER', " +
  2471. "target_type VARCHAR(20) NOT NULL COMMENT '目标类型: product/activity', " +
  2472. "target_id BIGINT DEFAULT NULL COMMENT '目标ID(NULL表示分类级别)', " +
  2473. "category_id BIGINT DEFAULT NULL COMMENT '类目ID(分类级别折扣)', " +
  2474. "discount_percent INT NOT NULL COMMENT '折扣百分比(如90表示90%)', " +
  2475. "enabled TINYINT DEFAULT 1 COMMENT '是否启用', " +
  2476. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2477. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  2478. "INDEX idx_level (level_code), " +
  2479. "INDEX idx_target (target_type, target_id), " +
  2480. "INDEX idx_category (target_type, category_id)" +
  2481. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员折扣配置'");
  2482. log.info("已创建member_discount_configs表");
  2483. } catch (Exception e) {
  2484. log.warn("创建member_discount_configs表失败: {}", e.getMessage());
  2485. }
  2486. // ==================== 食材推荐指数 Phase: food_recommend_idx / food_recommend_idx_log =============
  2487. try {
  2488. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" +
  2489. "`id` BIGINT NOT NULL AUTO_INCREMENT, " +
  2490. "`user_id` BIGINT NOT NULL COMMENT '用户ID', " +
  2491. "`food_id` BIGINT NOT NULL COMMENT '食材ID', " +
  2492. "`food_name` VARCHAR(100) COMMENT '食材名称', " +
  2493. "`index_score` INT NOT NULL DEFAULT 0 COMMENT '推荐指数(0-100, 来自菌群检测报告)', " +
  2494. "`health_report_id` BIGINT COMMENT '关联健康报告ID', " +
  2495. "`calculated_at` DATETIME COMMENT '计算时间', " +
  2496. "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2497. "PRIMARY KEY (`id`), " +
  2498. "INDEX `idx_fri_user` (`user_id`), " +
  2499. "INDEX `idx_fri_food` (`food_id`), " +
  2500. "UNIQUE KEY `uk_fri_user_food` (`user_id`, `food_id`) " +
  2501. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材推荐指数当前值'");
  2502. log.info("已创建/确认 food_recommend_idx 表");
  2503. } catch (Exception e) {
  2504. log.warn("创建 food_recommend_idx 表失败: {}", e.getMessage());
  2505. }
  2506. try {
  2507. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx_log` (" +
  2508. "`id` BIGINT NOT NULL AUTO_INCREMENT, " +
  2509. "`user_id` BIGINT NOT NULL COMMENT '用户ID', " +
  2510. "`food_id` BIGINT NOT NULL COMMENT '食材ID', " +
  2511. "`food_name` VARCHAR(100) COMMENT '食材名称', " +
  2512. "`previous_index` INT COMMENT '变更前推荐指数', " +
  2513. "`new_index` INT COMMENT '变更后推荐指数', " +
  2514. "`health_report_id` BIGINT COMMENT '触发的健康报告ID', " +
  2515. "`change_reason` VARCHAR(200) COMMENT '变更原因', " +
  2516. "`changed_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2517. "PRIMARY KEY (`id`), " +
  2518. "INDEX `idx_fril_user` (`user_id`), " +
  2519. "INDEX `idx_fril_food` (`food_id`) " +
  2520. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材推荐指数变更历史日志'");
  2521. log.info("已创建/确认 food_recommend_idx_log 表");
  2522. } catch (Exception e) {
  2523. log.warn("创建 food_recommend_idx_log 表失败: {}", e.getMessage());
  2524. }
  2525. // 迁移25: activities表添加审核相关字段(活动工作流:待审核/驳回/发布)
  2526. try {
  2527. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audit_status VARCHAR(20) DEFAULT NULL COMMENT '审核状态:approved/pending/rejected'");
  2528. log.info("已添加audit_status列到activities表");
  2529. } catch (Exception e) {
  2530. // 列已存在,忽略错误
  2531. }
  2532. try {
  2533. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audit_reason VARCHAR(500) DEFAULT NULL COMMENT '驳回原因'");
  2534. log.info("已添加audit_reason列到activities表");
  2535. } catch (Exception e) {
  2536. // 列已存在,忽略错误
  2537. }
  2538. try {
  2539. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN auditor_id BIGINT DEFAULT NULL COMMENT '审核人ID'");
  2540. log.info("已添加auditor_id列到activities表");
  2541. } catch (Exception e) {
  2542. // 列已存在,忽略错误
  2543. }
  2544. try {
  2545. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audited_at DATETIME DEFAULT NULL COMMENT '审核时间'");
  2546. log.info("已添加audited_at列到activities表");
  2547. } catch (Exception e) {
  2548. }
  2549. // 家庭挑战:目标模式与目标值(aggregate=集体总合目标 / all_members=全员个人目标)
  2550. ensureColumn("family_challenge", "target_mode", "VARCHAR(20) DEFAULT 'aggregate' COMMENT '目标模式: aggregate=集体总合/all_members=全员个人目标'");
  2551. ensureColumn("family_challenge", "target_value", "INT DEFAULT 0 COMMENT '目标值(aggregate=家庭合计值, all_members=每人目标值)'");
  2552. // 迁移: family_challenge 表添加回复时效字段(待同意成员超时未回复自动拒绝)
  2553. ensureColumn("family_challenge", "respond_deadline", "DATETIME NULL COMMENT '回复时效截止时间(超过后未回复的 pending 成员自动置为 rejected)'");
  2554. // 迁移248: 创建 health_report_edit_logs 表(健康报告修改留痕,原迁移222误置于initializeDefaultData中仅测试环境执行)
  2555. try {
  2556. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_report_edit_logs (" +
  2557. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2558. "report_id BIGINT NOT NULL, " +
  2559. "user_id BIGINT, " +
  2560. "report_type VARCHAR(32), " +
  2561. "action VARCHAR(20) COMMENT 'create/edit', " +
  2562. "before_payload LONGTEXT, " +
  2563. "after_payload LONGTEXT, " +
  2564. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2565. "INDEX idx_report_id (report_id)" +
  2566. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕'");
  2567. log.info("已创建health_report_edit_logs表");
  2568. } catch (Exception e) {
  2569. // 表已存在,忽略错误
  2570. }
  2571. // 迁移249: file_record 表添加 file_type 和 description 列(原迁移221/228误置于seedFullRegionData中,区域数据非空时永不执行)
  2572. // 迁移250: health_plans 添加 plan_json 和 status 列(支持结构化方案编辑)
  2573. try {
  2574. ensureColumn("health_plans", "plan_json", "JSON COMMENT '结构化方案内容(总览+营养+饮食+运动)'");
  2575. } catch (Exception e) {
  2576. log.warn("health_plans.plan_json 添加失败(可能已存在): " + e.getMessage());
  2577. }
  2578. try {
  2579. ensureColumn("health_plans", "status", "VARCHAR(20) DEFAULT 'draft' COMMENT '方案状态: draft(草稿)/pending_review(待审核)/published(已发布)/rejected(已驳回)'");
  2580. } catch (Exception e) {
  2581. log.warn("health_plans.status 添加失败(可能已存在): " + e.getMessage());
  2582. }
  2583. // 迁移250: health_plans 新增审核字段(规划师/营养师方案维护流程)
  2584. try {
  2585. ensureColumn("health_plans", "reviewed_by", "BIGINT DEFAULT NULL COMMENT '审核人ID(规划师/营养师)'");
  2586. ensureColumn("health_plans", "reviewed_at", "DATETIME DEFAULT NULL COMMENT '审核时间'");
  2587. ensureColumn("health_plans", "review_comment", "TEXT DEFAULT NULL COMMENT '审核意见'");
  2588. } catch (Exception e) {
  2589. log.warn("health_plans 审核字段添加失败(可能已存在): " + e.getMessage());
  2590. }
  2591. // 迁移251: health_plans 新增 teacher_id 列(用户指定规划师)
  2592. try {
  2593. ensureColumn("health_plans", "teacher_id", "BIGINT DEFAULT NULL COMMENT '指定规划师ID(用户选择)'");
  2594. } catch (Exception ex) {
  2595. log.warn("health_plans.teacher_id 添加失败(可能已存在): " + ex.getMessage());
  2596. }
  2597. // 迁移251b: health_plans 新增 updated_at 列(实体HealthPlan含该字段,缺失导致selectById报Unknown column)
  2598. try {
  2599. ensureColumn("health_plans", "updated_at", "DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'");
  2600. } catch (Exception ex) {
  2601. log.warn("health_plans.updated_at 添加失败(可能已存在): " + ex.getMessage());
  2602. }
  2603. // 迁移251c: finance_checkins.child_id 允许NULL(家长本人打卡不选孩子,Service已支持memberId为空;原NOT NULL无默认值导致INSERT报错)
  2604. try {
  2605. jdbcTemplate.execute("ALTER TABLE finance_checkins MODIFY COLUMN child_id BIGINT NULL COMMENT '孩子ID,家长本人打卡时为NULL'");
  2606. log.info("已修改finance_checkins.child_id字段允许NULL");
  2607. } catch (Exception e) {
  2608. log.warn("finance_checkins.child_id 修改失败: " + e.getMessage());
  2609. }
  2610. // 迁移251c: finance_checkins 补齐 type 列(initGuideModuleTables建表版本缺失,实体FinanceCheckin含type字段且收支统计依赖)
  2611. try {
  2612. ensureColumn("finance_checkins", "type", "VARCHAR(10) DEFAULT 'expense' COMMENT '类型: income(收入)/expense(支出)'");
  2613. } catch (Exception e) {
  2614. log.warn("finance_checkins.type 添加失败(可能已存在): " + e.getMessage());
  2615. }
  2616. // 迁移258: 创建energy_daily_limit表(每日能量上限配置持久化)
  2617. try {
  2618. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS energy_daily_limit (" +
  2619. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2620. "dimension_code VARCHAR(20) NOT NULL COMMENT '维度代码: body/mind/wisdom/action/wealth', " +
  2621. "daily_limit INT NOT NULL DEFAULT 50 COMMENT '每日能量上限', " +
  2622. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  2623. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  2624. "UNIQUE KEY uk_dimension_code (dimension_code)" +
  2625. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日能量上限配置'");
  2626. log.info("已创建energy_daily_limit表");
  2627. // 插入默认数据(幂等)
  2628. jdbcTemplate.execute("INSERT IGNORE INTO energy_daily_limit (dimension_code, daily_limit) VALUES " +
  2629. "('body', 50), ('mind', 50), ('wisdom', 80), ('action', 60), ('wealth', 50)");
  2630. log.info("已插入energy_daily_limit默认数据");
  2631. } catch (Exception ex) {
  2632. log.warn("energy_daily_limit 表创建失败(可能已存在): " + ex.getMessage());
  2633. }
  2634. // 迁移251d: diet_preferences 补齐 must_eat 列(实体DietPreferences含mustEat字段,建表时两处均遗漏,查询报Unknown column)
  2635. try {
  2636. ensureColumn("diet_preferences", "must_eat", "JSON COMMENT '必吃食物列表'");
  2637. } catch (Exception e) {
  2638. log.warn("diet_preferences.must_eat 添加失败(可能已存在): " + e.getMessage());
  2639. }
  2640. ensureColumn("file_record", "file_type", "VARCHAR(50) DEFAULT NULL COMMENT '文件类型: image/pdf/video/audio/other'");
  2641. ensureColumn("file_record", "description", "TEXT COMMENT '文件描述'");
  2642. log.info("数据库迁移完成");
  2643. // 迁移252: 体素/心理/认知指标定义种子数据
  2644. try {
  2645. Object[][] rows = {
  2646. {"body", "nutrition", "body_fat", "体脂率(%)", "18-28", "1"},
  2647. {"body", "nutrition", "bmi", "BMI指数", "18.5-24", "2"},
  2648. {"body", "nutrition", "protein", "蛋白质摄入(g/日)", "50-80", "3"},
  2649. {"body", "nutrition", "calcium", "钙摄入(mg/日)", "800-1000", "4"},
  2650. {"body", "nutrition", "vitamin_d", "维生素D(ng/ml)", "30-100", "5"},
  2651. {"body", "chronic", "blood_pressure_systolic", "收缩压(mmHg)", "90-120", "1"},
  2652. {"body", "chronic", "blood_pressure_diastolic", "舒张压(mmHg)", "60-80", "2"},
  2653. {"body", "chronic", "blood_sugar_fasting", "空腹血糖(mmol/L)", "3.9-6.1", "3"},
  2654. {"body", "chronic", "cholesterol_total", "总胆固醇(mmol/L)", "3.1-5.2", "4"},
  2655. {"body", "chronic", "triglyceride", "甘油三酯(mmol/L)", "0.56-1.7", "5"},
  2656. {"body", "exercise", "step_count", "日均步数", "6000-10000", "1"},
  2657. {"body", "exercise", "sleep_duration", "睡眠时长(小时)", "7-9", "2"},
  2658. {"mind", "emotion", "emotion_stability", "情绪稳定性", "0-100", "1"},
  2659. {"mind", "emotion", "anxiety_level", "焦虑水平", "0-100", "2"},
  2660. {"mind", "emotion", "sleep_quality", "睡眠质量", "0-100", "3"},
  2661. {"mind", "personality", "openness", "开放性", "0-100", "1"},
  2662. {"mind", "personality", "conscientiousness", "尽责性", "0-100", "2"},
  2663. {"mind", "personality", "extraversion", "外向性", "0-100", "3"},
  2664. {"mind", "personality", "agreeableness", "宜人性", "0-100", "4"},
  2665. {"mind", "personality", "neuroticism", "神经质(反向)", "0-100", "5"},
  2666. {"wisdom", "cognitive", "perception", "感知能力", "0-100", "1"},
  2667. {"wisdom", "cognitive", "focus", "专注力", "0-100", "2"},
  2668. {"wisdom", "cognitive", "memory", "记忆力", "0-100", "3"},
  2669. {"wisdom", "cognitive", "logic", "逻辑思维", "0-100", "4"},
  2670. {"wisdom", "cognitive", "spatial", "空间思维", "0-100", "5"},
  2671. {"wisdom", "cognitive", "processing_speed", "加工速度", "0-100", "6"},
  2672. {"wisdom", "cognitive", "growth_mindset", "成长型思维", "0-100", "7"}
  2673. };
  2674. for (Object[] r : rows) {
  2675. jdbcTemplate.update(
  2676. "INSERT IGNORE INTO indicator_definitions (domain, category, code, name, data_type, ref_range, sort_order, status) VALUES (?,?,?,?,?,?,?,1)",
  2677. r[0], r[1], r[2], r[3], "number", r[4], Integer.parseInt((String) r[5])
  2678. );
  2679. }
  2680. log.info("迁移252: indicator_definitions 种子数据已加载 ({}条)", rows.length);
  2681. } catch (Exception e) {
  2682. log.warn("迁移252失败: {}", e.getMessage());
  2683. }
  2684. log.info("数据库迁移完成");
  2685. // ==================== 健康维度 Phase 1: health_dimension_score / health_data_source_record / health_norm_reference ============= migrateHealthDimensionsTable();
  2686. seedHealthNorms();
  2687. seedMindActionWealthNorms();
  2688. runLateMigrations();
  2689. migration102();
  2690. // 迁移226-247: 原seedFullRegionData内的DDL迁移,确保不因省市区数据已存在而跳过
  2691. runFullRegionDataMigrations();
  2692. // seedFullRegionData(); 已废弃,区域数据由独立迁移处理
  2693. // 迁移269: sleep_record 修改 sleep_time/wake_time 为 TIME(前端按 HH:mm 打卡,java.sql.Time 三段格式)
  2694. try {
  2695. jdbcTemplate.execute("ALTER TABLE sleep_record MODIFY COLUMN sleep_time TIME, MODIFY COLUMN wake_time TIME");
  2696. log.info("已修改 sleep_record 时间为 TIME 类型");
  2697. } catch (Exception e) {
  2698. log.warn("sleep_record 时间列修改失败(可能已为 TIME): {}", e.getMessage());
  2699. }
  2700. // ==================== 迁移270: 先天画像来源权重配置表 =============
  2701. try {
  2702. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS innate_portrait_config (" +
  2703. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  2704. "source_type VARCHAR(20) NOT NULL," +
  2705. "source_name VARCHAR(50) NOT NULL," +
  2706. "weight INT DEFAULT 25," +
  2707. "enabled TINYINT(1) DEFAULT 1," +
  2708. "ai_enabled TINYINT(1) DEFAULT 1," +
  2709. "sort_order INT DEFAULT 0," +
  2710. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  2711. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  2712. "UNIQUE KEY uk_source (source_type)" +
  2713. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='先天画像来源权重配置'");
  2714. Integer cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM innate_portrait_config", Integer.class);
  2715. if (cnt == null || cnt == 0) {
  2716. jdbcTemplate.batchUpdate(
  2717. "INSERT INTO innate_portrait_config (source_type, source_name, weight, sort_order) VALUES (?, ?, ?, ?)",
  2718. java.util.Arrays.asList(new Object[][]{
  2719. {"zodiac", "星座", 2500, 1},
  2720. {"bazi", "八字", 2500, 2},
  2721. {"blood", "血型", 2500, 3},
  2722. {"numsoul", "数字能量", 2500, 4},
  2723. })
  2724. );
  2725. log.info("迁移270: innate_portrait_config 种子数据已加载 (4条)");
  2726. }
  2727. log.info("迁移270: innate_portrait_config 表创建完成");
  2728. } catch (Exception e) {
  2729. log.warn("迁移270失败: {}", e.getMessage());
  2730. }
  2731. // ==================== 迁移271: 先天画像报告缓存表 =============
  2732. try {
  2733. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS innate_portrait_report (" +
  2734. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  2735. "member_id BIGINT NOT NULL," +
  2736. "member_type VARCHAR(10) NOT NULL," +
  2737. "family_id BIGINT NOT NULL," +
  2738. "portrait_json TEXT," +
  2739. "ai_reading TEXT," +
  2740. "generated_at DATETIME," +
  2741. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  2742. "UNIQUE KEY uk_member (member_id, member_type)" +
  2743. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='先天画像报告缓存'");
  2744. log.info("迁移271: innate_portrait_report 表创建完成");
  2745. } catch (Exception e) {
  2746. log.warn("迁移271失败: {}", e.getMessage());
  2747. }
  2748. // ==================== 迁移272: 数字能量详细配置表 =============
  2749. try {
  2750. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS numsoul_detail_config (" +
  2751. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  2752. "number_type VARCHAR(20) NOT NULL," +
  2753. "number_value INT NOT NULL," +
  2754. "title VARCHAR(50)," +
  2755. "keywords VARCHAR(200)," +
  2756. "mind_base INT DEFAULT 0," +
  2757. "mind_advice TEXT," +
  2758. "color_hex VARCHAR(10)," +
  2759. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  2760. "UNIQUE KEY uk_type_num (number_type, number_value)" +
  2761. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字能量详细配置'");
  2762. Integer cnt2 = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM numsoul_detail_config", Integer.class);
  2763. if (cnt2 == null || cnt2 == 0) {
  2764. jdbcTemplate.batchUpdate(
  2765. "INSERT INTO numsoul_detail_config (number_type, number_value, title, keywords, mind_base, mind_advice, color_hex) VALUES (?, ?, ?, ?, ?, ?, ?)",
  2766. java.util.Arrays.asList(new Object[][]{
  2767. {"life_path", 1, "开创者", "独立、领导、创新", 68, "培养团队协作意识,避免独断专行", "#FF4444"},
  2768. {"life_path", 2, "和平者", "温和、合作、敏感", 72, "增强自我主张,避免过度迎合", "#FF8C00"},
  2769. {"life_path", 3, "表达者", "创意、社交、乐观", 78, "保持专注,避免浅尝辄止", "#FFD700"},
  2770. {"life_path", 4, "建设者", "务实、稳定、有序", 70, "学会变通,避免过于固执", "#4CAF50"},
  2771. {"life_path", 5, "自由者", "冒险、变化、好奇", 75, "培养持久力,避免缺乏恒心", "#2196F3"},
  2772. {"life_path", 6, "关怀者", "责任、关爱、奉献", 80, "学会自我关爱,避免过度付出", "#9C27B0"},
  2773. {"life_path", 7, "探索者", "智慧、分析、内省", 76, "与人分享见解,避免过度封闭", "#673AB7"},
  2774. {"life_path", 8, "成就者", "雄心、管理、决策", 72, "关注内在成长,避免过度追求外在", "#3F51B5"},
  2775. {"life_path", 9, "博爱者", "包容、智慧、善良", 82, "设定个人边界,避免过度牺牲", "#E91E63"},
  2776. {"life_path", 11, "启明者", "直觉、灵感、理想", 85, "落地执行,避免空想", "#7C4DFF"},
  2777. {"life_path", 22, "建造师", "远见、实践、宏图", 88, "分步落地大目标", "#536DFE"},
  2778. {"life_path", 33, "引路人", "大爱、奉献、疗愈", 90, "先照顾好自己再照亮他人", "#FF4081"},
  2779. })
  2780. );
  2781. log.info("迁移272: numsoul_detail_config 生命灵数种子已加载");
  2782. }
  2783. log.info("迁移272: numsoul_detail_config 表创建完成");
  2784. } catch (Exception e) {
  2785. log.warn("迁移272失败: {}", e.getMessage());
  2786. }
  2787. // 迁移273: dan_report_uploads.file_id 加宽(OSS 完整 URL 超过原 VARCHAR(100) 导致截断报错)
  2788. try {
  2789. jdbcTemplate.execute("ALTER TABLE dan_report_uploads MODIFY COLUMN file_id VARCHAR(500) COMMENT '媒资文件ID或相对路径'");
  2790. log.info("迁移273: 已加宽 dan_report_uploads.file_id 为 VARCHAR(500)");
  2791. } catch (Exception e) {
  2792. log.warn("迁移273失败: {}", e.getMessage());
  2793. }
  2794. // 迁移274: dan_assessment_results.report_file_id 加宽(确认入库写入 OSS URL 超长截断)
  2795. try {
  2796. jdbcTemplate.execute("ALTER TABLE dan_assessment_results MODIFY COLUMN report_file_id VARCHAR(500) COMMENT '上传文件媒资ID或相对路径'");
  2797. log.info("迁移274: 已加宽 dan_assessment_results.report_file_id 为 VARCHAR(500)");
  2798. } catch (Exception e) {
  2799. log.warn("迁移274失败: {}", e.getMessage());
  2800. }
  2801. // 迁移275: dan_assessment_results.dan_level 兼容旧库(NOT NULL 无默认导致家长上传确认失败)
  2802. ensureColumn("dan_assessment_results", "dan_level",
  2803. "VARCHAR(10) NULL DEFAULT NULL COMMENT 'DAN等级A/B/C/D'");
  2804. try {
  2805. jdbcTemplate.execute("ALTER TABLE dan_assessment_results MODIFY COLUMN dan_level VARCHAR(10) NULL DEFAULT NULL COMMENT 'DAN等级A/B/C/D'");
  2806. log.info("迁移275: 已将 dan_assessment_results.dan_level 改为可空并带默认");
  2807. } catch (Exception e) {
  2808. log.warn("迁移275失败: {}", e.getMessage());
  2809. }
  2810. runSystemMigrations();
  2811. registerCollectExpectedIndicators();
  2812. // 减脂专题表创建(远程 fatloss 功能)
  2813. createFatWeightRecordTable();
  2814. createFatDietRecordTable();
  2815. createFatExerciseRecordTable();
  2816. createFatGoalTable();
  2817. addProductVendorVisibleColumn();
  2818. // 迁移367: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记)
  2819. addProductHealthConstraintColumns();
  2820. // 迁移368: 创建 drug_interactions 表(药物-食物禁忌对照表)
  2821. createDrugInteractionsTable();
  2822. // 迁移369: 创建 disease_intake_limits 表(疾病-饮食摄入阈值)
  2823. createDiseaseIntakeLimitsTable();
  2824. // 迁移370: 初始化 drug_interactions 种子数据
  2825. seedDrugInteractionsData();
  2826. // 迁移371: 初始化 disease_intake_limits 种子数据
  2827. seedDiseaseIntakeLimitsData();
  2828. // 指标统一层迁移(356~362)
  2829. migrateIndicatorDefinitionsColumns();
  2830. migrateIndicatorValuesColumns();
  2831. addIndicatorValuesKeys();
  2832. createIndicatorMappingRulesTable();
  2833. createIndicatorCorrelationsTable();
  2834. ensureIndicatorCollectionDate();
  2835. seedDimensionDefinitions();
  2836. // 商品组合推荐与采购任务迁移(363)
  2837. createProcurementTaskTable();
  2838. // 迁移364: health_report_drafts 添加解析进度/重试字段(known-type 解析失败退避重试,不再静默回退 opencode)
  2839. addDraftParseProgressColumns();
  2840. // 迁移365: diet_preferences 添加无特殊偏好标记(问卷第3步「无特殊偏好」一键跳过落库)
  2841. ensureColumn("diet_preferences", "no_preference", "TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)'");
  2842. // 迁移366: 9.9 元/月健康会员复用既有 BASIC「体验会员」等级(迁移360/schema.sql 已创建),
  2843. // 移除早期误建的重复等级 LITE(与 BASIC 同为 9.9 元/月),并迁移可能已落库的 LITE 记录。
  2844. // 三段独立 try,确保任一段异常都不会阻断后续的等级删除。
  2845. try {
  2846. jdbcTemplate.update("UPDATE family_memberships SET level_code = 'BASIC' WHERE level_code = 'LITE'");
  2847. } catch (Exception e) {
  2848. log.warn("迁移366: 迁移 family_memberships 中的 LITE 记录失败: {}", e.getMessage());
  2849. }
  2850. try {
  2851. jdbcTemplate.update("UPDATE users SET member_level = 'BASIC' WHERE member_level = 'LITE'");
  2852. } catch (Exception e) {
  2853. log.warn("迁移366: 迁移 users.member_level 中的 LITE 记录失败: {}", e.getMessage());
  2854. }
  2855. try {
  2856. int removed = jdbcTemplate.update("DELETE FROM membership_levels WHERE level_code = 'LITE'");
  2857. log.info("迁移366: 9.9 元/月健康会员统一使用 BASIC 体验会员,已移除重复的 LITE 等级({} 行)", removed);
  2858. } catch (Exception e) {
  2859. log.warn("迁移366: 清理 LITE 等级失败或不存在: {}", e.getMessage());
  2860. }
  2861. // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签)
  2862. addNutritionProductEfficacyTags();
  2863. // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
  2864. addProductTagsColumn();
  2865. // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表)
  2866. addUserContentWeightsTable();
  2867. // 迁移375: 修复 article_quiz_records 表结构(旧表缺 member_id/total_questions/cf_earned/energy_earned,questions/answers 需为 TEXT)
  2868. fixArticleQuizRecordsTable();
  2869. // 迁移376: health_status 添加 lifestyle JSON 列(心情/睡眠/排便/抗生素/过敏表现)
  2870. ensureColumn("health_status", "lifestyle", "JSON DEFAULT NULL COMMENT '生活方式与肠道健康 {mood,sleepDuration,bedtime,bowelFrequency,bowelForm,antibioticHistory,allergySymptoms}'");
  2871. // 迁移377: diet_preferences 添加 diet_structure JSON 列(蔬果量/乳制品/含糖饮料频率)
  2872. ensureColumn("diet_preferences", "diet_structure", "JSON DEFAULT NULL COMMENT '饮食结构现状 {vegetableFruit,dairy,sugaryDrinks}'");
  2873. // 迁移378: 创建 member_indicator_current 表(成员指标当前值 + 相对上次变化 delta)
  2874. addMemberIndicatorCurrentTable();
  2875. // 迁移379: 创建 report_member_permissions 表(管理员授权某用户查看某成员的全部报告)
  2876. addReportMemberPermissionsTable();
  2877. // 迁移380: 修正 FAMILY 会员虚拟道具映射 member1314 → member1980(会员等级表 FAMILY 年费 ¥1980,原道具价 ¥1314 与下单金额不一致,会被微信"下单金额=道具价"校验拒付)
  2878. fixFamilyMembershipVirtualGoodsMapping();
  2879. // 迁移381: 创建 activity_poster_templates 表 + 初始化 3 套默认海报模板(Web 管理端定义 → 小程序端海报模板选择)
  2880. createActivityPosterTemplatesTable();
  2881. // 迁移385: 活动报名添加驳回原因列(审核中心详情页驳回时写入)
  2882. addActivityRegistrationRejectReasonColumn();
  2883. // 迁移386: 清理规划师占位假数据(teacher_status='pending' 且 role<>'teacher'),默认值改 NULL
  2884. cleanPlannerPlaceholderData();
  2885. // 迁移387: 下线独立“服务角色申请审核”菜单(申请按角色分流合并进审核中心)
  2886. hideServiceRoleApplicationMenu();
  2887. // 迁移388: activities表添加intro列(活动简介,纯文本短摘要,用于活动列表卡片展示)
  2888. ensureColumn("activities", "intro", "VARCHAR(200) DEFAULT NULL COMMENT '活动简介(列表卡片展示)'");
  2889. // 迁移389: activities表添加tags列(活动标签,逗号分隔,根据活动内容自动生成)
  2890. ensureColumn("activities", "tags", "VARCHAR(255) DEFAULT NULL COMMENT '活动标签,逗号分隔'");
  2891. // 迁移382: health_status 添加 signature 列(用户手签名,base64 PNG data URL,用于问卷回答确认)
  2892. ensureColumn("health_status", "signature", "MEDIUMTEXT DEFAULT NULL COMMENT '用户手签名(base64 PNG data URL),确认问卷回答准确性'");
  2893. // 迁移383: health_status 添加 signed_at 列(手签名时间;问卷内容变更后置空,需重新签署)
  2894. ensureColumn("health_status", "signed_at", "DATETIME DEFAULT NULL COMMENT '手签名时间(问卷变更后置空,需重新签署)'");
  2895. // 迁移384: water_record 添加 task_id 列(喝水打卡任务回写,与 health_meal_record.task_id 同款)
  2896. ensureColumn("water_record", "task_id", "BIGINT NULL COMMENT '关联任务ID(喝水打卡任务回写)'");
  2897. // 迁移385: family_platform_exchange_record 添加 family_coupon_id 列(把 CF 兑换记录与发出的券实例绑定)
  2898. // 目的:订单取消/退款时,对用户主动选择使用的券实例能精确判断「是否为 CF 兑换而来」并退还 CF 值;
  2899. // 平台赠送券(JOIN/POPULATION/MANUAL)无对应兑换记录,退款时不退 CF。
  2900. ensureColumn("family_platform_exchange_record", "family_coupon_id",
  2901. "BIGINT DEFAULT NULL COMMENT '兑换发放的优惠券实例ID(family_coupon.id), 退款时据此退还CF值'");
  2902. // 迁移388: activities 添加 poster_template_id 列(小程序端创建活动时选择海报模板并保存,详情页生成海报时默认使用)
  2903. ensureColumn("activities", "poster_template_id",
  2904. "BIGINT NULL COMMENT '创建时选择的海报模板ID(activity_poster_templates.id, 小程序端海报模板选择)'");
  2905. // 迁移389: 创建 special_price_deals 表(限时特价活动/扫码闪购,见 specs/2026-10-20-limited-time-special-price-design.md)
  2906. try {
  2907. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS special_price_deals (" +
  2908. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  2909. "product_id BIGINT NOT NULL," +
  2910. "name VARCHAR(100) NOT NULL," +
  2911. "special_price INT NOT NULL COMMENT '特价(分)'," +
  2912. "start_time DATETIME," +
  2913. "end_time DATETIME," +
  2914. "total_quantity INT NOT NULL DEFAULT 0," +
  2915. "sold_quantity INT NOT NULL DEFAULT 0," +
  2916. "per_user_limit INT NOT NULL DEFAULT 1," +
  2917. "allow_coupon TINYINT NOT NULL DEFAULT 0," +
  2918. "status VARCHAR(20) NOT NULL DEFAULT 'draft'," +
  2919. "created_at DATETIME," +
  2920. "updated_at DATETIME," +
  2921. "INDEX idx_product (product_id)," +
  2922. "INDEX idx_status (status, start_time, end_time)" +
  2923. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时特价活动'");
  2924. log.info("迁移389: 已创建 special_price_deals 表");
  2925. } catch (Exception e) {
  2926. log.warn("迁移389: 创建 special_price_deals 表失败(可能已存在): {}", e.getMessage());
  2927. }
  2928. // 迁移390: product_orders 添加 special_deal_id 列(限时特价订单关联)
  2929. ensureColumn("product_orders", "special_deal_id", "BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)'");
  2930. // 迁移391: activity_registrations 添加取消时间/原因列(P0-2 取消报名支持 approved + 退款链路,取消时记录)
  2931. ensureColumn("activity_registrations", "cancelled_at", "DATETIME DEFAULT NULL COMMENT '取消报名时间'");
  2932. ensureColumn("activity_registrations", "cancel_reason", "VARCHAR(200) DEFAULT NULL COMMENT '取消原因'");
  2933. // 迁移392: activity_feedback 添加 updated_at 列 + activity_id+child_id 唯一索引(P1-3 评价去重:同一孩子只能评一次,重复提交改为更新)
  2934. try {
  2935. ensureColumn("activity_feedback", "updated_at", "DATETIME DEFAULT NULL COMMENT '更新时间(重复提交评价时更新)'");
  2936. jdbcTemplate.execute("ALTER TABLE activity_feedback ADD UNIQUE KEY uk_activity_child (activity_id, child_id)");
  2937. log.info("迁移392: 已为 activity_feedback 添加 updated_at 列和 uk_activity_child 唯一索引");
  2938. } catch (Exception e) {
  2939. log.warn("迁移392: 添加 activity_feedback.updated_at / uk_activity_child 失败(可能已存在): {}", e.getMessage());
  2940. }
  2941. // 迁移393: 创建 organizations 表(机构/企业组织主表)
  2942. try {
  2943. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `organizations` (" +
  2944. "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2945. "`name` VARCHAR(128) NOT NULL COMMENT '组织名称', " +
  2946. "`type` VARCHAR(32) COMMENT 'enterprise/institution/other', " +
  2947. "`code` VARCHAR(64) UNIQUE COMMENT '组织编号(唯一标识)', " +
  2948. "`admin_user_id` BIGINT COMMENT '组织管理员 userId(关联 users.id)', " +
  2949. "`contact_name` VARCHAR(64) COMMENT '联系人姓名', " +
  2950. "`contact_phone` VARCHAR(32) COMMENT '联系人电话', " +
  2951. "`description` TEXT COMMENT '组织描述', " +
  2952. "`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', " +
  2953. "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  2954. "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  2955. "INDEX `idx_admin_user_id` (`admin_user_id`), " +
  2956. "INDEX `idx_status` (`status`) " +
  2957. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构/企业组织主表'");
  2958. log.info("迁移393: 已创建 organizations 表");
  2959. } catch (Exception e) {
  2960. log.warn("迁移393: 创建 organizations 表失败(可能已存在): {}", e.getMessage());
  2961. }
  2962. // 迁移393b: 创建 org_members 表(组织成员关联表)
  2963. try {
  2964. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `org_members` (" +
  2965. "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  2966. "`org_id` BIGINT NOT NULL COMMENT '组织ID', " +
  2967. "`user_id` BIGINT NOT NULL COMMENT '用户ID(users.id)', " +
  2968. "`member_type` VARCHAR(16) NOT NULL COMMENT 'planner/user', " +
  2969. "`role_in_org` VARCHAR(32) COMMENT '组织内角色(teacher/student/member等)', " +
  2970. "`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', " +
  2971. "`joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  2972. "UNIQUE KEY `uk_org_user` (`org_id`, `user_id`), " +
  2973. "INDEX `idx_org_id` (`org_id`), " +
  2974. "INDEX `idx_user_id` (`user_id`), " +
  2975. "INDEX `idx_status` (`status`) " +
  2976. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织成员关联表'");
  2977. log.info("迁移393b: 已创建 org_members 表");
  2978. } catch (Exception e) {
  2979. log.warn("迁移393b: 创建 org_members 表失败(可能已存在): {}", e.getMessage());
  2980. }
  2981. // 迁移394: 创建 planner_cert_categories 表 + 初始化首批学习种类(planner/nutritionist/assessor)
  2982. // 见 docs/superpowers/specs/2026-10-22-planner-learning-assessment-gate-design.md
  2983. createPlannerCertCategoriesTable();
  2984. // 迁移395: 创建 planner_courses 表(线上课程目录,映射小鹅通课程)
  2985. createPlannerCoursesTable();
  2986. // 迁移396: 创建 planner_course_progress 表(每用户每课程进度,小鹅通回传/手动同步写入)
  2987. createPlannerCourseProgressTable();
  2988. // 迁移397: 创建 planner_offline_course_map 表(线下培训活动↔认证类别桥接,复用 activities 模块)
  2989. createPlannerOfflineCourseMapTable();
  2990. // 迁移398: 创建 activity_checkins 表(活动签到记录,供培训课时桥接,修复签到审核形同虚设旧患)
  2991. createActivityCheckinsTable();
  2992. // 迁移399: 创建 planner_certifications 表(规划师每类别培训认证主表,认证闸门依据)
  2993. createPlannerCertificationsTable();
  2994. // 迁移400: 创建 planner_exam_papers / planner_exam_questions / planner_exam_attempts 表(考核自建)
  2995. createPlannerExamTables();
  2996. // 迁移401: 存量规划师补登认证(D6:active guide 按已持有基础类别 planner certified 补登,避免误封存量)
  2997. backfillExistingPlannerCertifications();
  2998. // 迁移402: delivery_method COMMENT 增加 6=同城配送(履约多渠道,规格 2026-10-22-fulfillment-multi-mode-design)
  2999. addDeliveryMethod6Comments();
  3000. // 迁移403: withdrawal_requests 增加 reject_reason 列(审核中心统一详情:提现驳回原因与活动报名/服务角色申请口径一致)
  3001. addWithdrawalRejectReasonColumn();
  3002. // 迁移404: articles.audio_url 由 varchar(500) 改为 MEDIUMTEXT(多段音频JSON数组可超500字符)
  3003. alterArticleAudioUrlToMediumtext();
  3004. // 迁移406: activity_poster_templates 增加 layout_config 列(海报模板元素位置/样式可配置,需求:活动详情海报预览重设计)
  3005. addActivityPosterTemplateLayoutConfigColumn();
  3006. // 迁移407: 创建 cf_balance / cf_balance_log(个人 CF 值账本,成员维度;F-1)
  3007. createCfBalanceTables();
  3008. // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history + 24 行规则种子(F02–F24;F-1)
  3009. createCfEarnRuleTables();
  3010. seedCfEarnRules();
  3011. // 迁移409: 创建 cf_direct_sales_tier / cf_indirect_sales_tier(两级销售奖档位;F-1)
  3012. createCfSalesTierTables();
  3013. // 迁移410: 创建 cf_earn_daily_limit + 会员等级日限种子(N11;F-1)
  3014. createCfEarnDailyLimitTable();
  3015. seedCfEarnDailyLimits();
  3016. // 迁移411: coupon / user_coupon 补 family_id(优惠券家庭化 N3;F-1)
  3017. addCouponFamilyIdColumns();
  3018. // 迁移412: 创建 service_config + 散落参数迁入 + CF 通用运营参数种子(W0.1)
  3019. // 顺序:先迁旧位置实际值,再补种子默认值(避免默认值覆盖 sys_config 里的真实配置)
  3020. createServiceConfigTable();
  3021. migrateScatteredCfConfigs();
  3022. seedCfServiceConfig();
  3023. // 迁移413: service_config 收编 product_config(W0.2 双配置中心合并)
  3024. // 幂等:product_key 列/索引已存在则跳过;product_config 表不存在时数据并入为空跑
  3025. addServiceConfigProductKeyColumn();
  3026. migrateProductConfigToServiceConfig();
  3027. // 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)
  3028. createAuditLogTable();
  3029. // 迁移415: 创建 family_food_overview 表(家庭×食材 持久化最小值总览;T28)
  3030. // 家庭总览取"家庭成员该项食材最新推荐指数的最小值"并持久化,由 recalculateForUser 触发重算
  3031. createFamilyFoodOverviewTable();
  3032. // 迁移416: cf_balance_log 补 operator_user_id + 幂等键升级 (member_id,type,ref_type,ref_id)
  3033. // + CF 提现开关/汇率种子(F-2)
  3034. migrateCfBalanceLogForLedger();
  3035. // 迁移417: 三套旧 CF 账本一次性迁入 cf_balance(F-6 存量迁移)
  3036. // 顺序:必须在迁移416(uk_ref_type 唯一键升级)之后执行,幂等守卫与新唯一键保持一致。
  3037. // 幂等 ref_type 分别为 migration_family_pool / migration_system_points /
  3038. // migration_user_wallet,重复启动不会重复入账。
  3039. try {
  3040. int migrated = cfLegacyLedgerMigrationService.migrate();
  3041. log.info("迁移417: 旧账本存量迁移完成,本次新增 {} 笔", migrated);
  3042. } catch (Exception e) {
  3043. // 事务已整体回滚;保留启动可用性,下次启动可安全重试。
  3044. log.warn("迁移417: 旧账本存量迁移失败,本批已回滚,下次启动重试: {}", e.getMessage());
  3045. }
  3046. // 迁移418: 两级销售奖档位种子 + F03a/F03b 幂等 ref_type 对齐(F-4 分佣收口)
  3047. seedCfSalesTiers();
  3048. alignSalesRuleRefTypes();
  3049. // 迁移419: membership_levels 补 max_daily_analyze(会员等级每日报告解析次数上限)
  3050. // 背景:ReportAnalyzeQuotaService(报告解析配额)已入库,但 MembershipLevel 实体字段与
  3051. // membership_levels 表列在历史提交中丢失,导致编译失败 + 运行时 Unknown column。
  3052. // 此处补列(幂等);0/NULL 表示不限次。
  3053. ensureColumn("membership_levels", "max_daily_analyze",
  3054. "INT DEFAULT NULL COMMENT '每日报告解析次数上限(0/NULL=不限)'");
  3055. // 迁移420: 创建 ppoint_consumption_log(滚动一年销售奖档位聚合数据源;F-4)
  3056. // 注:迁移419 已被 membership_levels.max_daily_analyze 占用,本表顺延为 420。
  3057. createPpointConsumptionLogTable();
  3058. // 迁移421: activities 表新增适用人群字段 audience_scope / audience_type(A2 活动人群与费用模型重构)
  3059. // 说明:沿用 visible_scope 迁移的 information_schema.COLUMNS 存在性检查范式,
  3060. // 以便用 AFTER visible_to 精确定位列顺序(ensureColumn 不支持列定位)。
  3061. addActivityAudienceColumns();
  3062. // 迁移422: 指标多期展示(DS-2026-10-25-01)
  3063. // 顺序红线:先确保 health_indicators.collection_date 物理列存在,才能放开实体映射;
  3064. // 顺序颠倒会导致缺列环境所有 health_indicators 查询报 Unknown column。
  3065. ensureHealthIndicatorsCollectionDateColumn();
  3066. migrateIndicatorValuesTimelineColumns();
  3067. // 迁移423: 体脂秤测量记录表(sourceId 由 memberId 改为 recordId 的前置表)
  3068. createBodyFatScaleRecordsTable();
  3069. // 迁移424: health_plans 加 plan_type(区分健康方案/家庭归因干预方案;G5)
  3070. migrateHealthPlansPlanType();
  3071. // 迁移425: 家庭归因常模值表(G10,菌群 710 常模落点)
  3072. createAttributionNormValuesTable();
  3073. // 迁移425-导入: 菌群常模可配置 JSON 导入(空壳文件导入 0 行,不报错)
  3074. importAttributionNormGutFlora();
  3075. // 迁移425-种子: 家庭归因确认问卷模板种子(第八章 16 条,幂等)
  3076. seedFamilyAttributionSurveyTemplate();
  3077. // 迁移426: 家庭归因报告表 attribution_reports(B1 核心闭环③,report_json+intermediate_state+redline_hits+status)
  3078. createAttributionReportsTable();
  3079. // 迁移427: 创建 content_dimension_threshold 表(A-后端 内容维度阈值显隐配置:商品/文章/活动/游戏 五维+子项+自定义指标)
  3080. createContentDimensionThresholdTable();
  3081. // 迁移428: 会员优惠券转赠表 coupon_gift_transfer + family_coupon.is_gift 列
  3082. // (用户可把自己的会员券转赠给推荐下级,用于购买会员,可撤回)
  3083. createCouponGiftTransferTable();
  3084. // 迁移429: 用户关注指标收藏表 indicator_favorite(T04 方案A:后端持久化,跨端同步)
  3085. createIndicatorFavoriteTable();
  3086. // 迁移430: 商品成本价改为整型(分) —— P点口径:P点 = 实际成交额 - 成本价(分)
  3087. migrateProductsPurchasePriceToCents();
  3088. // 迁移431: 销售奖档位门槛 ×100(元→分)+ 档位名改为 新秀/小成/中富/大成/至富
  3089. migrateSalesAwardTierThresholds();
  3090. // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等)
  3091. migrateFamilyNameBackfill();
  3092. // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等)
  3093. migrateFamilyNameBackfill();
  3094. // 迁移432: report_type_registry 添加 linked_product_id 列(报告类型→商城商品链接配置,规格 2026-10-30-report-type-product-link-design)
  3095. ensureColumn("report_type_registry", "linked_product_id",
  3096. "BIGINT DEFAULT NULL COMMENT '关联商城商品 products.id,NULL=不可链接'");
  3097. // 迁移433: 家庭指标聚合方式配置表(五维能量汇总/珍珠图/章鱼图汇集配置)
  3098. createFamilyMetricAggConfigTable();
  3099. // 迁移434: SKU商品组合明细表 + 订单记录下单SKU(组合SKU识别)
  3100. migrateSkuCombo();
  3101. // 迁移436: products 表添加营养数据字段(热量/蛋白质/脂肪/碳水/膳食纤维/营养标签)
  3102. migrateProductNutritionFields();
  3103. // 迁移437: T35 活动管理团队 —— activities.team_id + activity_team/activity_team_member/service_assignment 表
  3104. migrateActivityTeam();
  3105. // 迁移440: 套餐内容明细表(套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级)
  3106. migrateProductComboItems();
  3107. }
  3108. /**
  3109. * 迁移430: 商品成本价改为整型(分)(P点口径调整)。
  3110. *
  3111. * <p>原 products.purchase_price DECIMAL(10,2),值与 price 同量级(分);改为 INT 存分。
  3112. * 仅改类型不做数值换算(幂等:已是 INT 则跳过)。</p>
  3113. */
  3114. private void migrateProductsPurchasePriceToCents() {
  3115. try {
  3116. String colType = jdbcTemplate.queryForObject(
  3117. "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() "
  3118. + "AND TABLE_NAME='products' AND COLUMN_NAME='purchase_price'", String.class);
  3119. if ("decimal".equalsIgnoreCase(colType)) {
  3120. // 值本身已是分(与 price 同量级、低于售价),只改类型不做数值换算
  3121. jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN purchase_price INT NOT NULL DEFAULT 0 COMMENT '成本价(分)'");
  3122. log.info("迁移430完成:products.purchase_price decimal→int(分)(值已是分,仅改类型)");
  3123. } else {
  3124. log.info("迁移430跳过:products.purchase_price 已是 {} 类型", colType);
  3125. }
  3126. } catch (Exception e) {
  3127. log.warn("迁移430失败(幂等容忍): {}", e.getMessage());
  3128. }
  3129. }
  3130. /**
  3131. * 迁移431: 销售奖档位门槛由元改为分(×100),并按 id 重命名为新档位名(费率不变)。
  3132. *
  3133. * <p>档位:新秀(1w)/小成(5w)/中富(20w)/大成(50w)/至富(100w),单位分。</p>
  3134. */
  3135. private void migrateSalesAwardTierThresholds() {
  3136. try {
  3137. // 唯一键 uk_min 冲突规避:先设临时值(唯一且不撞最终值),再设最终值。
  3138. // 单条 CASE UPDATE 逐行执行时,若直接改 100→10000 会与尚未更新的 10000(原钻石)撞 uk_min。
  3139. jdbcTemplate.execute(
  3140. "UPDATE cf_direct_sales_tier SET min_annual_ppoints = 9000000 + id, tier_name = CONCAT('TMP', id) "
  3141. + "WHERE id BETWEEN 1 AND 5 AND tier_name IN ('青铜','白银','黄金','铂金','钻石')");
  3142. jdbcTemplate.execute(
  3143. "UPDATE cf_direct_sales_tier SET min_annual_ppoints = CASE id "
  3144. + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, "
  3145. + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' "
  3146. + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END "
  3147. + "WHERE id BETWEEN 1 AND 5 AND min_annual_ppoints BETWEEN 9000001 AND 9000005");
  3148. jdbcTemplate.execute(
  3149. "UPDATE cf_indirect_sales_tier SET min_self_ppoints = 9000000 + id, tier_name = CONCAT('TMP', id) "
  3150. + "WHERE id BETWEEN 1 AND 5 AND tier_name IN ('青铜','白银','黄金','铂金','钻石')");
  3151. jdbcTemplate.execute(
  3152. "UPDATE cf_indirect_sales_tier SET min_self_ppoints = CASE id "
  3153. + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, "
  3154. + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' "
  3155. + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END "
  3156. + "WHERE id BETWEEN 1 AND 5 AND min_self_ppoints BETWEEN 9000001 AND 9000005");
  3157. log.info("迁移431完成:销售奖档位门槛×100(分),档位名新秀/小成/中富/大成/至富");
  3158. } catch (Exception e) {
  3159. log.warn("迁移431失败(幂等容忍): {}", e.getMessage());
  3160. }
  3161. }
  3162. /** 迁移:家庭主账号已填真实姓名但家庭名仍为旧规则 → 回填为「XX家庭」(幂等,重复执行无副作用) */
  3163. private void migrateFamilyNameBackfill() {
  3164. String sql = "UPDATE families f JOIN users u ON u.id = f.creator_id " +
  3165. "SET f.name = CONCAT(TRIM(u.real_name), '家庭'), f.updated_at = NOW() " +
  3166. "WHERE u.real_name IS NOT NULL AND TRIM(u.real_name) <> '' " +
  3167. "AND f.name <> CONCAT(TRIM(u.real_name), '家庭')";
  3168. try {
  3169. int n = jdbcTemplate.update(sql);
  3170. log.info("迁移[家庭名回填XX家庭] 影响行数={}", n);
  3171. } catch (Exception e) {
  3172. log.warn("迁移[家庭名回填XX家庭] 执行异常: {}", e.getMessage());
  3173. }
  3174. }
  3175. /**
  3176. * 迁移421: activities 表新增 audience_scope / audience_type 两列(A2)
  3177. * - audience_scope: all(默认,全部用户可参加) / member_only(仅会员可参加,后端硬门禁)
  3178. * - audience_type : adult(默认,仅成人) / include_child / include_elder / include_child_elder
  3179. * 长者档不新增独立价格列(决策D1),长者复用成人价,audience_type 仅作展示与筛选标签。
  3180. * 幂等:information_schema 检查列是否存在,存在则跳过。
  3181. */
  3182. private void addActivityAudienceColumns() {
  3183. // 迁移421-1: activities表添加audience_scope列
  3184. try {
  3185. Integer colExists = jdbcTemplate.queryForObject(
  3186. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'audience_scope'",
  3187. Integer.class);
  3188. if (colExists == null || colExists == 0) {
  3189. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audience_scope VARCHAR(20) DEFAULT 'all' COMMENT '参与人群范围: all(全部用户)/member_only(仅会员可参加)' AFTER visible_to");
  3190. log.info("已添加audience_scope列到activities表");
  3191. } else {
  3192. log.info("activities表audience_scope列已存在,跳过");
  3193. }
  3194. } catch (Exception e) {
  3195. log.warn("检查/添加activities表audience_scope列失败: {}", e.getMessage());
  3196. }
  3197. // 迁移421-2: activities表添加audience_type列
  3198. try {
  3199. Integer colExists = jdbcTemplate.queryForObject(
  3200. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'audience_type'",
  3201. Integer.class);
  3202. if (colExists == null || colExists == 0) {
  3203. jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audience_type VARCHAR(20) DEFAULT 'adult' COMMENT '参与人群构成: adult(仅成人)/include_child/include_elder/include_child_elder' AFTER audience_scope");
  3204. log.info("已添加audience_type列到activities表");
  3205. } else {
  3206. log.info("activities表audience_type列已存在,跳过");
  3207. }
  3208. } catch (Exception e) {
  3209. log.warn("检查/添加activities表audience_type列失败: {}", e.getMessage());
  3210. }
  3211. }
  3212. /**
  3213. * 迁移436:products 表添加营养数据字段。
  3214. * <p>商品营养数据用于管理端录入、小程序详情页展示与推荐按营养标签筛选。
  3215. * 全部字段允许为 NULL(NULL = 未配置),迁移幂等可重复执行。
  3216. * <p>编号说明:原分支为迁移417,合入主线时 417 已被「旧 CF 账本存量迁移」占用,顺延至主线最新 435 (product_bundle_items放开可空) + 1 = 436。
  3217. */
  3218. private void migrateProductNutritionFields() {
  3219. if (!tableExists("products")) {
  3220. log.warn("迁移436: products 表不存在,跳过营养数据字段迁移");
  3221. return;
  3222. }
  3223. try {
  3224. ensureColumn("products", "calories_kcal", "DECIMAL(10,2) DEFAULT NULL COMMENT '热量(kcal/100g),NULL=未配置'");
  3225. ensureColumn("products", "protein_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '蛋白质(g/100g),NULL=未配置'");
  3226. ensureColumn("products", "fat_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '脂肪(g/100g),NULL=未配置'");
  3227. ensureColumn("products", "carb_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '碳水化合物(g/100g),NULL=未配置'");
  3228. ensureColumn("products", "fiber_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '膳食纤维(g/100g),NULL=未配置'");
  3229. ensureColumn("products", "nutrition_tags", "VARCHAR(255) DEFAULT NULL COMMENT '营养标签,逗号分隔(如\"低糖,高蛋白\"),NULL=无标签'");
  3230. log.info("迁移436: products 表营养数据字段已就绪");
  3231. } catch (Exception e) {
  3232. log.warn("迁移436: products 表营养数据字段迁移失败: {}", e.getMessage());
  3233. }
  3234. }
  3235. /**
  3236. * 迁移437:T35 活动管理团队。
  3237. *
  3238. * <p>activities.team_id 关联 activity_team;activity_team/activity_team_member 承载团队与成员;
  3239. * service_assignment 为通用服务指派表(service_type=ACTIVITY_SUPPORT),供活动运营服务指派使用,
  3240. * 与 T34 服务指派机制同一张表,后续服务类型只需新增 service_type 取值。
  3241. * 全部 DDL 幂等(ensureColumn / CREATE TABLE IF NOT EXISTS)。</p>
  3242. */
  3243. private void migrateActivityTeam() {
  3244. try {
  3245. ensureColumn("activities", "team_id",
  3246. "BIGINT DEFAULT NULL COMMENT '活动管理团队ID(activity_team.id), NULL=无团队; 团队成员可协作运营(迁移437/T35)'");
  3247. log.info("迁移437: activities.team_id 列已就绪");
  3248. } catch (Exception e) {
  3249. log.warn("迁移437: activities.team_id 列检查失败: {}", e.getMessage());
  3250. }
  3251. try {
  3252. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_team (" +
  3253. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  3254. "name VARCHAR(100) NOT NULL COMMENT '团队名称', " +
  3255. "description VARCHAR(500) DEFAULT NULL COMMENT '团队简介', " +
  3256. "owner_id BIGINT NOT NULL COMMENT '团长userId', " +
  3257. "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', " +
  3258. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  3259. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  3260. "INDEX idx_owner (owner_id)" +
  3261. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动管理团队'");
  3262. log.info("迁移437: 已创建 activity_team 表");
  3263. } catch (Exception e) {
  3264. log.warn("迁移437: 创建 activity_team 表失败(可能已存在): {}", e.getMessage());
  3265. }
  3266. try {
  3267. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_team_member (" +
  3268. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  3269. "team_id BIGINT NOT NULL COMMENT '团队ID(activity_team.id)', " +
  3270. "user_id BIGINT NOT NULL COMMENT '成员userId', " +
  3271. "role VARCHAR(32) NOT NULL DEFAULT 'operator' COMMENT 'owner(团长)/manager(运营主管)/operator(运营成员)', " +
  3272. "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active(在团)/removed(已移出)', " +
  3273. "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  3274. "UNIQUE KEY uk_team_user (team_id, user_id), " +
  3275. "INDEX idx_user (user_id)" +
  3276. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动团队成员'");
  3277. log.info("迁移437: 已创建 activity_team_member 表");
  3278. } catch (Exception e) {
  3279. log.warn("迁移437: 创建 activity_team_member 表失败(可能已存在): {}", e.getMessage());
  3280. }
  3281. try {
  3282. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_assignment (" +
  3283. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  3284. "service_type VARCHAR(32) NOT NULL COMMENT '服务类型: ACTIVITY_SUPPORT(活动运营)', " +
  3285. "target_type VARCHAR(32) NOT NULL COMMENT '目标类型: ACTIVITY(具体活动)/ACTIVITY_TEAM(团队整体)', " +
  3286. "target_id BIGINT NOT NULL COMMENT '目标ID', " +
  3287. "team_id BIGINT DEFAULT NULL COMMENT '关联团队ID(activity_team.id)', " +
  3288. "assignee_id BIGINT NOT NULL COMMENT '被指派人userId', " +
  3289. "role VARCHAR(32) NOT NULL DEFAULT 'support' COMMENT 'lead/support/checkin/content', " +
  3290. "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/revoked', " +
  3291. "remark VARCHAR(255) DEFAULT NULL COMMENT '备注', " +
  3292. "assigned_by BIGINT DEFAULT NULL COMMENT '指派操作人userId', " +
  3293. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  3294. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  3295. "UNIQUE KEY uk_service_assign (service_type, target_type, target_id, assignee_id, role), " +
  3296. "INDEX idx_team (team_id), " +
  3297. "INDEX idx_assignee (assignee_id)" +
  3298. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务指派(活动运营ACTIVITY_SUPPORT, T34机制复用)'");
  3299. log.info("迁移437: 已创建 service_assignment 表");
  3300. } catch (Exception e) {
  3301. log.warn("迁移437: 创建 service_assignment 表失败(可能已存在): {}", e.getMessage());
  3302. }
  3303. }
  3304. /**
  3305. * 迁移440:套餐内容明细表 product_combo_items。
  3306. * <p>套餐=独立商品(product_type='bundle'),内容=套餐包含哪些子商品(精确到子商品SKU)×数量;
  3307. * 内容挂套餐SKU级(combo_sku_id)。下单/支付只动套餐SKU自身库存,不扣子商品库存。
  3308. * <p>幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。
  3309. */
  3310. private void migrateProductComboItems() {
  3311. try {
  3312. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_combo_items` ("
  3313. + "`id` BIGINT NOT NULL AUTO_INCREMENT, "
  3314. + "`combo_sku_id` BIGINT NOT NULL COMMENT '套餐SKU ID(product_skus.id),内容挂SKU级', "
  3315. + "`child_product_id` BIGINT NOT NULL COMMENT '子商品ID', "
  3316. + "`child_sku_id` BIGINT NOT NULL COMMENT '子商品SKU ID(精确到规格,必填)', "
  3317. + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该子商品数量', "
  3318. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  3319. + "PRIMARY KEY (`id`), "
  3320. + "UNIQUE KEY `uk_combo_sku_child` (`combo_sku_id`, `child_sku_id`) "
  3321. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐内容明细(套餐商品包含的子商品SKU×数量)'");
  3322. log.info("迁移440: 已创建 product_combo_items 表");
  3323. } catch (Exception e) {
  3324. log.warn("迁移440: 创建 product_combo_items 表失败(可能已存在): {}", e.getMessage());
  3325. }
  3326. }
  3327. private void migrateHealthDimensionsTable() {
  3328. try {
  3329. if (tableExists("health_dimension_score")) {
  3330. jdbcTemplate.execute("RENAME TABLE `health_dimension_score` TO `health_dimension_score_old`");
  3331. }
  3332. } catch (Exception e) {
  3333. log.warn("health_dimension_score rename skipped: {}", e.getMessage());
  3334. }
  3335. try {
  3336. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_dimension_scores` (\n" +
  3337. " `id` bigint NOT NULL AUTO_INCREMENT,\n" +
  3338. " `member_id` bigint NOT NULL COMMENT '家庭成员ID',\n" +
  3339. " `dimension` varchar(20) NOT NULL COMMENT '维度: growth/sleep/vision/immunity/nutrition/gut/exercise',\n" +
  3340. " `score` int NOT NULL COMMENT '0-100分',\n" +
  3341. " `percentile` int DEFAULT NULL COMMENT '人群百分位 0-100',\n" +
  3342. " `data_source` varchar(20) NOT NULL COMMENT '数据源: REPORT/PHOTO/VOICE/MANUAL',\n" +
  3343. " `tier` tinyint NOT NULL DEFAULT 3 COMMENT '数据层级: 1自动/2语音/3手动',\n" +
  3344. " `raw_data` json DEFAULT NULL COMMENT '原始数据快照',\n" +
  3345. " `assess_date` date NOT NULL COMMENT '评估日期',\n" +
  3346. " `expire_date` date DEFAULT NULL COMMENT '数据过期日',\n" +
  3347. " `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
  3348. " `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n" +
  3349. " PRIMARY KEY (`id`),\n" +
  3350. " UNIQUE KEY `uk_member_dim_date` (`member_id`, `dimension`, `assess_date`)\n" +
  3351. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分表'");
  3352. } catch (Exception e) {
  3353. log.warn("health_dimension_scores 表创建失败: {}", e.getMessage());
  3354. }
  3355. try {
  3356. if (tableExists("health_data_source_record")) {
  3357. jdbcTemplate.execute("RENAME TABLE `health_data_source_record` TO `health_data_source_record_old`");
  3358. }
  3359. } catch (Exception e) {
  3360. log.warn("health_data_source_record rename skipped: {}", e.getMessage());
  3361. }
  3362. try {
  3363. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_data_sources` (\n" +
  3364. " `id` bigint NOT NULL AUTO_INCREMENT,\n" +
  3365. " `member_id` bigint NOT NULL,\n" +
  3366. " `source_type` varchar(20) NOT NULL COMMENT 'PDF/PHOTO/VOICE/MANUAL/WEIXIN_STEP',\n" +
  3367. " `dimension_codes` json NOT NULL COMMENT '影响的维度列表',\n" +
  3368. " `parsed_result` json DEFAULT NULL COMMENT '解析结果全量',\n" +
  3369. " `file_url` varchar(500) DEFAULT NULL COMMENT '原始文件URL',\n" +
  3370. " `confidence` decimal(3,2) DEFAULT NULL COMMENT 'AI解析置信度',\n" +
  3371. " `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
  3372. " PRIMARY KEY (`id`),\n" +
  3373. " KEY `idx_member_source` (`member_id`, `source_type`)\n" +
  3374. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录表'");
  3375. } catch (Exception e) {
  3376. log.warn("health_data_sources 表创建失败: {}", e.getMessage());
  3377. }
  3378. try {
  3379. if (tableExists("health_norm_reference")) {
  3380. jdbcTemplate.execute("RENAME TABLE `health_norm_reference` TO `health_norm_reference_old`");
  3381. }
  3382. } catch (Exception e) {
  3383. log.warn("health_norm_reference rename skipped: {}", e.getMessage());
  3384. }
  3385. try {
  3386. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_norm_reference` (\n" +
  3387. " `id` bigint NOT NULL AUTO_INCREMENT,\n" +
  3388. " `dimension` varchar(20) NOT NULL COMMENT '维度',\n" +
  3389. " `gender` varchar(10) NOT NULL COMMENT 'male/female/all',\n" +
  3390. " `age_min` int NOT NULL COMMENT '年龄下限(月)',\n" +
  3391. " `age_max` int NOT NULL COMMENT '年龄上限(月)',\n" +
  3392. " `percentile_5` int DEFAULT NULL,\n" +
  3393. " `percentile_15` int DEFAULT NULL,\n" +
  3394. " `percentile_25` int DEFAULT NULL,\n" +
  3395. " `percentile_50` int DEFAULT NULL COMMENT '中位值',\n" +
  3396. " `percentile_75` int DEFAULT NULL,\n" +
  3397. " `percentile_85` int DEFAULT NULL,\n" +
  3398. " `percentile_95` int DEFAULT NULL,\n" +
  3399. " `source` varchar(50) DEFAULT NULL COMMENT '常模来源',\n" +
  3400. " `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
  3401. " PRIMARY KEY (`id`),\n" +
  3402. " UNIQUE KEY `uk_dim_gender_age` (`dimension`, `gender`, `age_min`, `age_max`)\n" +
  3403. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康常模对照表'");
  3404. } catch (Exception e) {
  3405. log.warn("health_norm_reference 表重建失败: {}", e.getMessage());
  3406. }
  3407. try {
  3408. jdbcTemplate.execute("INSERT IGNORE INTO health_dimension_scores (member_id, dimension, score, data_source, tier, assess_date, created_at)\n" +
  3409. " SELECT member_id,\n" +
  3410. " CASE dimension\n" +
  3411. " WHEN 'body' THEN 'growth'\n" +
  3412. " WHEN 'fitness' THEN 'exercise'\n" +
  3413. " WHEN 'cardio' THEN 'exercise'\n" +
  3414. " WHEN 'blood' THEN 'immunity'\n" +
  3415. " WHEN 'sleep' THEN 'sleep'\n" +
  3416. " WHEN 'psyche' THEN 'growth'\n" +
  3417. " WHEN 'diet' THEN 'nutrition'\n" +
  3418. " ELSE dimension\n" +
  3419. " END,\n" +
  3420. " score,\n" +
  3421. " UPPER(data_source),\n" +
  3422. " 3,\n" +
  3423. " record_date,\n" +
  3424. " NOW()\n" +
  3425. " FROM health_dimension_score_old WHERE is_latest = 1");
  3426. } catch (Exception e) {
  3427. log.warn("旧维度评分数据迁移跳过: {}", e.getMessage());
  3428. }
  3429. }
  3430. private void seedHealthNorms() {
  3431. try {
  3432. int count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM health_norm_reference", Integer.class);
  3433. if (count == 0) {
  3434. jdbcTemplate.execute("INSERT INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES\n" +
  3435. "('growth', 'male', 72, 84, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
  3436. "('growth', 'female', 72, 84, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
  3437. "('growth', 'male', 84, 96, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
  3438. "('growth', 'female', 84, 96, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
  3439. "('growth', 'male', 96, 120, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
  3440. "('growth', 'female', 96, 120, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
  3441. "('sleep', 'all', 0, 12, 40, 50, 60, 70, 80, 88, 95, 'NSF婴幼儿睡眠→百分制'),\n" +
  3442. "('sleep', 'all', 12, 36, 40, 50, 60, 70, 80, 88, 95, 'NSF幼儿睡眠→百分制'),\n" +
  3443. "('sleep', 'all', 36, 72, 40, 50, 60, 70, 80, 88, 95, 'NSF学龄前睡眠→百分制'),\n" +
  3444. "('sleep', 'all', 72, 156, 40, 50, 60, 70, 80, 88, 95, 'NSF学龄儿童睡眠→百分制'),\n" +
  3445. "('vision', 'male', 72, 144, 45, 55, 65, 75, 85, 90, 95, '视力发育标准→百分制'),\n" +
  3446. "('vision', 'female', 72, 144, 45, 55, 65, 75, 85, 90, 95, '视力发育标准→百分制'),\n" +
  3447. "('immunity', 'all', 0, 72, 15, 30, 45, 65, 80, 90, 95, '年患病次数→百分制'),\n" +
  3448. "('immunity', 'all', 72, 192, 15, 35, 50, 70, 85, 92, 98, '年患病次数→百分制'),\n" +
  3449. "('nutrition', 'all', 36, 192, 20, 30, 40, 50, 60, 70, 80, '膳食多样性→百分制'),\n" +
  3450. "('gut', 'all', 0, 192, 40, 50, 60, 70, 80, 85, 95, '菌群人群分布(平台自建)'),\n" +
  3451. "('exercise', 'male', 72, 144, 30, 40, 50, 60, 70, 80, 90, '国家学生体质健康标准'),\n" +
  3452. "('exercise', 'female', 72, 144, 25, 35, 45, 55, 65, 75, 85, '国家学生体质健康标准')");
  3453. log.info("health_norm_reference 常模种子数据已加载 (18条, 统一0-100百分制)");
  3454. } else {
  3455. log.info("health_norm_reference 常模数据已存在 ({}条), 跳过", count);
  3456. }
  3457. } catch (Exception e) {
  3458. log.warn("health_norm_reference 常模种子数据初始化失败: {}", e.getMessage());
  3459. }
  3460. // 迁移: articles表添加审核和作者类型字段
  3461. try {
  3462. Integer exists = jdbcTemplate.queryForObject(
  3463. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'audit_status'",
  3464. Integer.class);
  3465. if (exists == null || exists == 0) {
  3466. jdbcTemplate.execute("ALTER TABLE articles " +
  3467. "ADD COLUMN author_type VARCHAR(20) DEFAULT 'admin' COMMENT '作者类型:admin/parent/child' AFTER created_by, " +
  3468. "ADD COLUMN audit_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态:approved/pending/rejected' AFTER status, " +
  3469. "ADD COLUMN audit_reason TEXT COMMENT '驳回原因' AFTER audit_status, " +
  3470. "ADD COLUMN auditor_id BIGINT DEFAULT 0 COMMENT '审核人 ID' AFTER audit_reason, " +
  3471. "ADD COLUMN audited_at DATETIME COMMENT '审核时间' AFTER auditor_id");
  3472. log.info("已添加审核和作者类型字段到articles表");
  3473. } else {
  3474. log.info("articles表审核字段已存在,跳过");
  3475. }
  3476. } catch (Exception e) {
  3477. log.warn("检查/添加articles表审核字段失败: {}", e.getMessage());
  3478. }
  3479. // 迁移20240709-01: content字段扩为TEXT - 用户输入内容可能超VARCHAR上限
  3480. // activities.description
  3481. modifyColumnToText("activities", "description", "活动描述");
  3482. // tasks.description
  3483. modifyColumnToText("tasks", "description", "任务描述");
  3484. // wishes.description, reject_reason, cancel_reason
  3485. modifyColumnToText("wishes", "description", "心愿描述");
  3486. modifyColumnToText("wishes", "reject_reason", "拒绝原因");
  3487. modifyColumnToText("wishes", "cancel_reason", "取消原因");
  3488. // reward_wishlists.description
  3489. modifyColumnToText("reward_wishlists", "description", "心愿描述");
  3490. // products.description, reject_reason
  3491. modifyColumnToText("products", "description", "商品描述");
  3492. modifyColumnToText("products", "reject_reason", "审核拒绝原因");
  3493. // product_orders.remark
  3494. modifyColumnToText("product_orders", "remark", "备注");
  3495. // articles.summary
  3496. modifyColumnToText("articles", "summary", "摘要");
  3497. // activities.audit_reason
  3498. modifyColumnToText("activities", "audit_reason", "驳回原因");
  3499. // guide_applications.reject_reason
  3500. modifyColumnToText("guide_applications", "reject_reason", "拒绝原因");
  3501. // guide_application_records.remark
  3502. modifyColumnToText("guide_application_records", "remark", "备注/原因");
  3503. // user_operation_log.description
  3504. modifyColumnToText("user_operation_log", "description", "操作描述");
  3505. // follow_up.description, teacher_review
  3506. modifyColumnToText("follow_up", "description", "描述");
  3507. modifyColumnToText("follow_up", "teacher_review", "规划师点评");
  3508. // energy_log.description
  3509. modifyColumnToText("energy_log", "description", "描述");
  3510. // sys_config.description
  3511. modifyColumnToText("sys_config", "description", "配置说明");
  3512. // points_log.description
  3513. modifyColumnToText("points_log", "description", "描述");
  3514. // insurance_policies.remark
  3515. modifyColumnToText("insurance_policies", "remark", "备注");
  3516. // health_reports.remark
  3517. modifyColumnToText("health_reports", "remark", "备注");
  3518. // guide_service_logs.notes
  3519. modifyColumnToText("guide_service_logs", "notes", "服务记录");
  3520. // guide_packages.description
  3521. modifyColumnToText("guide_packages", "description", "套餐说明");
  3522. // guide_package_templates.description
  3523. modifyColumnToText("guide_package_templates", "description", "说明");
  3524. // service_types.description
  3525. modifyColumnToText("service_types", "description", "描述");
  3526. // commission_records.remark
  3527. modifyColumnToText("commission_records", "remark", "备注");
  3528. // zodiac_pairing.description, advice
  3529. modifyColumnToText("zodiac_pairing", "description", "配对解读");
  3530. modifyColumnToText("zodiac_pairing", "advice", "相处建议");
  3531. // blood_type_pairing.description, advice
  3532. modifyColumnToText("blood_type_pairing", "description", "配对描述");
  3533. modifyColumnToText("blood_type_pairing", "advice", "相处建议");
  3534. // life_number_relationship.description, advice
  3535. modifyColumnToText("life_number_relationship", "description", "关系解读");
  3536. modifyColumnToText("life_number_relationship", "advice", "相处建议");
  3537. // daily_health_checkin.remark
  3538. modifyColumnToText("daily_health_checkin", "remark", "备注");
  3539. // health_sleep_record.remark
  3540. modifyColumnToText("health_sleep_record", "remark", "备注");
  3541. // health_exercise_record.remark
  3542. modifyColumnToText("health_exercise_record", "remark", "备注");
  3543. // finance_checkins.note
  3544. modifyColumnToText("finance_checkins", "note", "备注");
  3545. // guide_packages.description (old duplicate)
  3546. // assessment_appointments.remark
  3547. modifyColumnToText("assessment_appointments", "remark", "备注");
  3548. // withdrawal_requests.remark
  3549. modifyColumnToText("withdrawal_requests", "remark", "备注");
  3550. // supply_settlements.remark
  3551. modifyColumnToText("supply_settlements", "remark", "备注");
  3552. // content_sections.description
  3553. ensureColumn("content_sections", "description", "TEXT COMMENT '描述说明'");
  3554. // education_systems.description
  3555. modifyColumnToText("education_systems", "description", "描述");
  3556. // dimension_config.description
  3557. modifyColumnToText("dimension_config", "description", "描述");
  3558. // energy_rules.description
  3559. modifyColumnToText("energy_rules", "description", "规则描述");
  3560. // team_goals.description
  3561. modifyColumnToText("team_goals", "description", "团队描述");
  3562. // product_dimension_config.remark
  3563. modifyColumnToText("product_dimension_config", "remark", "备注");
  3564. // indicator_values.remark
  3565. modifyColumnToText("indicator_values", "remark", "备注");
  3566. // tongue_records.overall_assessment
  3567. modifyColumnToText("tongue_records", "overall_assessment", "总体评估");
  3568. // system_config.description, config_value
  3569. modifyColumnToText("system_config", "description", "说明");
  3570. modifyColumnToText("system_config", "config_value", "配置值");
  3571. // supply_system.description
  3572. modifyColumnToText("supply_system", "description", "描述");
  3573. // supply_settlement.remark
  3574. modifyColumnToText("supply_settlement", "remark", "备注");
  3575. // growth_task.description
  3576. modifyColumnToText("growth_task", "description", "描述");
  3577. // distribution_systems.description
  3578. modifyColumnToText("distribution_systems", "description", "团队描述");
  3579. // mini_games.description
  3580. modifyColumnToText("mini_games", "description", "游戏描述");
  3581. // blood_type_config.description
  3582. modifyColumnToText("blood_type_config", "description", "性格描述");
  3583. // 迁移20240709-02: 北京菌群报告采集增强(P0 疾病关联菌 / P1 菌群状态)
  3584. // health_disease_risk.related_bacteria: 疾病关联菌(异常菌)明细 JSON
  3585. ensureColumn("health_disease_risk", "related_bacteria", "TEXT NULL COMMENT '关联菌明细JSON:[{bacteria,correlation,value,status}]'");
  3586. // health_reports.gut_status_level: 肠道菌群状态(0度~4度)
  3587. ensureColumn("health_reports", "gut_status_level", "VARCHAR(20) NULL COMMENT '肠道菌群状态(0度~4度)'");
  3588. // 迁移248: 任务系统升级(行为调度中心)——tasks 新增字段
  3589. ensureColumn("tasks", "prerequisite_task_id", "BIGINT NULL COMMENT '前置任务ID:完成后才解锁本任务'");
  3590. ensureColumn("tasks", "action_type", "VARCHAR(32) NULL COMMENT '联动动作: minigame/focus/survey/article/activity/cart/order'");
  3591. ensureColumn("tasks", "action_config", "JSON NULL COMMENT '联动参数: {\"gameCode\":\"schulte\",\"productId\":123,\"minSeconds\":30,\"completeMilestone\":\"paid\"/\"received\",\"surveyId\":99}'");
  3592. ensureColumn("tasks", "require_input", "TINYINT DEFAULT 0 COMMENT '1=需填写内容才能完成'");
  3593. ensureColumn("tasks", "start_required", "TINYINT DEFAULT 0 COMMENT '1=需先开始再完成(两阶段)'");
  3594. ensureColumn("tasks", "start_count", "INT DEFAULT 0 COMMENT '已开始次数(累计)'");
  3595. ensureColumn("tasks", "min_duration_seconds", "INT DEFAULT 0 COMMENT '最短持续秒数(专注/游戏类,未达标不计完成)'");
  3596. // 迁移248: 任务执行进度表(两阶段/里程碑/填写内容记录)
  3597. try {
  3598. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS task_executions (" +
  3599. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  3600. "task_id BIGINT NOT NULL COMMENT '任务实例ID', " +
  3601. "member_id BIGINT NOT NULL COMMENT '执行成员ID(family_members.id)', " +
  3602. "status VARCHAR(20) NOT NULL DEFAULT 'started' COMMENT 'started/finished/failed', " +
  3603. "started_at DATETIME NOT NULL COMMENT '开始时间', " +
  3604. "finished_at DATETIME COMMENT '完成时间', " +
  3605. "milestone VARCHAR(32) COMMENT '购物任务里程碑: cart_added/paid/received(当前进度)', " +
  3606. "content TEXT COMMENT '填写内容(require_input 任务)', " +
  3607. "content_type VARCHAR(20) COMMENT 'content 类型: text/image/audio/video', " +
  3608. "extra JSON COMMENT '扩展数据: {gameScore, focusMinutes, orderId, articleId, surveyId}', " +
  3609. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  3610. "INDEX idx_task_member (task_id, member_id), " +
  3611. "INDEX idx_status (status)" +
  3612. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务执行进度表'");
  3613. log.info("已创建task_executions表");
  3614. } catch (Exception e) {
  3615. log.warn("创建task_executions表失败: {}", e.getMessage());
  3616. }
  3617. // 迁移248: focus_sessions 添加 task_id 字段(专注任务挂钩)
  3618. ensureColumn("focus_sessions", "task_id", "BIGINT NULL COMMENT '关联的任务ID(专注任务挂钩)'");
  3619. }
  3620. private void seedMindActionWealthNorms() {
  3621. try {
  3622. Integer count = jdbcTemplate.queryForObject(
  3623. "SELECT COUNT(*) FROM health_norm_reference WHERE dimension LIKE 'bigFive%' OR dimension LIKE 'emotion%' OR dimension LIKE 'drive%' OR dimension LIKE 'selfConcept%' OR dimension LIKE 'action%' OR dimension LIKE 'wealth%'",
  3624. Integer.class);
  3625. if (count != null && count > 0) {
  3626. log.info("mind/action/wealth 常模数据已存在 ({}条), 跳过", count);
  3627. return;
  3628. }
  3629. java.util.List<String> sqls = new java.util.ArrayList<>();
  3630. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3631. "('bigFiveOpenness','all',0,84,20,27,35,45,55,63,70,'心理常模'),\n" +
  3632. "('bigFiveOpenness','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
  3633. "('bigFiveOpenness','all',144,216,30,37,45,55,65,73,80,'心理常模'),\n" +
  3634. "('bigFiveOpenness','all',216,1200,25,32,40,50,60,68,75,'心理常模')");
  3635. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3636. "('bigFiveConscientiousness','all',0,84,15,22,30,40,50,58,65,'心理常模'),\n" +
  3637. "('bigFiveConscientiousness','all',84,144,23,30,38,48,58,66,73,'心理常模'),\n" +
  3638. "('bigFiveConscientiousness','all',144,216,30,37,45,55,65,73,80,'心理常模'),\n" +
  3639. "('bigFiveConscientiousness','all',216,1200,33,40,48,58,68,76,83,'心理常模')");
  3640. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3641. "('bigFiveExtraversion','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" +
  3642. "('bigFiveExtraversion','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
  3643. "('bigFiveExtraversion','all',144,216,25,32,40,50,60,68,75,'心理常模'),\n" +
  3644. "('bigFiveExtraversion','all',216,1200,25,32,40,50,60,68,75,'心理常模')");
  3645. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3646. "('bigFiveAgreeableness','all',0,84,33,40,48,58,68,76,83,'心理常模'),\n" +
  3647. "('bigFiveAgreeableness','all',84,144,30,37,45,55,65,73,80,'心理常模'),\n" +
  3648. "('bigFiveAgreeableness','all',144,216,28,35,43,53,63,71,78,'心理常模'),\n" +
  3649. "('bigFiveAgreeableness','all',216,1200,30,37,45,55,65,73,80,'心理常模')");
  3650. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3651. "('bigFiveNeuroticism','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" +
  3652. "('bigFiveNeuroticism','all',84,144,23,30,38,48,58,66,73,'心理常模'),\n" +
  3653. "('bigFiveNeuroticism','all',144,216,20,27,35,45,55,63,70,'心理常模'),\n" +
  3654. "('bigFiveNeuroticism','all',216,1200,30,37,45,55,65,73,80,'心理常模')");
  3655. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3656. "('emotionStability','all',0,84,17,24,32,42,52,60,67,'心理常模'),\n" +
  3657. "('emotionStability','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
  3658. "('emotionStability','all',144,216,33,40,48,58,68,76,83,'心理常模'),\n" +
  3659. "('emotionStability','all',216,1200,37,44,52,62,72,80,87,'心理常模')");
  3660. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3661. "('driveAutonomy','all',0,84,10,17,25,35,45,53,60,'心理常模'),\n" +
  3662. "('driveAutonomy','all',84,144,25,32,40,50,60,68,75,'心理常模'),\n" +
  3663. "('driveAutonomy','all',144,216,37,44,52,62,72,80,87,'心理常模'),\n" +
  3664. "('driveAutonomy','all',216,1200,43,50,58,68,78,86,93,'心理常模')");
  3665. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3666. "('driveCompetence','all',0,84,15,22,30,40,50,58,65,'心理常模'),\n" +
  3667. "('driveCompetence','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
  3668. "('driveCompetence','all',144,216,35,42,50,60,70,78,85,'心理常模'),\n" +
  3669. "('driveCompetence','all',216,1200,37,44,52,62,72,80,87,'心理常模')");
  3670. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3671. "('driveRelatedness','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" +
  3672. "('driveRelatedness','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
  3673. "('driveRelatedness','all',144,216,25,32,40,50,60,68,75,'心理常模'),\n" +
  3674. "('driveRelatedness','all',216,1200,30,37,45,55,65,73,80,'心理常模')");
  3675. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3676. "('selfConceptOverall','all',0,84,20,27,35,45,55,63,70,'心理常模'),\n" +
  3677. "('selfConceptOverall','all',84,144,30,37,45,55,65,73,80,'心理常模'),\n" +
  3678. "('selfConceptOverall','all',144,216,33,40,48,58,68,76,83,'心理常模'),\n" +
  3679. "('selfConceptOverall','all',216,1200,33,40,48,58,68,76,83,'心理常模')");
  3680. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3681. "('actionChildParentChild','all',0,84,35,42,50,60,70,78,85,'关系质量常模'),\n" +
  3682. "('actionChildParentChild','all',84,144,33,40,48,58,68,76,83,'关系质量常模'),\n" +
  3683. "('actionChildParentChild','all',144,216,30,37,45,55,65,73,80,'关系质量常模')");
  3684. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3685. "('actionSibling','all',0,84,20,27,35,45,55,63,70,'关系质量常模'),\n" +
  3686. "('actionSibling','all',84,144,25,32,40,50,60,68,75,'关系质量常模'),\n" +
  3687. "('actionSibling','all',144,216,30,37,45,55,65,73,80,'关系质量常模')");
  3688. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3689. "('actionRelative','all',0,84,25,32,40,50,60,68,75,'关系质量常模'),\n" +
  3690. "('actionRelative','all',84,144,30,37,45,55,65,73,80,'关系质量常模'),\n" +
  3691. "('actionRelative','all',144,216,33,40,48,58,68,76,83,'关系质量常模')");
  3692. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3693. "('actionTeacherStudent','all',0,84,25,32,40,50,60,68,75,'关系质量常模'),\n" +
  3694. "('actionTeacherStudent','all',84,144,35,42,50,60,70,78,85,'关系质量常模'),\n" +
  3695. "('actionTeacherStudent','all',144,216,37,44,52,62,72,80,87,'关系质量常模')");
  3696. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3697. "('actionChildSociality','all',0,84,23,30,38,48,58,66,73,'关系质量常模'),\n" +
  3698. "('actionChildSociality','all',84,144,30,37,45,55,65,73,80,'关系质量常模'),\n" +
  3699. "('actionChildSociality','all',144,216,35,42,50,60,70,78,85,'关系质量常模')");
  3700. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3701. "('actionChildInterpersonal','all',0,84,20,27,35,45,55,63,70,'关系质量常模'),\n" +
  3702. "('actionChildInterpersonal','all',84,144,27,34,42,52,62,70,77,'关系质量常模'),\n" +
  3703. "('actionChildInterpersonal','all',144,216,33,40,48,58,68,76,83,'关系质量常模')");
  3704. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3705. "('actionParentChild','all',216,1200,33,40,48,58,68,76,83,'关系质量常模')");
  3706. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3707. "('actionMarital','all',216,1200,30,37,45,55,65,73,80,'关系质量常模')");
  3708. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3709. "('actionParental','all',216,1200,27,34,42,52,62,70,77,'关系质量常模')");
  3710. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3711. "('actionLeadership','all',216,1200,30,37,45,55,65,73,80,'关系质量常模')");
  3712. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3713. "('actionSociality','all',216,1200,35,42,50,60,70,78,85,'关系质量常模')");
  3714. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3715. "('actionInterpersonal','all',216,1200,33,40,48,58,68,76,83,'关系质量常模')");
  3716. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3717. "('wealthSkill','all',0,84,10,17,25,35,45,53,60,'财富素养常模'),\n" +
  3718. "('wealthSkill','all',84,144,20,27,35,45,55,63,70,'财富素养常模'),\n" +
  3719. "('wealthSkill','all',144,216,30,37,45,55,65,73,80,'财富素养常模'),\n" +
  3720. "('wealthSkill','all',216,1200,35,42,50,60,70,78,85,'财富素养常模')");
  3721. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3722. "('wealthAltruism','all',0,84,5,12,20,30,40,48,55,'财富素养常模'),\n" +
  3723. "('wealthAltruism','all',84,144,15,22,30,40,50,58,65,'财富素养常模'),\n" +
  3724. "('wealthAltruism','all',144,216,25,32,40,50,60,68,75,'财富素养常模'),\n" +
  3725. "('wealthAltruism','all',216,1200,30,37,45,55,65,73,80,'财富素养常模')");
  3726. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3727. "('wealthIncome','all',0,84,0,7,15,25,35,43,50,'财富素养常模'),\n" +
  3728. "('wealthIncome','all',84,144,10,17,25,35,45,53,60,'财富素养常模'),\n" +
  3729. "('wealthIncome','all',144,216,20,27,35,45,55,63,70,'财富素养常模'),\n" +
  3730. "('wealthIncome','all',216,1200,30,37,45,55,65,73,80,'财富素养常模')");
  3731. sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
  3732. "('wealthFx','all',0,84,0,2,10,20,30,38,45,'财富素养常模'),\n" +
  3733. "('wealthFx','all',84,144,5,12,20,30,40,48,55,'财富素养常模'),\n" +
  3734. "('wealthFx','all',144,216,15,22,30,40,50,58,65,'财富素养常模'),\n" +
  3735. "('wealthFx','all',216,1200,25,32,40,50,60,68,75,'财富素养常模')");
  3736. for (String sql : sqls) {
  3737. jdbcTemplate.execute(sql);
  3738. }
  3739. log.info("mind/action/wealth 常模种子数据已加载 ({}条)", sqls.size());
  3740. } catch (Exception e) {
  3741. log.warn("mind/action/wealth 常模种子数据初始化失败: {}", e.getMessage());
  3742. }
  3743. }
  3744. private void modifyColumnToText(String table, String column, String comment) {
  3745. if (!tableExists(table)) {
  3746. return;
  3747. }
  3748. try {
  3749. jdbcTemplate.execute("ALTER TABLE " + table + " MODIFY COLUMN " + column + " TEXT COMMENT '" + comment + "'");
  3750. log.info("已修改{}.{}为TEXT类型", table, column);
  3751. } catch (Exception e) {
  3752. log.warn("修改{}.{}为TEXT失败: {}", table, column, e.getMessage());
  3753. }
  3754. }
  3755. private void initializeDefaultData() {
  3756. // 初始化预设奖励模板
  3757. try {
  3758. // 检查是否已有预设奖励
  3759. Integer count = jdbcTemplate.queryForObject(
  3760. "SELECT COUNT(*) FROM rewards WHERE is_template = 1", Integer.class);
  3761. if (count == null || count == 0) {
  3762. // 插入预设奖励模板(child_id为NULL表示模板)
  3763. jdbcTemplate.execute("INSERT INTO rewards (family_id, child_id, title, points_required, category, is_template) VALUES " +
  3764. "(0, NULL, \'看动画片30分钟\', 30, \'精神类\', 1), " +
  3765. "(0, NULL, \'玩平板游戏30分钟\', 40, \'特权类\', 1), " +
  3766. "(0, NULL, \'去游乐场\', 100, \'物质类\', 1), " +
  3767. "(0, NULL, \'买玩具\', 150, \'物质类\', 1), " +
  3768. "(0, NULL, \'吃冰淇淋\', 20, \'物质类\', 1), " +
  3769. "(0, NULL, \'选择晚餐\', 25, \'特权类\', 1)");
  3770. log.info("预设奖励模板已初始化");
  3771. }
  3772. } catch (Exception e) {
  3773. log.warn("初始化预设奖励失败: {}", e.getMessage());
  3774. }
  3775. // 初始化奖励分类
  3776. try {
  3777. Integer count = jdbcTemplate.queryForObject(
  3778. "SELECT COUNT(*) FROM reward_categories WHERE family_id = 0", Integer.class);
  3779. if (count == null || count == 0) {
  3780. jdbcTemplate.execute("INSERT INTO reward_categories (family_id, name, icon, sort_order) VALUES " +
  3781. "(0, '物质类', 'gift', 1), " +
  3782. "(0, '精神类', 'star', 2), " +
  3783. "(0, '特权类', 'crown', 3)");
  3784. log.info("奖励分类已初始化");
  3785. }
  3786. } catch (Exception e) {
  3787. log.warn("初始化奖励分类失败: {}", e.getMessage());
  3788. }
  3789. // 初始化打卡里程碑
  3790. try {
  3791. Integer count = jdbcTemplate.queryForObject(
  3792. "SELECT COUNT(*) FROM streak_milestones", Integer.class);
  3793. if (count == null || count == 0) {
  3794. jdbcTemplate.execute("INSERT INTO streak_milestones (days, reward_points) VALUES " +
  3795. "(3, 5), (7, 10), (30, 30), (100, 100)");
  3796. log.info("打卡里程碑已初始化");
  3797. }
  3798. } catch (Exception e) {
  3799. log.warn("初始化打卡里程碑失败: {}", e.getMessage());
  3800. }
  3801. // 初始化勋章定义
  3802. try {
  3803. Integer count = jdbcTemplate.queryForObject(
  3804. "SELECT COUNT(*) FROM badge_definitions", Integer.class);
  3805. if (count == null || count == 0) {
  3806. jdbcTemplate.execute("INSERT INTO badge_definitions (badge_type, name, description, icon, condition_type, condition_value, reward_points) VALUES " +
  3807. "('first_task', '初次完成任务', '首次完成任意任务', 'star', 'task_count', 1, 0), " +
  3808. "('streak_3', '连续3天', '连续3天按时完成任务', 'fire', 'streak_days', 3, 5), " +
  3809. "('streak_7', '连续7天', '连续7天按时完成任务', 'fire', 'streak_days', 7, 10), " +
  3810. "('points_50', '积分达人', '一周获得50积分', 'trophy', 'weekly_points', 50, 0), " +
  3811. "('task_100', '任务达人', '完成100个任务', 'medal', 'task_count', 100, 20)");
  3812. log.info("勋章定义已初始化");
  3813. }
  3814. } catch (Exception e) {
  3815. log.warn("初始化勋章定义失败: {}", e.getMessage());
  3816. }
  3817. // 初始化小游戏数据
  3818. try {
  3819. Integer count = jdbcTemplate.queryForObject(
  3820. "SELECT COUNT(*) FROM mini_games", Integer.class);
  3821. if (count == null || count == 0) {
  3822. jdbcTemplate.execute("INSERT INTO mini_games (game_code, game_name, description, icon, default_duration, default_points, game_url, sort_order, status) VALUES " +
  3823. "('schulte', '舒尔特方格', '训练专注力和眼动能力,找到1-25按顺序点击', '🎯', 5, 5, '/pages/games/schulte', 1, 1), " +
  3824. "('1a2b', '猜数字', '猜对数字和位置获得积分,锻炼逻辑思维', '🔢', 10, 5, '/pages/games/1a2b', 2, 1), " +
  3825. "('sudoku', '数独', '填充数字使每行每列每个宫格都包含1-9', '🔳', 15, 8, '/pages/games/sudoku', 3, 1)");
  3826. log.info("小游戏数据已初始化");
  3827. }
  3828. } catch (Exception e) {
  3829. log.warn("初始化小游戏数据失败: {}", e.getMessage());
  3830. }
  3831. // 初始化默认管理员
  3832. try {
  3833. // 先检查并添加role列(如果不存在)
  3834. try {
  3835. jdbcTemplate.execute("ALTER TABLE admins ADD COLUMN role VARCHAR(20) DEFAULT \'admin\' AFTER email");
  3836. log.info("已添加role列到admins表");
  3837. } catch (Exception e) {
  3838. // 列已存在,忽略错误
  3839. }
  3840. Integer count = jdbcTemplate.queryForObject(
  3841. "SELECT COUNT(*) FROM admins", Integer.class);
  3842. if (count == null || count == 0) {
  3843. // 默认管理员: 手机号 13800138000, 密码 admin123
  3844. String encryptedPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_admin_salt_admin123".getBytes());
  3845. jdbcTemplate.execute("INSERT INTO admins (username, password, real_name, phone, email, role, status) VALUES " +
  3846. "(\'admin\', \'" + encryptedPassword + "\', \'系统管理员\', \'13800138000\', \'admin@xzyj.com\', \'admin\', 1)");
  3847. log.info("默认管理员已初始化: 手机号 13800138000, 密码 admin123");
  3848. }
  3849. } catch (Exception e) {
  3850. log.warn("初始化默认管理员失败: {}", e.getMessage());
  3851. }
  3852. // 初始化成长规划师WEB管理账号
  3853. try {
  3854. Integer teacherCount = jdbcTemplate.queryForObject(
  3855. "SELECT COUNT(*) FROM admins WHERE username = 'teacher'", Integer.class);
  3856. if (teacherCount == null || teacherCount == 0) {
  3857. String teacherPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_admin_salt_teacher123".getBytes());
  3858. jdbcTemplate.execute("INSERT INTO admins (username, password, real_name, phone, role, status) VALUES " +
  3859. "('teacher', '" + teacherPassword + "', '成长规划师', '13800138003', 'teacher', 1)");
  3860. log.info("成长规划师管理账号已初始化: 用户名 teacher, 密码 teacher123");
  3861. }
  3862. } catch (Exception e) {
  3863. log.warn("初始化成长规划师管理账号失败: {}", e.getMessage());
  3864. }
  3865. // 初始化任务模板示例数据
  3866. try {
  3867. Integer count = jdbcTemplate.queryForObject(
  3868. "SELECT COUNT(*) FROM admin_task_templates", Integer.class);
  3869. if (count == null || count == 0) {
  3870. jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, recommended_age, difficulty, is_active) VALUES " +
  3871. "('完成作业', '按时完成学校布置的家庭作业', 10, '学习', 1, 6, 'medium', 1), " +
  3872. "('阅读30分钟', '自主阅读课外书籍30分钟', 8, '阅读', 0, 5, 'easy', 1), " +
  3873. "('整理房间', '收拾整理自己的房间和书桌', 6, '家务', 0, 6, 'easy', 1), " +
  3874. "('洗碗', '饭后帮忙清洗餐具', 5, '家务', 1, 8, 'medium', 1), " +
  3875. "('户外运动1小时', '进行跑步、球类等户外运动', 12, '运动', 0, 7, 'hard', 1), " +
  3876. "('练习书法', '练习毛笔或硬笔书法20分钟', 7, '学习', 0, 7, 'medium', 1), " +
  3877. "('早睡早起', '晚上9点前睡觉,早上7点前起床', 5, '生活习惯', 0, 5, 'easy', 1), " +
  3878. "('背诵古诗', '背诵一首古诗词', 8, '学习', 1, 6, 'medium', 1)");
  3879. log.info("任务模板示例数据已初始化");
  3880. }
  3881. } catch (Exception e) {
  3882. log.warn("初始化任务模板失败: {}", e.getMessage());
  3883. }
  3884. // 初始化街道数据示例(广东省广州市)
  3885. try {
  3886. Integer count = jdbcTemplate.queryForObject(
  3887. "SELECT COUNT(*) FROM streets", Integer.class);
  3888. if (count == null || count == 0) {
  3889. // 省
  3890. jdbcTemplate.execute("INSERT INTO streets (province, province_code, full_name, level, parent_id, sort_order) VALUES " +
  3891. "('广东省', '44', '广东省', 1, NULL, 1)");
  3892. // 市
  3893. jdbcTemplate.execute("INSERT INTO streets (province, province_code, city, city_code, full_name, level, parent_id, sort_order) VALUES " +
  3894. "('广东省', '44', '广州市', '4401', '广东省广州市', 2, 1, 1), " +
  3895. "('广东省', '44', '深圳市', '4403', '广东省深圳市', 2, 1, 2), " +
  3896. "('广东省', '44', '珠海市', '4404', '广东省珠海市', 2, 1, 3)");
  3897. // 区
  3898. jdbcTemplate.execute("INSERT INTO streets (province, province_code, city, city_code, district, district_code, full_name, level, parent_id, sort_order) VALUES " +
  3899. "('广东省', '44', '广州市', '4401', '天河区', '440106', '广东省广州市天河区', 3, 2, 1), " +
  3900. "('广东省', '44', '广州市', '4401', '越秀区', '440104', '广东省广州市越秀区', 3, 2, 2), " +
  3901. "('广东省', '44', '广州市', '4401', '番禺区', '440113', '广东省广州市番禺区', 3, 2, 3), " +
  3902. "('广东省', '44', '深圳市', '4403', '南山区', '440305', '广东省深圳市南山区', 3, 3, 1), " +
  3903. "('广东省', '44', '深圳市', '4403', '福田区', '440304', '广东省深圳市福田区', 3, 3, 2)");
  3904. // 街道
  3905. jdbcTemplate.execute("INSERT INTO streets (province, province_code, city, city_code, district, district_code, street, street_code, full_name, level, parent_id, sort_order) VALUES " +
  3906. "('广东省', '44', '广州市', '4401', '天河区', '440106', '天河南街道', '440106001', '广东省广州市天河区天河南街道', 4, 4, 1), " +
  3907. "('广东省', '44', '广州市', '4401', '天河区', '440106', '沙河街道', '440106002', '广东省广州市天河区沙河街道', 4, 4, 2), " +
  3908. "('广东省', '44', '广州市', '4401', '越秀区', '440104', '东山街道', '440104001', '广东省广州市越秀区东山街道', 4, 5, 1), " +
  3909. "('广东省', '44', '广州市', '4401', '番禺区', '440113', '市桥街道', '440113001', '广东省广州市番禺区市桥街道', 4, 6, 1), " +
  3910. "('广东省', '44', '深圳市', '4403', '南山区', '440305', '南山街道', '440305001', '广东省深圳市南山区南山街道', 4, 7, 1), " +
  3911. "('广东省', '44', '深圳市', '4403', '福田区', '440304', '福田街道', '440304001', '广东省深圳市福田区福田街道', 4, 8, 1)");
  3912. log.info("街道示例数据已初始化");
  3913. }
  3914. } catch (Exception e) {
  3915. log.warn("初始化街道数据失败: {}", e.getMessage());
  3916. }
  3917. try {
  3918. Integer count = jdbcTemplate.queryForObject(
  3919. "SELECT COUNT(*) FROM admins", Integer.class);
  3920. if (count == null || count == 0) {
  3921. // 默认管理员: 手机号 13800138000, 密码 admin123
  3922. String encryptedPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_admin_salt_admin123".getBytes());
  3923. jdbcTemplate.execute("INSERT INTO admins (username, password, real_name, phone, email, role, status) VALUES " +
  3924. "('admin', '" + encryptedPassword + "', '系统管理员', '13800138000', 'admin@xzyj.com', 'admin', 1)");
  3925. log.info("默认管理员已初始化: 手机号 13800138000, 密码 admin123");
  3926. }
  3927. } catch (Exception e) {
  3928. log.warn("初始化任务模板失败: {}", e.getMessage());
  3929. }
  3930. // 初始化 users 表测试用户
  3931. try {
  3932. // 创建默认家庭(如果不存在)
  3933. Integer familyCount = jdbcTemplate.queryForObject(
  3934. "SELECT COUNT(*) FROM families WHERE id = 1", Integer.class);
  3935. if (familyCount == null || familyCount == 0) {
  3936. jdbcTemplate.execute("INSERT INTO families (id, name, invite_code) VALUES (1, '默认家庭', '888888')");
  3937. log.info("默认家庭已创建");
  3938. }
  3939. // 密码加密: MD5("xzyj_salt_" + password)
  3940. String adminPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_salt_admin123".getBytes());
  3941. String teacherPassword = org.springframework.util.DigestUtils.md5DigestAsHex("xzyj_salt_teacher123".getBytes());
  3942. // 创建管理员用户(管理员角色,密码 admin123)
  3943. Integer adminUserCount = jdbcTemplate.queryForObject(
  3944. "SELECT COUNT(*) FROM users WHERE phone = '13800138000'", Integer.class);
  3945. if (adminUserCount == null || adminUserCount == 0) {
  3946. jdbcTemplate.execute("INSERT INTO users (openid, family_id, role, phone, nickname, real_name, password) VALUES " +
  3947. "('seed_admin_13800138000', 1, 'admin', '13800138000', '系统管理员', '系统管理员', '" + adminPassword + "')");
  3948. log.info("测试管理员用户已创建: 13800138000 (admin角色, 密码 admin123)");
  3949. }
  3950. // 创建测试教师用户
  3951. Integer teacherUserCount = jdbcTemplate.queryForObject(
  3952. "SELECT COUNT(*) FROM users WHERE phone = '13800138001'", Integer.class);
  3953. if (teacherUserCount == null || teacherUserCount == 0) {
  3954. jdbcTemplate.execute("INSERT INTO users (openid, family_id, role, phone, nickname, real_name, password) VALUES " +
  3955. "('seed_teacher_13800138001', 1, 'teacher', '13800138001', '测试教师', '测试教师', '" + teacherPassword + "')");
  3956. log.info("测试教师用户已创建: 13800138001 (教师角色, 密码 teacher123)");
  3957. }
  3958. } catch (Exception e) {
  3959. log.warn("初始化测试用户失败: {}", e.getMessage());
  3960. }
  3961. // 初始化星座配置种子数据
  3962. try {
  3963. Integer count = jdbcTemplate.queryForObject(
  3964. "SELECT COUNT(*) FROM zodiac_config", Integer.class);
  3965. if (count == null || count == 0) {
  3966. jdbcTemplate.execute("INSERT INTO zodiac_config (zodiac_code, zodiac_name, element, start_month, start_day, end_month, end_day, mind_base, wisdom_base, body_modifier, action_modifier, wealth_modifier) VALUES " +
  3967. "('aries', '白羊座', '火', 3, 21, 4, 19, 65, 70, 1.10, 1.05, 0.95), " +
  3968. "('taurus', '金牛座', '土', 4, 20, 5, 20, 60, 65, 1.05, 1.10, 1.15), " +
  3969. "('gemini', '双子座', '金', 5, 21, 6, 21, 70, 80, 0.95, 1.15, 0.90), " +
  3970. "('cancer', '巨蟹座', '水', 6, 22, 7, 22, 75, 60, 1.00, 0.90, 1.05), " +
  3971. "('leo', '狮子座', '火', 7, 23, 8, 22, 65, 75, 1.15, 1.00, 1.10), " +
  3972. "('virgo', '处女座', '土', 8, 23, 9, 22, 55, 85, 0.95, 1.05, 1.00), " +
  3973. "('libra', '天秤座', '金', 9, 23, 10, 23, 70, 75, 0.90, 0.95, 1.10), " +
  3974. "('scorpio', '天蝎座', '水', 10, 24, 11, 22, 80, 70, 1.10, 1.00, 1.15), " +
  3975. "('sagittarius', '射手座', '火', 11, 23, 12, 21, 60, 65, 1.15, 1.15, 0.95), " +
  3976. "('capricorn', '摩羯座', '土', 12, 22, 1, 19, 55, 75, 1.05, 1.10, 1.20), " +
  3977. "('aquarius', '水瓶座', '金', 1, 20, 2, 18, 75, 85, 0.95, 0.90, 1.00), " +
  3978. "('pisces', '双鱼座', '水', 2, 19, 3, 20, 80, 60, 0.90, 0.95, 0.95)");
  3979. log.info("星座配置种子数据已初始化");
  3980. }
  3981. } catch (Exception e) {
  3982. log.warn("初始化星座配置失败: {}", e.getMessage());
  3983. }
  3984. // 初始化八字配置种子数据(十天干 + 十二地支)
  3985. try {
  3986. Integer count = jdbcTemplate.queryForObject(
  3987. "SELECT COUNT(*) FROM bazi_config", Integer.class);
  3988. if (count == null || count == 0) {
  3989. jdbcTemplate.execute("INSERT INTO bazi_config (stem_or_branch, type, element, yin_yang, mind_base, wisdom_base, body_modifier, action_modifier, wealth_modifier) VALUES " +
  3990. "('甲', 'stem', '木', 'yang', 60, 75, 1.05, 1.10, 1.00), " +
  3991. "('乙', 'stem', '木', 'yin', 65, 70, 0.95, 1.05, 1.00), " +
  3992. "('丙', 'stem', '火', 'yang', 75, 65, 1.10, 1.00, 0.95), " +
  3993. "('丁', 'stem', '火', 'yin', 70, 60, 1.05, 0.95, 1.00), " +
  3994. "('戊', 'stem', '土', 'yang', 60, 65, 1.15, 1.05, 1.10), " +
  3995. "('己', 'stem', '土', 'yin', 55, 60, 1.10, 1.00, 1.15), " +
  3996. "('庚', 'stem', '金', 'yang', 65, 80, 1.05, 1.10, 1.05), " +
  3997. "('辛', 'stem', '金', 'yin', 60, 75, 0.95, 1.00, 1.10), " +
  3998. "('壬', 'stem', '水', 'yang', 70, 70, 1.00, 0.95, 1.15), " +
  3999. "('癸', 'stem', '水', 'yin', 75, 65, 0.95, 0.90, 1.10), " +
  4000. "('子', 'branch', '水', 'yang', 70, 65, 1.00, 0.95, 1.05), " +
  4001. "('丑', 'branch', '土', 'yin', 55, 60, 1.10, 1.05, 1.10), " +
  4002. "('寅', 'branch', '木', 'yang', 65, 75, 1.15, 1.10, 1.00), " +
  4003. "('卯', 'branch', '木', 'yin', 60, 70, 1.05, 1.05, 0.95), " +
  4004. "('辰', 'branch', '土', 'yang', 60, 65, 1.10, 1.00, 1.10), " +
  4005. "('巳', 'branch', '火', 'yin', 75, 70, 1.05, 0.95, 1.00), " +
  4006. "('午', 'branch', '火', 'yang', 80, 65, 1.15, 1.00, 0.95), " +
  4007. "('未', 'branch', '土', 'yin', 55, 60, 1.05, 1.00, 1.05), " +
  4008. "('申', 'branch', '金', 'yang', 65, 80, 1.00, 1.10, 1.05), " +
  4009. "('酉', 'branch', '金', 'yin', 60, 75, 0.95, 1.05, 1.10), " +
  4010. "('戌', 'branch', '土', 'yang', 60, 65, 1.10, 1.00, 1.15), " +
  4011. "('亥', 'branch', '水', 'yin', 70, 60, 1.00, 0.95, 1.05)");
  4012. log.info("八字配置种子数据已初始化");
  4013. }
  4014. } catch (Exception e) {
  4015. log.warn("初始化八字配置失败: {}", e.getMessage());
  4016. }
  4017. // 初始化血型配置种子数据
  4018. try {
  4019. Integer count = jdbcTemplate.queryForObject(
  4020. "SELECT COUNT(*) FROM blood_type_config", Integer.class);
  4021. if (count == null || count == 0) {
  4022. jdbcTemplate.execute("INSERT INTO blood_type_config (blood_type, description, mind_base, wisdom_base, body_modifier, action_modifier, wealth_modifier) VALUES " +
  4023. "('A', '认真负责、严谨细致,但易焦虑', 70, 75, 0.95, 1.00, 1.05), " +
  4024. "('B', '乐观开朗、好奇心强,但缺乏耐心', 65, 70, 1.05, 1.10, 0.95), " +
  4025. "('AB', '理性冷静、适应力强,但优柔寡断', 75, 80, 1.00, 0.95, 1.10), " +
  4026. "('O', '自信果断、行动力强,但固执己见', 60, 65, 1.15, 1.15, 1.15)");
  4027. log.info("血型配置种子数据已初始化");
  4028. }
  4029. } catch (Exception e) {
  4030. log.warn("初始化血型配置失败: {}", e.getMessage());
  4031. }
  4032. // 初始化优惠券种子数据
  4033. try {
  4034. Integer count = jdbcTemplate.queryForObject(
  4035. "SELECT COUNT(*) FROM coupon", Integer.class);
  4036. if (count == null || count == 0) {
  4037. jdbcTemplate.update(
  4038. "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code) " +
  4039. "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, 'REGISTER', NULL)",
  4040. "新用户专享10元券", "FIXED", 1000, 0, "ALL");
  4041. jdbcTemplate.update(
  4042. "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code) " +
  4043. "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, NULL, NULL)",
  4044. "会员升级50元券", "FIXED", 5000, 9900, "MEMBERSHIP");
  4045. // JOIN 赠券模板:开通家庭会员赠 20 元券
  4046. jdbcTemplate.update(
  4047. "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code, grant_quantity) " +
  4048. "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, 'JOIN', 'FAMILY', 1)");
  4049. // JOIN 赠券模板:开通 Premium 会员赠 50 元券
  4050. jdbcTemplate.update(
  4051. "INSERT INTO coupon (name, type, value, min_spend, applicable_to, valid_from, valid_until, total_count, used_count, grant_type, grant_level_code, grant_quantity) " +
  4052. "VALUES (?, ?, ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), -1, 0, 'JOIN', 'PREMIUM', 1)");
  4053. log.info("优惠券种子数据已初始化(含 JOIN 赠券模板)");
  4054. }
  4055. // COUPON-REG:幂等修正存量库——将「新用户专享10元券」模板标记为 REGISTER 自动发放
  4056. // (存量库种子创建时 grant_type 为 NULL,改后无需 DDL,仅数据修正)
  4057. int fixed = jdbcTemplate.update(
  4058. "UPDATE coupon SET grant_type = 'REGISTER' " +
  4059. "WHERE name = '新用户专享10元券' AND (grant_type IS NULL OR grant_type = '')");
  4060. if (fixed > 0) {
  4061. log.info("已标记 {} 张存量「新用户专享10元券」模板为 REGISTER 自动发放", fixed);
  4062. }
  4063. } catch (Exception e) {
  4064. log.warn("初始化优惠券数据失败: {}", e.getMessage());
  4065. }
  4066. // 初始化成长规划师模块相关表
  4067. initGuideModuleTables(jdbcTemplate);
  4068. }
  4069. private void initGuideModuleTables(JdbcTemplate jdbcTemplate) {
  4070. // 服务类型表
  4071. try {
  4072. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_types (" +
  4073. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4074. "name VARCHAR(100) NOT NULL COMMENT '类型名称', " +
  4075. "icon VARCHAR(50) COMMENT '图标', " +
  4076. "description VARCHAR(500) COMMENT '描述', " +
  4077. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  4078. "status VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', " +
  4079. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4080. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  4081. ")");
  4082. } catch (Exception e) {
  4083. log.warn("创建service_types表失败: {}", e.getMessage());
  4084. }
  4085. // 服务内容表
  4086. try {
  4087. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_contents (" +
  4088. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4089. "type_id BIGINT NOT NULL COMMENT '关联服务类型', " +
  4090. "title VARCHAR(200) NOT NULL COMMENT '内容标题', " +
  4091. "content TEXT COMMENT '富文本内容', " +
  4092. "duration INT COMMENT '建议时长(分钟)', " +
  4093. "age_range VARCHAR(50) COMMENT '适用年龄', " +
  4094. "price INT COMMENT '参考价格(分)', " +
  4095. "status VARCHAR(20) DEFAULT 'DRAFT' COMMENT '状态:DRAFT/ACTIVE', " +
  4096. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4097. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  4098. ")");
  4099. } catch (Exception e) {
  4100. log.warn("创建service_contents表失败: {}", e.getMessage());
  4101. }
  4102. // 套餐模板表
  4103. try {
  4104. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_package_templates (" +
  4105. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4106. "name VARCHAR(100) NOT NULL COMMENT '模板名称', " +
  4107. "validity_months INT COMMENT '有效期(月)', " +
  4108. "reference_price INT COMMENT '参考价格(分)', " +
  4109. "min_price INT COMMENT '最低售价(分)', " +
  4110. "description VARCHAR(500) COMMENT '说明', " +
  4111. "status VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED', " +
  4112. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4113. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  4114. ")");
  4115. } catch (Exception e) {
  4116. log.warn("创建guide_package_templates表失败: {}", e.getMessage());
  4117. }
  4118. // 套餐模板服务项表
  4119. try {
  4120. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS package_template_services (" +
  4121. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4122. "template_id BIGINT NOT NULL COMMENT '关联模板', " +
  4123. "content_id BIGINT NOT NULL COMMENT '关联服务内容', " +
  4124. "quantity INT DEFAULT 1 COMMENT '数量', " +
  4125. "unit VARCHAR(20) COMMENT '单位' " +
  4126. ")");
  4127. } catch (Exception e) {
  4128. log.warn("创建package_template_services表失败: {}", e.getMessage());
  4129. }
  4130. // 成长规划师套餐表
  4131. try {
  4132. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_packages (" +
  4133. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4134. "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " +
  4135. "template_id BIGINT COMMENT '关联模板', " +
  4136. "name VARCHAR(100) NOT NULL COMMENT '套餐名称', " +
  4137. "price INT NOT NULL COMMENT '销售价格(分)', " +
  4138. "validity_months INT COMMENT '有效期(月)', " +
  4139. "description VARCHAR(500) COMMENT '套餐说明', " +
  4140. "status VARCHAR(20) DEFAULT 'DRAFT' COMMENT '状态:DRAFT/PUBLISHED/PAUSED', " +
  4141. "sales_count INT DEFAULT 0 COMMENT '销量', " +
  4142. "share_code VARCHAR(50) COMMENT '分享码', " +
  4143. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4144. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  4145. ")");
  4146. } catch (Exception e) {
  4147. log.warn("创建guide_packages表失败: {}", e.getMessage());
  4148. }
  4149. // 迁移:guide_packages 表添加缺失列
  4150. try {
  4151. jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN guide_id BIGINT COMMENT '成长规划师ID' AFTER id");
  4152. } catch (Exception e) {
  4153. }
  4154. try {
  4155. jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN package_id BIGINT COMMENT '关联模板' AFTER guide_id");
  4156. } catch (Exception e) {
  4157. }
  4158. try {
  4159. jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN commission_rate INT COMMENT '佣金比例(bps,100=1%)' AFTER package_id");
  4160. } catch (Exception e) {
  4161. }
  4162. try {
  4163. jdbcTemplate.execute("ALTER TABLE guide_packages MODIFY COLUMN teacher_id BIGINT NULL");
  4164. } catch (Exception e) {
  4165. }
  4166. try {
  4167. jdbcTemplate.execute("ALTER TABLE guide_packages MODIFY COLUMN template_id BIGINT NULL");
  4168. } catch (Exception e) {
  4169. }
  4170. // 迁移:guide_packages 表添加 level 列(GuidePackage 实体需要)
  4171. try {
  4172. jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN level VARCHAR(20) COMMENT '套餐等级' AFTER name");
  4173. } catch (Exception e) {
  4174. }
  4175. // 迁移:guide_packages 表添加 validity_days 列
  4176. try {
  4177. jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN validity_days INT DEFAULT 0 COMMENT '有效期天数' AFTER validity_months");
  4178. } catch (Exception e) {
  4179. }
  4180. // 迁移:guide_packages 表添加 sort_order 列
  4181. try {
  4182. jdbcTemplate.execute("ALTER TABLE guide_packages ADD COLUMN sort_order INT DEFAULT 0 COMMENT '排序' AFTER status");
  4183. } catch (Exception e) {
  4184. }
  4185. // 成长规划师套餐服务项表
  4186. try {
  4187. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_package_services (" +
  4188. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4189. "package_id BIGINT NOT NULL COMMENT '关联套餐', " +
  4190. "content_id BIGINT NOT NULL COMMENT '关联服务内容', " +
  4191. "quantity INT DEFAULT 1 COMMENT '数量', " +
  4192. "unit VARCHAR(20) COMMENT '单位' " +
  4193. ")");
  4194. } catch (Exception e) {
  4195. log.warn("创建guide_package_services表失败: {}", e.getMessage());
  4196. }
  4197. // 订单表
  4198. try {
  4199. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_orders (" +
  4200. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4201. "order_no VARCHAR(50) NOT NULL COMMENT '订单号', " +
  4202. "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " +
  4203. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  4204. "package_id BIGINT NOT NULL COMMENT '套餐ID', " +
  4205. "amount INT NOT NULL COMMENT '金额(分)', " +
  4206. "status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态:PENDING/PAID/COMPLETED/REFUNDED', " +
  4207. "pay_time DATETIME COMMENT '支付时间', " +
  4208. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4209. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  4210. ")");
  4211. } catch (Exception e) {
  4212. log.warn("创建guide_orders表失败: {}", e.getMessage());
  4213. }
  4214. // 佣金明细表
  4215. try {
  4216. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_commissions (" +
  4217. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4218. "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " +
  4219. "order_id BIGINT NOT NULL COMMENT '订单ID', " +
  4220. "amount INT NOT NULL COMMENT '佣金金额(分)', " +
  4221. "rate INT COMMENT '佣金比例(bps,100=1%)', " +
  4222. "status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态:PENDING/COMPLETED', " +
  4223. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
  4224. ")");
  4225. } catch (Exception e) {
  4226. log.warn("创建guide_commissions表失败: {}", e.getMessage());
  4227. }
  4228. // 服务记录表
  4229. try {
  4230. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_service_logs (" +
  4231. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4232. "teacher_id BIGINT NOT NULL COMMENT '成长规划师ID', " +
  4233. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  4234. "order_id BIGINT COMMENT '订单ID', " +
  4235. "service_content_id BIGINT NOT NULL COMMENT '服务内容ID', " +
  4236. "notes VARCHAR(500) COMMENT '服务记录', " +
  4237. "duration INT COMMENT '服务时长(分钟)', " +
  4238. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
  4239. ")");
  4240. } catch (Exception e) {
  4241. log.warn("创建guide_service_logs表失败: {}", e.getMessage());
  4242. }
  4243. // 成长规划师邀请码表
  4244. try {
  4245. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_invite_codes (" +
  4246. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4247. "guide_id BIGINT NOT NULL COMMENT '成长规划师用户ID', " +
  4248. "code VARCHAR(50) NOT NULL UNIQUE COMMENT '邀请码', " +
  4249. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态', " +
  4250. "used_by_user_id BIGINT COMMENT '使用该邀请码的用户ID', " +
  4251. "used_at DATETIME COMMENT '使用时间', " +
  4252. "expire_at DATETIME COMMENT '过期时间', " +
  4253. "use_count INT DEFAULT 0 COMMENT '使用次数', " +
  4254. "max_use_count INT COMMENT '最大使用次数', " +
  4255. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4256. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  4257. ")");
  4258. } catch (Exception e) {
  4259. log.warn("创建guide_invite_codes表失败: {}", e.getMessage());
  4260. }
  4261. // 迁移29: assessment_orders表添加appointment_id字段
  4262. try {
  4263. jdbcTemplate.execute("ALTER TABLE assessment_orders ADD COLUMN appointment_id BIGINT COMMENT '关联的预约ID'");
  4264. log.info("已添加appointment_id列到assessment_orders表");
  4265. } catch (Exception e) {
  4266. // 列已存在,忽略错误
  4267. }
  4268. // 财富维度: 财商打卡记录表
  4269. try {
  4270. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS finance_checkins (" +
  4271. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4272. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  4273. "child_id BIGINT NULL COMMENT '孩子ID,家长本人打卡时为NULL', " +
  4274. "checkin_type VARCHAR(20) NOT NULL COMMENT '打卡类型: daily_plan/saving/expense/review', " +
  4275. "type VARCHAR(10) DEFAULT 'expense' COMMENT '类型: income(收入)/expense(支出)', " +
  4276. "checkin_date DATE NOT NULL COMMENT '打卡日期', " +
  4277. "amount INT COMMENT '金额(分,收支记录)', " +
  4278. "category VARCHAR(50) COMMENT '收支分类', " +
  4279. "note VARCHAR(200) COMMENT '备注', " +
  4280. "mood VARCHAR(20) COMMENT '心情', " +
  4281. "earned_energy INT DEFAULT 0 COMMENT '获得的能量值', " +
  4282. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4283. "INDEX idx_user_date (user_id, checkin_date), " +
  4284. "INDEX idx_child (child_id)" +
  4285. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='财商打卡记录表'");
  4286. log.info("已创建finance_checkins表");
  4287. } catch (Exception e) {
  4288. log.warn("创建finance_checkins表失败: {}", e.getMessage());
  4289. }
  4290. // 财富维度: 保单管理表
  4291. try {
  4292. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS insurance_policies (" +
  4293. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4294. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  4295. "child_id BIGINT COMMENT '关联孩子ID', " +
  4296. "policy_name VARCHAR(100) NOT NULL COMMENT '保单名称', " +
  4297. "insurance_company VARCHAR(100) COMMENT '保险公司', " +
  4298. "policy_type VARCHAR(50) COMMENT '保单类型: medical/life/accident/education/endowment', " +
  4299. "insured_person VARCHAR(50) NOT NULL COMMENT '被保人姓名', " +
  4300. "premium INT COMMENT '保费(分)', " +
  4301. "sum_insured INT COMMENT '保额(分)', " +
  4302. "start_date DATE COMMENT '生效日期', " +
  4303. "end_date DATE COMMENT '到期日期', " +
  4304. "renewal_type VARCHAR(20) DEFAULT 'annual' COMMENT '续费类型: annual/monthly/single', " +
  4305. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/expired/cancelled', " +
  4306. "policy_no VARCHAR(100) COMMENT '保单号', " +
  4307. "remark VARCHAR(500) COMMENT '备注', " +
  4308. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4309. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4310. "INDEX idx_user (user_id), " +
  4311. "INDEX idx_child (child_id), " +
  4312. "INDEX idx_status (status)" +
  4313. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='保单管理表'");
  4314. log.info("已创建insurance_policies表");
  4315. } catch (Exception e) {
  4316. log.warn("创建insurance_policies表失败: {}", e.getMessage());
  4317. }
  4318. // ==================== 健康检测报告模块 ============= // 健康报告表(体检报告/菌群检测报告)
  4319. try {
  4320. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_reports (" +
  4321. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4322. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  4323. "family_id BIGINT COMMENT '家庭ID', " +
  4324. "report_type VARCHAR(32) NOT NULL COMMENT '报告类型: physical_exam(体检)/gut_flora(菌群检测)', " +
  4325. "report_date DATE COMMENT '报告日期', " +
  4326. "overall_score INT COMMENT '健康总分(0-100)', " +
  4327. "gut_health_score INT COMMENT '菌群健康分', " +
  4328. "chronic_disease_score INT COMMENT '慢病控制分', " +
  4329. "nutrition_score INT COMMENT '营养均衡分', " +
  4330. "gut_age VARCHAR(32) COMMENT '肠道预测年龄', " +
  4331. "gut_type VARCHAR(50) COMMENT '肠型', " +
  4332. "file_url VARCHAR(500) COMMENT '报告文件URL', " +
  4333. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/archived', " +
  4334. "remark VARCHAR(500) COMMENT '备注', " +
  4335. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4336. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4337. "INDEX idx_user_id (user_id), " +
  4338. "INDEX idx_family_id (family_id), " +
  4339. "INDEX idx_report_type (report_type)" +
  4340. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康检测报告表'");
  4341. log.info("已创建health_reports表");
  4342. } catch (Exception e) {
  4343. log.warn("创建health_reports表可能已存在: {}", e.getMessage());
  4344. }
  4345. // 健康指标明细表
  4346. try {
  4347. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_indicators (" +
  4348. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4349. "report_id BIGINT NOT NULL COMMENT '关联报告ID', " +
  4350. "category VARCHAR(50) COMMENT '指标分类: 营养状况/疾病风险/肠道屏障/神经递质/...', " +
  4351. "indicator_name VARCHAR(100) NOT NULL COMMENT '指标名称', " +
  4352. "indicator_value VARCHAR(100) COMMENT '检测值', " +
  4353. "unit VARCHAR(50) COMMENT '单位', " +
  4354. "ref_range VARCHAR(200) COMMENT '参考范围', " +
  4355. "status VARCHAR(50) COMMENT '评估状态: 正常/偏低/偏高/过多/注意/低风险/未检出', " +
  4356. "sort_order INT DEFAULT 0 COMMENT '显示排序', " +
  4357. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4358. "INDEX idx_report_id (report_id), " +
  4359. "INDEX idx_category (category)" +
  4360. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康指标明细表'");
  4361. log.info("已创建health_indicators表");
  4362. } catch (Exception e) {
  4363. log.warn("创建health_indicators表可能已存在: {}", e.getMessage());
  4364. }
  4365. // ---------- 健康报告表 新增菌群摘要字段 ----------
  4366. try {
  4367. jdbcTemplate.execute("ALTER TABLE health_reports " +
  4368. "ADD COLUMN gut_balance_score INT COMMENT '菌群平衡分(0-100)', " +
  4369. "ADD COLUMN gut_diversity_score INT COMMENT '菌群多样性分(0-100)', " +
  4370. "ADD COLUMN beneficial_bacteria_score INT COMMENT '有益菌分(0-100)', " +
  4371. "ADD COLUMN harmful_bacteria_score INT COMMENT '有害菌分(0-100)', " +
  4372. "ADD COLUMN core_species_score INT COMMENT '核心菌属分(0-100)', " +
  4373. "ADD COLUMN health_rating VARCHAR(100) COMMENT '总分评级,如亚健康/优秀等'");
  4374. log.info("已为health_reports表添加6个菌群摘要字段");
  4375. } catch (Exception e) {
  4376. log.warn("为health_reports表添加字段可能已存在: {}", e.getMessage());
  4377. }
  4378. ensureColumn("health_reports", "subject_id", "BIGINT COMMENT '报告主体用户ID(关联users表,为null表示未绑定)'");
  4379. log.info("成长规划师模块、财富维度及健康检测报告数据库表初始化完成");
  4380. // 健康打卡表
  4381. try {
  4382. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_checkins (" +
  4383. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4384. "user_id BIGINT COMMENT '用户ID(家长)', " +
  4385. "child_id BIGINT COMMENT '孩子ID', " +
  4386. "checkin_date DATE COMMENT '打卡日期', " +
  4387. "behaviors TEXT COMMENT '打卡行为JSON数组', " +
  4388. "note VARCHAR(500) COMMENT '备注', " +
  4389. "mood VARCHAR(20) COMMENT '心情: happy/normal/sad/angry/sick', " +
  4390. "earned_energy INT DEFAULT 5 COMMENT '获得能量值', " +
  4391. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4392. "INDEX idx_child_id (child_id), " +
  4393. "INDEX idx_checkin_date (checkin_date)" +
  4394. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康打卡表'");
  4395. log.info("已创建health_checkins表");
  4396. } catch (Exception e) {
  4397. log.warn("创建health_checkins表可能已存在: {}", e.getMessage());
  4398. }
  4399. // 迁移31: 创建联系人表
  4400. // 创建文章阅读记录表
  4401. try {
  4402. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_reading_records (" +
  4403. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4404. "user_id BIGINT COMMENT '用户ID', " +
  4405. "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
  4406. "content VARCHAR(500) COMMENT '阅读内容摘要', " +
  4407. "duration_seconds INT DEFAULT 0 COMMENT '阅读时长(秒)', " +
  4408. "read_at DATETIME COMMENT '阅读时间', " +
  4409. "created_at DATETIME, " +
  4410. "INDEX idx_child_id (child_id), " +
  4411. "INDEX idx_read_at (read_at)" +
  4412. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章阅读记录表'");
  4413. log.info("已创建article_reading_records表");
  4414. } catch (Exception e) {
  4415. log.warn("创建article_reading_records表可能已存在: {}", e.getMessage());
  4416. }
  4417. // 迁移32: products表添加配送/类目/销售字段(商品编辑表单对齐)
  4418. ensureColumn("products", "delivery_method", "TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可'");
  4419. ensureColumn("products", "category_id", "BIGINT COMMENT '商品类目ID'");
  4420. ensureColumn("products", "distribution_system_id", "BIGINT COMMENT '销售体系ID'");
  4421. // 迁移33: 创建供应商销售体系表 + product_orders扩展字段
  4422. // 迁移222: health_reports 添加 shannon_index, payload_json 列并创建 health_report_edit_logs 表
  4423. try {
  4424. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN shannon_index VARCHAR(32) DEFAULT NULL COMMENT '香农指数'");
  4425. log.info("已添加shannon_index列到health_reports表");
  4426. } catch (Exception e) {
  4427. // 列已存在,忽略错误
  4428. }
  4429. try {
  4430. jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN payload_json LONGTEXT COMMENT '报告完整载荷快照'");
  4431. log.info("已添加payload_json列到health_reports表");
  4432. } catch (Exception e) {
  4433. // 列已存在,忽略错误
  4434. }
  4435. try {
  4436. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_report_edit_logs (" +
  4437. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4438. "report_id BIGINT NOT NULL, " +
  4439. "user_id BIGINT, " +
  4440. "report_type VARCHAR(32), " +
  4441. "action VARCHAR(20) COMMENT 'create/edit', " +
  4442. "before_payload LONGTEXT, " +
  4443. "after_payload LONGTEXT, " +
  4444. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
  4445. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕'");
  4446. log.info("已创建health_report_edit_logs表");
  4447. } catch (Exception e) {
  4448. // 表已存在,忽略错误
  4449. }
  4450. ensureColumn("product_orders", "supply_system_id", "BIGINT COMMENT '所属供应商体系ID'");
  4451. ensureColumn("product_orders", "supplier_id", "BIGINT COMMENT '收款供应商用户ID'");
  4452. ensureColumn("product_orders", "supply_commission", "INT COMMENT '销售佣金(分)'");
  4453. try {
  4454. jdbcTemplate.execute("ALTER TABLE product_orders ADD KEY idx_supply_system_id (supply_system_id)");
  4455. log.info("已添加idx_supply_system_id索引到product_orders表");
  4456. } catch (Exception e) {
  4457. log.warn("添加idx_supply_system_id索引可能已存在: {}", e.getMessage());
  4458. }
  4459. try {
  4460. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system (" +
  4461. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4462. "name VARCHAR(100) NOT NULL COMMENT '体系名称', " +
  4463. "admin_id BIGINT NOT NULL COMMENT '管理员用户ID', " +
  4464. "settlement_period_days INT NOT NULL DEFAULT 30 COMMENT '账期(天)', " +
  4465. "platform_profit_rate DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '平台留利比例(%)', " +
  4466. "description VARCHAR(500) COMMENT '描述', " +
  4467. "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled', " +
  4468. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  4469. "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4470. "KEY idx_admin_id (admin_id)" +
  4471. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系'");
  4472. log.info("已创建supply_system表");
  4473. } catch (Exception e) {
  4474. log.warn("创建supply_system表可能已存在: {}", e.getMessage());
  4475. }
  4476. try {
  4477. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system_member (" +
  4478. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4479. "system_id BIGINT NOT NULL COMMENT '体系ID', " +
  4480. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  4481. "role VARCHAR(20) NOT NULL DEFAULT 'member' COMMENT '角色: admin/member', " +
  4482. "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive', " +
  4483. "joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  4484. "UNIQUE KEY uk_system_user (system_id, user_id), " +
  4485. "KEY idx_user_id (user_id)" +
  4486. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体系成员'");
  4487. log.info("已创建supply_system_member表");
  4488. } catch (Exception e) {
  4489. log.warn("创建supply_system_member表可能已存在: {}", e.getMessage());
  4490. }
  4491. try {
  4492. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement (" +
  4493. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4494. "system_id BIGINT NOT NULL COMMENT '体系ID', " +
  4495. "period_start DATE NOT NULL COMMENT '结算周期开始', " +
  4496. "period_end DATE NOT NULL COMMENT '结算周期结束', " +
  4497. "total_sales INT NOT NULL DEFAULT 0 COMMENT '销售总额(分)', " +
  4498. "platform_profit INT NOT NULL DEFAULT 0 COMMENT '平台留利金额(分)', " +
  4499. "supplier_payout INT NOT NULL DEFAULT 0 COMMENT '供应商实付(分)', " +
  4500. "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/settled', " +
  4501. "settled_at DATETIME COMMENT '结算时间', " +
  4502. "remark VARCHAR(500) COMMENT '备注', " +
  4503. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  4504. "KEY idx_system_id (system_id), " +
  4505. "KEY idx_period (period_start, period_end)" +
  4506. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算记录'");
  4507. log.info("已创建supply_settlement表");
  4508. } catch (Exception e) {
  4509. log.warn("创建supply_settlement表可能已存在: {}", e.getMessage());
  4510. }
  4511. try {
  4512. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement_detail (" +
  4513. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4514. "settlement_id BIGINT NOT NULL COMMENT '结算ID', " +
  4515. "order_id BIGINT NOT NULL COMMENT '订单ID', " +
  4516. "order_no VARCHAR(64) NOT NULL COMMENT '订单号', " +
  4517. "product_name VARCHAR(200) NOT NULL COMMENT '商品名称', " +
  4518. "amount INT NOT NULL COMMENT '订单金额(分)', " +
  4519. "platform_fee INT NOT NULL COMMENT '平台留利(分)', " +
  4520. "supplier_id BIGINT COMMENT '收款供应商用户ID', " +
  4521. "supplier_name VARCHAR(100) COMMENT '收款供应商名称', " +
  4522. "payout INT NOT NULL COMMENT '供应商实付(分)', " +
  4523. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  4524. "KEY idx_settlement_id (settlement_id), " +
  4525. "KEY idx_order_id (order_id)" +
  4526. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算明细'");
  4527. log.info("已创建supply_settlement_detail表");
  4528. } catch (Exception e) {
  4529. log.warn("创建supply_settlement_detail表可能已存在: {}", e.getMessage());
  4530. }
  4531. // 迁移: growth_records表添加来源类型/去重/补充字段
  4532. try {
  4533. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN source_type VARCHAR(20) DEFAULT 'manual' COMMENT '来源类型: manual/platform_purchase/self_upload/supplement'");
  4534. log.info("已添加source_type列到growth_records表");
  4535. } catch (Exception e) {
  4536. }
  4537. try {
  4538. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN source_file_url VARCHAR(500) COMMENT '原始报告文件URL'");
  4539. log.info("已添加source_file_url列到growth_records表");
  4540. } catch (Exception e) {
  4541. }
  4542. try {
  4543. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN order_id BIGINT COMMENT '关联订单ID'");
  4544. log.info("已添加order_id列到growth_records表");
  4545. } catch (Exception e) {
  4546. }
  4547. try {
  4548. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN report_date DATETIME COMMENT '测评/报告日期'");
  4549. log.info("已添加report_date列到growth_records表");
  4550. } catch (Exception e) {
  4551. }
  4552. try {
  4553. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN parent_record_id BIGINT COMMENT '关联的原始档案ID(补充档案专用)'");
  4554. log.info("已添加parent_record_id列到growth_records表");
  4555. } catch (Exception e) {
  4556. }
  4557. try {
  4558. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN is_latest TINYINT(1) DEFAULT 1 COMMENT '是否同组最新记录'");
  4559. log.info("已添加is_latest列到growth_records表");
  4560. } catch (Exception e) {
  4561. }
  4562. try {
  4563. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN dedup_hash VARCHAR(64) COMMENT '去重哈希值(SHA256)'");
  4564. log.info("已添加dedup_hash列到growth_records表");
  4565. } catch (Exception e) {
  4566. }
  4567. try {
  4568. jdbcTemplate.execute("ALTER TABLE growth_records ADD COLUMN parsed_data TEXT COMMENT '上传报告的解析结果(JSON)'");
  4569. log.info("已添加parsed_data列到growth_records表");
  4570. } catch (Exception e) {
  4571. }
  4572. try {
  4573. jdbcTemplate.execute("CREATE UNIQUE INDEX IF NOT EXISTS uk_growth_dedup_hash ON growth_records(dedup_hash)");
  4574. log.info("已添加uk_growth_dedup_hash唯一索引到growth_records表");
  4575. } catch (Exception e) {
  4576. try {
  4577. jdbcTemplate.execute("ALTER TABLE growth_records ADD UNIQUE INDEX uk_growth_dedup_hash (dedup_hash)");
  4578. log.info("已通过ALTER TABLE添加uk_growth_dedup_hash唯一索引");
  4579. } catch (Exception e2) {
  4580. log.warn("添加uk_growth_dedup_hash索引可能已存在: {}", e2.getMessage());
  4581. }
  4582. }
  4583. // 初始化成长任务种子数据
  4584. try {
  4585. Integer count = jdbcTemplate.queryForObject(
  4586. "SELECT COUNT(*) FROM growth_task", Integer.class);
  4587. if (count == null || count == 0) {
  4588. jdbcTemplate.execute("INSERT INTO growth_task (type, title, description, reward_points, reward_energy, target_value, task_key, enabled, sort_order, created_at) VALUES " +
  4589. "('DAILY', '每日签到', '完成健康打卡签到', 20, 3, 1, 'DAILY_CHECKIN', 1, 1, NOW()), " +
  4590. "('DAILY', '完成任务', '完成一个任务', 30, 5, 1, 'DAILY_TASK', 1, 2, NOW()), " +
  4591. "('DAILY', '每日分享', '分享小程序给好友', 10, 2, 1, 'DAILY_SHARE', 1, 3, NOW()), " +
  4592. "('DAILY', 'AI对话', '与AI助手对话一次', 15, 3, 1, 'DAILY_AI', 1, 4, NOW())");
  4593. log.info("成长任务种子数据已初始化");
  4594. }
  4595. } catch (Exception e) {
  4596. log.warn("初始化成长任务种子数据失败: {}", e.getMessage());
  4597. }
  4598. // 迁移34: dan_assessment_results表添加家庭成员/EMI心理/大五人格/孩子快照字段
  4599. ensureColumn("dan_assessment_results", "family_member_id", "BIGINT COMMENT '家庭成员ID'");
  4600. ensureColumn("dan_assessment_results", "family_member_name", "VARCHAR(50) COMMENT '成员姓名快照'");
  4601. ensureColumn("dan_assessment_results", "family_member_birthday", "DATE COMMENT '成员出生日期快照'");
  4602. ensureColumn("dan_assessment_results", "family_member_gender", "VARCHAR(10) COMMENT '成员性别快照'");
  4603. ensureColumn("dan_assessment_results", "family_member_age", "INT COMMENT '成员年龄快照'");
  4604. ensureColumn("dan_assessment_results", "emotion_score", "INT COMMENT '情绪商数(EMI)'");
  4605. ensureColumn("dan_assessment_results", "resilience_score", "INT COMMENT '心理韧性(EMI)'");
  4606. ensureColumn("dan_assessment_results", "stress_coping_score", "INT COMMENT '压力应对(EMI)'");
  4607. ensureColumn("dan_assessment_results", "self_awareness_score", "INT COMMENT '自我认知(EMI)'");
  4608. ensureColumn("dan_assessment_results", "openness_score", "INT COMMENT '开放性(大五人格)'");
  4609. ensureColumn("dan_assessment_results", "conscientiousness_score", "INT COMMENT '尽责性(大五人格)'");
  4610. ensureColumn("dan_assessment_results", "extraversion_score", "INT COMMENT '外向性(大五人格)'");
  4611. ensureColumn("dan_assessment_results", "agreeableness_score", "INT COMMENT '宜人性(大五人格)'");
  4612. ensureColumn("dan_assessment_results", "neuroticism_score", "INT COMMENT '神经质(大五人格)'");
  4613. ensureColumn("dan_assessment_results", "big_five_overall_score", "INT COMMENT '大五人格综合分'");
  4614. ensureColumn("dan_assessment_results", "child_name", "VARCHAR(50) COMMENT '孩子姓名快照'");
  4615. ensureColumn("dan_assessment_results", "child_birthday", "DATE COMMENT '出生日期快照'");
  4616. ensureColumn("dan_assessment_results", "child_gender", "VARCHAR(10) COMMENT '性别快照'");
  4617. ensureColumn("dan_assessment_results", "child_age", "INT COMMENT '年龄快照'");
  4618. ensureColumn("dan_assessment_results", "school", "VARCHAR(100) COMMENT '学校快照'");
  4619. ensureColumn("dan_assessment_results", "grade", "VARCHAR(20) COMMENT '年级快照'");
  4620. ensureColumn("dan_assessment_results", "grade_class", "VARCHAR(20) COMMENT '班级快照'");
  4621. ensureColumn("dan_assessment_results", "height", "INT COMMENT '身高(CM)快照'");
  4622. ensureColumn("dan_assessment_results", "weight", "INT COMMENT '体重(KG)快照'");
  4623. ensureColumn("dan_assessment_results", "family_status", "VARCHAR(50) COMMENT '家庭状况快照'");
  4624. ensureColumn("dan_assessment_results", "parent_degree", "VARCHAR(50) COMMENT '父母最高学历快照'");
  4625. ensureColumn("dan_assessment_results", "nation", "VARCHAR(20) COMMENT '民族快照'");
  4626. ensureColumn("dan_assessment_results", "province", "VARCHAR(50) COMMENT '省快照'");
  4627. ensureColumn("dan_assessment_results", "city", "VARCHAR(50) COMMENT '市快照'");
  4628. ensureColumn("dan_assessment_results", "district", "VARCHAR(50) COMMENT '区快照'");
  4629. ensureColumn("dan_assessment_results", "address", "VARCHAR(200) COMMENT '详细地址快照'");
  4630. ensureColumn("dan_assessment_results", "single_child", "VARCHAR(10) COMMENT '是否独生子女快照'");
  4631. ensureColumn("dan_assessment_results", "school_score", "VARCHAR(20) COMMENT '在校成绩快照'");
  4632. ensureColumn("dan_assessment_results", "urgent_name", "VARCHAR(50) COMMENT '紧急联系人姓名'");
  4633. ensureColumn("dan_assessment_results", "urgent_phone", "VARCHAR(20) COMMENT '紧急联系人电话'");
  4634. ensureColumn("dan_assessment_results", "pics", "TEXT COMMENT '图片IDs'");
  4635. ensureColumn("dan_assessment_results", "videos", "TEXT COMMENT '视频IDs'");
  4636. ensureColumn("dan_assessment_results", "audio", "VARCHAR(100) COMMENT '音频ID'");
  4637. ensureColumn("dan_assessment_results", "audio_url", "VARCHAR(255) COMMENT '音频URL'");
  4638. ensureColumn("dan_assessment_results", "process_desc", "TEXT COMMENT '过程描述'");
  4639. ensureColumn("dan_assessment_results", "result_desc", "TEXT COMMENT '结果简评'");
  4640. // 迁移35: points_log表添加family_member_id列(EnergyService.calcChildWealth家庭能量计算需要)
  4641. ensureColumn("points_log", "family_member_id", "BIGINT COMMENT '家庭成员ID'");
  4642. // 迁移36: family_members表添加generation + is_spouse列(辈分重构)
  4643. ensureColumn("family_members", "generation", "INT COMMENT '辈分值(0=家庭创建者,+n=向上n代,-n=向下n代)'");
  4644. ensureColumn("family_members", "is_spouse", "TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈)'");
  4645. // 迁移37: relationship_type列已删除,旧数据已迁移完成
  4646. // 迁移38: product_orders表添加points_used/points_cost/money_amount列(混合支付支持)
  4647. ensureColumn("product_orders", "points_used", "INT DEFAULT 0 COMMENT '使用的积分数量'");
  4648. ensureColumn("product_orders", "points_cost", "INT DEFAULT 0 COMMENT '积分抵扣金额(分)'");
  4649. ensureColumn("product_orders", "money_amount", "INT DEFAULT 0 COMMENT '现金支付金额(分)'");
  4650. // 迁移39: 创建供应商体系相关表
  4651. try {
  4652. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system (" +
  4653. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4654. "name VARCHAR(100) NOT NULL COMMENT '体系名称', " +
  4655. "admin_id BIGINT COMMENT '管理员用户ID', " +
  4656. "settlement_period_days INT DEFAULT 30 COMMENT '账期天数', " +
  4657. "platform_profit_rate DECIMAL(10,2) DEFAULT 0.00 COMMENT '平台留利比例(%)', " +
  4658. "description VARCHAR(500) COMMENT '描述', " +
  4659. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/disabled', " +
  4660. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4661. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4662. "INDEX idx_admin (admin_id), " +
  4663. "INDEX idx_status (status)" +
  4664. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系'");
  4665. log.info("已创建supply_system表");
  4666. } catch (Exception e) {
  4667. log.warn("创建supply_system表失败: {}", e.getMessage());
  4668. }
  4669. try {
  4670. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_system_member (" +
  4671. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4672. "system_id BIGINT NOT NULL COMMENT '体系ID', " +
  4673. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  4674. "role VARCHAR(20) DEFAULT 'member' COMMENT '角色 admin/member', " +
  4675. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/disabled', " +
  4676. "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4677. "INDEX idx_system (system_id), " +
  4678. "INDEX idx_user (user_id), " +
  4679. "UNIQUE INDEX idx_system_user (system_id, user_id)" +
  4680. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系成员'");
  4681. log.info("已创建supply_system_member表");
  4682. } catch (Exception e) {
  4683. log.warn("创建supply_system_member表失败: {}", e.getMessage());
  4684. }
  4685. try {
  4686. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement (" +
  4687. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4688. "system_id BIGINT NOT NULL COMMENT '体系ID', " +
  4689. "period_start DATE COMMENT '结算周期开始', " +
  4690. "period_end DATE COMMENT '结算周期结束', " +
  4691. "total_sales INT DEFAULT 0 COMMENT '销售总额(分)', " +
  4692. "platform_profit INT DEFAULT 0 COMMENT '平台留利(分)', " +
  4693. "supplier_payout INT DEFAULT 0 COMMENT '供应商实付(分)', " +
  4694. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/settled', " +
  4695. "settled_at DATETIME COMMENT '结算时间', " +
  4696. "remark VARCHAR(500) COMMENT '备注', " +
  4697. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4698. "INDEX idx_system (system_id), " +
  4699. "INDEX idx_status (status), " +
  4700. "INDEX idx_period (period_start, period_end)" +
  4701. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商结算单'");
  4702. log.info("已创建supply_settlement表");
  4703. } catch (Exception e) {
  4704. log.warn("创建supply_settlement表失败: {}", e.getMessage());
  4705. }
  4706. try {
  4707. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_settlement_detail (" +
  4708. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4709. "settlement_id BIGINT NOT NULL COMMENT '结算单ID', " +
  4710. "order_id BIGINT COMMENT '订单ID', " +
  4711. "order_no VARCHAR(64) COMMENT '订单号', " +
  4712. "product_name VARCHAR(200) COMMENT '商品名称', " +
  4713. "amount INT DEFAULT 0 COMMENT '订单金额(分)', " +
  4714. "platform_fee INT DEFAULT 0 COMMENT '平台留利(分)', " +
  4715. "supplier_id BIGINT COMMENT '收款供应商ID', " +
  4716. "supplier_name VARCHAR(100) COMMENT '收款供应商名称', " +
  4717. "payout INT DEFAULT 0 COMMENT '实付(分)', " +
  4718. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4719. "INDEX idx_settlement (settlement_id), " +
  4720. "INDEX idx_order (order_id)" +
  4721. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商结算明细'");
  4722. log.info("已创建supply_settlement_detail表");
  4723. } catch (Exception e) {
  4724. log.warn("创建supply_settlement_detail表失败: {}", e.getMessage());
  4725. }
  4726. // 迁移40: 添加supplier_admin角色到users.role(列已为VARCHAR,直接可用)
  4727. // 当前role列为VARCHAR(20),无需ALTER ENUM
  4728. // 迁移40: users表添加mascot字段(AI助手形象: xibao浠宝/fubao福宝)
  4729. ensureColumn("users", "mascot", "VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)'");
  4730. // 迁移41: 创建family_relationships表(家庭成员双向关系,替代relationship_type/role_override)
  4731. try {
  4732. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_relationships (" +
  4733. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4734. "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " +
  4735. "from_member_id BIGINT NOT NULL COMMENT '关系起点成员ID', " +
  4736. "to_member_id BIGINT NOT NULL COMMENT '关系终点成员ID', " +
  4737. "generation_value INT NOT NULL COMMENT '辈分值: -1=子女, 0=同辈, 1=父母, 2=祖父母, 3=曾祖父母', " +
  4738. "relationship_key VARCHAR(50) COMMENT '关系标签key(冗余用于显示)', " +
  4739. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4740. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4741. "UNIQUE KEY uk_from_to (from_member_id, to_member_id), " +
  4742. "INDEX idx_family_id (family_id), " +
  4743. "INDEX idx_from (from_member_id), " +
  4744. "INDEX idx_to (to_member_id)" +
  4745. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员双向关系表'");
  4746. log.info("已创建family_relationships表");
  4747. } catch (Exception e) {
  4748. log.warn("创建family_relationships表可能已存在: {}", e.getMessage());
  4749. }
  4750. // 迁移42: 创建butler_sessions表(AI管家会话管理)
  4751. try {
  4752. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_sessions (" +
  4753. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4754. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  4755. "title VARCHAR(200) DEFAULT '新会话' COMMENT '会话标题', " +
  4756. "conversation_id VARCHAR(100) COMMENT 'Dify对话ID', " +
  4757. "context TEXT COMMENT '上下文信息(JSON)', " +
  4758. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active(进行中)/archived(已归档)', " +
  4759. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4760. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4761. "INDEX idx_user_id (user_id), " +
  4762. "INDEX idx_status (status), " +
  4763. "INDEX idx_user_status (user_id, status)" +
  4764. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI管家会话表'");
  4765. log.info("已创建butler_sessions表");
  4766. } catch (Exception e) {
  4767. log.warn("创建butler_sessions表可能已存在: {}", e.getMessage());
  4768. }
  4769. // 迁移 79: 创建 tianpan_daily_fortune 表(家庭每日运势)
  4770. try {
  4771. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tianpan_daily_fortune (" +
  4772. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4773. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  4774. "date VARCHAR(20) NOT NULL COMMENT '日期 YYYY-MM-DD', " +
  4775. "lunar_date VARCHAR(50) COMMENT '农历日期', " +
  4776. "day_ganzhi VARCHAR(10) COMMENT '日柱', " +
  4777. "five_elements_main VARCHAR(20) COMMENT '当日五行主气', " +
  4778. "five_elements_detail VARCHAR(100) COMMENT '五行详情', " +
  4779. "auspicious TEXT COMMENT '宜做的事(JSON数组)', " +
  4780. "inauspicious TEXT COMMENT '忌做的事(JSON数组)', " +
  4781. "family_advice TEXT COMMENT '家庭建议', " +
  4782. "focus_member_name VARCHAR(50) COMMENT '关注成员', " +
  4783. "focus_member_tip TEXT COMMENT '成员提示', " +
  4784. "wuxing_tip TEXT COMMENT '五行提示', " +
  4785. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4786. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4787. "UNIQUE KEY uk_family_date (family_id, date), " +
  4788. "INDEX idx_family_id (family_id), " +
  4789. "INDEX idx_date (date)" +
  4790. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭每日运势表'");
  4791. log.info("已创建 tianpan_daily_fortune 表");
  4792. } catch (Exception e) {
  4793. log.warn("创建 tianpan_daily_fortune 表可能已存在: {}", e.getMessage());
  4794. }
  4795. // 迁移 80: 创建 tianpan_member_annual_energy 表(成员年度能量)
  4796. try {
  4797. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tianpan_member_annual_energy (" +
  4798. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  4799. "member_id BIGINT NOT NULL COMMENT '成员ID', " +
  4800. "year INT NOT NULL COMMENT '年份', " +
  4801. "dimensions_body INT DEFAULT 0 COMMENT '身能量', " +
  4802. "dimensions_mind INT DEFAULT 0 COMMENT '心能量', " +
  4803. "dimensions_wisdom INT DEFAULT 0 COMMENT '智能量', " +
  4804. "dimensions_action INT DEFAULT 0 COMMENT '行能量', " +
  4805. "dimensions_wealth INT DEFAULT 0 COMMENT '富能量', " +
  4806. "jupiter_phase VARCHAR(50) COMMENT '木星相位', " +
  4807. "saturn_phase VARCHAR(50) COMMENT '土星相位', " +
  4808. "highlights TEXT COMMENT '年度亮点(JSON数组)', " +
  4809. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  4810. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  4811. "UNIQUE KEY uk_member_year (member_id, year), " +
  4812. "INDEX idx_member_id (member_id), " +
  4813. "INDEX idx_year (year)" +
  4814. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员年度能量表'");
  4815. log.info("已创建 tianpan_member_annual_energy 表");
  4816. } catch (Exception e) {
  4817. log.warn("创建 tianpan_member_annual_energy 表可能已存在: {}", e.getMessage());
  4818. }
  4819. }
  4820. private void insertSysConfigSeed(String key, String value, String desc) {
  4821. Integer count = jdbcTemplate.queryForObject(
  4822. "SELECT COUNT(*) FROM sys_config WHERE config_key = ?", Integer.class, key);
  4823. if (count == null || count == 0) {
  4824. jdbcTemplate.update(
  4825. "INSERT INTO sys_config (config_key, config_value, description, updated_at) VALUES (?, ?, ?, NOW())",
  4826. key, value, desc);
  4827. }
  4828. }
  4829. private void initFoodTables() {
  4830. log.info("开始初始化食品相关表...");
  4831. // 4张新表 (CREATE TABLE IF NOT EXISTS)
  4832. String[] ddl = {
  4833. "CREATE TABLE IF NOT EXISTS foods ("
  4834. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4835. + "name VARCHAR(100) NOT NULL COMMENT '食材名称', "
  4836. + "category VARCHAR(50) COMMENT '分类: 蔬菜/水果/肉禽/水产/蛋奶/豆类/谷物/调味/其他', "
  4837. + "unit VARCHAR(20) DEFAULT 'g' COMMENT '计量单位', "
  4838. + "calories DECIMAL(8,2) COMMENT '热量(kcal/100g)', "
  4839. + "protein DECIMAL(8,2) COMMENT '蛋白质(g/100g)', "
  4840. + "fat DECIMAL(8,2) COMMENT '脂肪(g/100g)', "
  4841. + "carbs DECIMAL(8,2) COMMENT '碳水(g/100g)', "
  4842. + "fiber DECIMAL(8,2) COMMENT '膳食纤维(g/100g)', "
  4843. + "energy_kj DECIMAL(8,2) COMMENT '能量(kJ/100g)', "
  4844. + "starch DECIMAL(8,2) COMMENT '淀粉(g/100g)', "
  4845. + "cholesterol DECIMAL(8,2) COMMENT '胆固醇(mg/100g)', "
  4846. + "price_level TINYINT COMMENT '价格等级 1-5', "
  4847. + "nutrition_tags VARCHAR(500) COMMENT '营养标签JSON', "
  4848. + "suitable_for VARCHAR(200) COMMENT '适宜人群JSON', "
  4849. + "allergens VARCHAR(200) COMMENT '过敏原JSON', "
  4850. + "external_source VARCHAR(100) COMMENT '数据来源', "
  4851. + "external_id VARCHAR(100) COMMENT '外部ID', "
  4852. + "cover_image VARCHAR(500) COMMENT '食材图片', "
  4853. + "report_id BIGINT COMMENT '关联报告ID(从PDF解析的食物推荐)', "
  4854. + "score INT COMMENT '食物评分', "
  4855. + "diet_type VARCHAR(50) COMMENT '饮食类型', "
  4856. + "status VARCHAR(20) DEFAULT 'active', "
  4857. + "sort_order INT DEFAULT 0, "
  4858. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4859. + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  4860. + "INDEX idx_category (category), "
  4861. + "INDEX idx_price (price_level)"
  4862. + ");",
  4863. "CREATE TABLE IF NOT EXISTS recipes ("
  4864. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4865. + "name VARCHAR(200) NOT NULL COMMENT '食谱名称', "
  4866. + "meal_type VARCHAR(20) COMMENT '早餐/午餐/晚餐/加餐', "
  4867. + "description TEXT COMMENT '简介', "
  4868. + "ingredients TEXT NOT NULL COMMENT '食材清单JSON', "
  4869. + "steps TEXT COMMENT '烹饪步骤JSON', "
  4870. + "cook_time INT COMMENT '烹饪时间(分钟)', "
  4871. + "difficulty VARCHAR(20) COMMENT '难度: 简单/中等/困难', "
  4872. + "total_calories DECIMAL(8,2) COMMENT '总热量', "
  4873. + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
  4874. + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
  4875. + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
  4876. + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
  4877. + "suitable_for VARCHAR(200) COMMENT '适宜场景', "
  4878. + "nutrition_tags VARCHAR(500) COMMENT '营养标签', "
  4879. + "external_source VARCHAR(100), "
  4880. + "external_id VARCHAR(100), "
  4881. + "cover_image VARCHAR(500), "
  4882. + "status VARCHAR(20) DEFAULT 'active', "
  4883. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4884. + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  4885. + "INDEX idx_meal_type (meal_type), "
  4886. + "INDEX idx_cook_time (cook_time)"
  4887. + ");",
  4888. "CREATE TABLE IF NOT EXISTS seasonal_foods ("
  4889. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4890. + "food_id BIGINT NOT NULL COMMENT '关联foods.id', "
  4891. + "month TINYINT NOT NULL COMMENT '月份 1-12', "
  4892. + "region VARCHAR(50) DEFAULT '全国' COMMENT '产地', "
  4893. + "peak_flag TINYINT DEFAULT 0 COMMENT '盛产标志', "
  4894. + "sort_order INT DEFAULT 0, "
  4895. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4896. + "INDEX idx_food_month (food_id, month), "
  4897. + "INDEX idx_month (month)"
  4898. + ");",
  4899. "CREATE TABLE IF NOT EXISTS meal_logs ("
  4900. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4901. + "user_id BIGINT NOT NULL COMMENT '用户ID', "
  4902. + "child_id BIGINT COMMENT '孩子ID', "
  4903. + "meal_type VARCHAR(20) NOT NULL COMMENT '早餐/午餐/晚餐/加餐', "
  4904. + "meal_date DATE NOT NULL COMMENT '用餐日期', "
  4905. + "meal_time DATETIME COMMENT '具体时间', "
  4906. + "foods TEXT NOT NULL COMMENT '食物清单JSON', "
  4907. + "total_calories DECIMAL(8,2) COMMENT '总热量', "
  4908. + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
  4909. + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
  4910. + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
  4911. + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
  4912. + "note TEXT COMMENT '备注', "
  4913. + "images VARCHAR(1000) COMMENT '图片JSON', "
  4914. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4915. + "INDEX idx_user_date (user_id, meal_date), "
  4916. + "INDEX idx_child_date (child_id, meal_date)"
  4917. + ");",
  4918. "CREATE TABLE IF NOT EXISTS report_food_suitability ("
  4919. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4920. + "report_id BIGINT NOT NULL COMMENT '健康报告ID', "
  4921. + "food_id BIGINT COMMENT '食材ID', "
  4922. + "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', "
  4923. + "category VARCHAR(50) COMMENT '分类', "
  4924. + "score INT NOT NULL COMMENT '适宜度评分', "
  4925. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4926. + "INDEX idx_report (report_id), "
  4927. + "INDEX idx_food (food_id)"
  4928. + ");",
  4929. "CREATE TABLE IF NOT EXISTS user_food_preference ("
  4930. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4931. + "user_id BIGINT NOT NULL COMMENT '用户ID', "
  4932. + "food_id BIGINT COMMENT '食材ID', "
  4933. + "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', "
  4934. + "score INT NOT NULL DEFAULT 0 COMMENT '评分', "
  4935. + "reason VARCHAR(500) COMMENT '原因', "
  4936. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4937. + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  4938. + "INDEX idx_user (user_id)"
  4939. + ");",
  4940. "CREATE TABLE IF NOT EXISTS food_nutrients ("
  4941. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4942. + "food_id BIGINT NOT NULL COMMENT '食材ID', "
  4943. + "nutrient_code VARCHAR(20) NOT NULL COMMENT '营养素编码', "
  4944. + "amount_per_100g DECIMAL(10,4) NOT NULL COMMENT '每100g含量', "
  4945. + "unit VARCHAR(20) NOT NULL COMMENT '单位', "
  4946. + "source VARCHAR(50) DEFAULT 'importer' COMMENT '数据来源', "
  4947. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4948. + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  4949. + "UNIQUE KEY uk_food_nutrient (food_id, nutrient_code), "
  4950. + "INDEX idx_nutrient (nutrient_code)"
  4951. + ");",
  4952. "CREATE TABLE IF NOT EXISTS rdi_standards ("
  4953. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4954. + "nutrient_code VARCHAR(20) NOT NULL COMMENT '营养素编码', "
  4955. + "age_group VARCHAR(10) NOT NULL COMMENT '年龄段', "
  4956. + "gender VARCHAR(6) DEFAULT 'all' COMMENT '性别', "
  4957. + "rdi_type VARCHAR(10) NOT NULL COMMENT '参考类型', "
  4958. + "amount DECIMAL(10,4) NOT NULL COMMENT '推荐量', "
  4959. + "unit VARCHAR(20) NOT NULL COMMENT '单位', "
  4960. + "source VARCHAR(100) COMMENT '数据来源', "
  4961. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4962. + "UNIQUE KEY uk_rdi (nutrient_code, age_group, gender, rdi_type), "
  4963. + "INDEX idx_age (age_group)"
  4964. + ");",
  4965. "CREATE TABLE IF NOT EXISTS shopping_lists ("
  4966. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4967. + "user_id BIGINT NOT NULL COMMENT '用户ID', "
  4968. + "child_id BIGINT COMMENT '孩子ID', "
  4969. + "report_id BIGINT COMMENT '健康报告ID', "
  4970. + "title VARCHAR(200) COMMENT '标题', "
  4971. + "source VARCHAR(20) NOT NULL COMMENT '来源', "
  4972. + "status VARCHAR(20) DEFAULT 'active' COMMENT '状态', "
  4973. + "total_items INT DEFAULT 0 COMMENT '总项数', "
  4974. + "purchased_items INT DEFAULT 0 COMMENT '已购项数', "
  4975. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4976. + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  4977. + "INDEX idx_user (user_id), "
  4978. + "INDEX idx_report (report_id), "
  4979. + "INDEX idx_status (status)"
  4980. + ");",
  4981. "CREATE TABLE IF NOT EXISTS shopping_list_items ("
  4982. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  4983. + "list_id BIGINT NOT NULL COMMENT '购物清单ID', "
  4984. + "food_id BIGINT COMMENT '食材ID', "
  4985. + "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', "
  4986. + "category VARCHAR(50) COMMENT '分类', "
  4987. + "quantity DECIMAL(8,2) DEFAULT 1 COMMENT '数量', "
  4988. + "unit VARCHAR(20) DEFAULT '份' COMMENT '单位', "
  4989. + "priority VARCHAR(10) DEFAULT 'medium' COMMENT '优先级', "
  4990. + "reason VARCHAR(200) COMMENT '推荐原因', "
  4991. + "score INT COMMENT '评分', "
  4992. + "source VARCHAR(20) COMMENT '来源', "
  4993. + "purchased TINYINT DEFAULT 0 COMMENT '是否已购', "
  4994. + "purchased_at DATETIME COMMENT '购买时间', "
  4995. + "sort_order INT DEFAULT 0 COMMENT '排序', "
  4996. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  4997. + "INDEX idx_list (list_id), "
  4998. + "INDEX idx_purchased (purchased)"
  4999. + ");",
  5000. "CREATE TABLE IF NOT EXISTS follow_up ("
  5001. + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
  5002. + "family_id BIGINT NOT NULL COMMENT '家庭ID', "
  5003. + "child_id BIGINT COMMENT '孩子ID', "
  5004. + "teacher_id BIGINT COMMENT '规划师ID', "
  5005. + "deficiency_id BIGINT COMMENT '缺乏项ID', "
  5006. + "message_id BIGINT COMMENT '消息ID', "
  5007. + "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态', "
  5008. + "priority VARCHAR(10) DEFAULT 'medium' COMMENT '优先级', "
  5009. + "description VARCHAR(500) COMMENT '描述', "
  5010. + "parent_feedback TEXT COMMENT '家长反馈', "
  5011. + "teacher_review VARCHAR(500) COMMENT '规划师点评', "
  5012. + "due_date DATETIME COMMENT '截止日期', "
  5013. + "completed_at DATETIME COMMENT '完成时间', "
  5014. + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  5015. + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  5016. + "INDEX idx_family (family_id), "
  5017. + "INDEX idx_teacher (teacher_id), "
  5018. + "INDEX idx_status (status), "
  5019. + "INDEX idx_due (due_date)"
  5020. + ");"
  5021. };
  5022. for (String sql : ddl) {
  5023. try {
  5024. jdbcTemplate.execute(sql);
  5025. } catch (Exception e) {
  5026. log.warn("食品表DDL执行失败(可能已存在): {}", e.getMessage());
  5027. }
  5028. }
  5029. // 扩展 user_nutrition_profile(如果表不存在则跳过)
  5030. ensureColumn("user_nutrition_profile", "budget_monthly", "INT COMMENT '月食品预算(元)'");
  5031. ensureColumn("user_nutrition_profile", "family_taste_preferences", "TEXT COMMENT '家庭成员口味偏好JSON'");
  5032. ensureColumn("user_nutrition_profile", "cuisine_style", "VARCHAR(100) COMMENT '偏好菜系'");
  5033. ensureColumn("user_nutrition_profile", "meal_count", "INT DEFAULT 3 COMMENT '每日餐数'");
  5034. ensureColumn("user_nutrition_profile", "cooking_ability", "VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力: simple/medium/advanced'");
  5035. log.info("食品相关表初始化完成");
  5036. }
  5037. private boolean tableExists(String tableName) {
  5038. try {
  5039. Integer count = jdbcTemplate.queryForObject(
  5040. "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
  5041. Integer.class, tableName);
  5042. return count != null && count > 0;
  5043. } catch (Exception e) {
  5044. return false;
  5045. }
  5046. }
  5047. private boolean columnExists(String table, String column) {
  5048. try {
  5049. Integer count = jdbcTemplate.queryForObject(
  5050. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
  5051. Integer.class, table, column);
  5052. return count != null && count > 0;
  5053. } catch (Exception e) {
  5054. return false;
  5055. }
  5056. }
  5057. private void ensureColumn(String table, String column, String definition) {
  5058. try {
  5059. jdbcTemplate.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition);
  5060. log.info("已添加列 {}.{}", table, column);
  5061. } catch (Exception e) {
  5062. String msg = e.getMessage();
  5063. if (msg != null && (msg.contains("Duplicate") || msg.contains("already exists"))) {
  5064. log.info("列 {}.{} 已存在,跳过", table, column);
  5065. } else {
  5066. log.error("添加列 {}.{} 失败:{}", table, column, msg);
  5067. throw e;
  5068. }
  5069. }
  5070. }
  5071. /** 幂等创建任务模板包,返回 package id */
  5072. private Long ensureTemplatePackage(String name, String desc, int cycleDays, String category) {
  5073. Integer cnt = jdbcTemplate.queryForObject(
  5074. "SELECT COUNT(*) FROM task_template_packages WHERE name = ?", Integer.class, name);
  5075. if (cnt != null && cnt > 0) {
  5076. return jdbcTemplate.queryForObject(
  5077. "SELECT id FROM task_template_packages WHERE name = ? LIMIT 1", Long.class, name);
  5078. }
  5079. jdbcTemplate.update(
  5080. "INSERT INTO task_template_packages (creator_id, name, description, cycle_days, category, is_public, status, created_at, updated_at) " +
  5081. "VALUES (1, ?, ?, ?, ?, 1, 'published', NOW(), NOW())",
  5082. name, desc, cycleDays, category);
  5083. return jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
  5084. }
  5085. /** 幂等写入模板包任务项 */
  5086. private void ensureTemplateItems(Long packageId, String[][] items) {
  5087. Integer cnt = jdbcTemplate.queryForObject(
  5088. "SELECT COUNT(*) FROM task_template_items WHERE package_id = ?", Integer.class, packageId);
  5089. if (cnt != null && cnt > 0) return;
  5090. for (String[] it : items) {
  5091. jdbcTemplate.update(
  5092. "INSERT INTO task_template_items (package_id, task_name, description, points, frequency, sort_order, created_at, updated_at) " +
  5093. "VALUES (?, ?, ?, ?, ?, 0, NOW(), NOW())",
  5094. packageId, it[0], it[1], Integer.valueOf(it[2]), it[3]);
  5095. }
  5096. }
  5097. /** 幂等写入方案生成规则 */
  5098. private void ensurePlanRule(String dimension, int scoreMin, int scoreMax, Long templateId, int priority) {
  5099. Integer cnt = jdbcTemplate.queryForObject(
  5100. "SELECT COUNT(*) FROM assessment_plan_rules WHERE dimension = ? AND score_min = ? AND score_max = ?",
  5101. Integer.class, dimension, scoreMin, scoreMax);
  5102. if (cnt != null && cnt > 0) return;
  5103. jdbcTemplate.update(
  5104. "INSERT INTO assessment_plan_rules (dimension, score_min, score_max, template_id, priority, is_active, created_at, updated_at) " +
  5105. "VALUES (?, ?, ?, ?, ?, 1, NOW(), NOW())",
  5106. dimension, scoreMin, scoreMax, templateId, priority);
  5107. }
  5108. private void insertProductSeed(String name, String desc, String type,
  5109. Integer price,
  5110. String domain, String memberEligible) {
  5111. Integer count = jdbcTemplate.queryForObject(
  5112. "SELECT COUNT(*) FROM products WHERE name = ? AND product_type = ?", Integer.class, name, type);
  5113. if (count == null || count == 0) {
  5114. jdbcTemplate.update(
  5115. "INSERT INTO products (name, description, price, product_type, vendor_id, vendor_name, status, domain, member_eligible, created_at, updated_at) " +
  5116. "VALUES (?, ?, ?, ?, NULL, '平台官方', 'on_shelf', ?, ?, NOW(), NOW())",
  5117. name, desc, price, type, domain, memberEligible);
  5118. }
  5119. }
  5120. private void insertActivitySeed(String title, String desc, String dimensionCode,
  5121. String activityType, String status,
  5122. String startTime, String endTime, String location,
  5123. int maxParticipants, int price, int memberPrice,
  5124. String dimensionWeights) {
  5125. Integer count = jdbcTemplate.queryForObject(
  5126. "SELECT COUNT(*) FROM activities WHERE title = ?", Integer.class, title);
  5127. if (count == null || count == 0) {
  5128. jdbcTemplate.update(
  5129. "INSERT INTO activities (title, description, dimension_code, dimension_weights, activity_type, status, " +
  5130. "start_time, end_time, location, max_participants, current_participants, price, member_price, " +
  5131. "require_registration, checkin_points, created_at, updated_at) " +
  5132. "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1, 10, NOW(), NOW())",
  5133. title, desc, dimensionCode, dimensionWeights, activityType, status,
  5134. startTime, endTime, location, maxParticipants, price, memberPrice);
  5135. }
  5136. }
  5137. private void insertArticleSeed(String title, String summary, String contentType,
  5138. Long categoryId, String dimensionCode, String status,
  5139. String author, String articleType, int readTime, int wordCount,
  5140. String dimensionWeights) {
  5141. Integer count = jdbcTemplate.queryForObject(
  5142. "SELECT COUNT(*) FROM articles WHERE title = ?", Integer.class, title);
  5143. if (count == null || count == 0) {
  5144. jdbcTemplate.update(
  5145. "INSERT INTO articles (title, summary, content, content_type, category_id, " +
  5146. "related_dimensions, dimension_weights, status, author, article_type, " +
  5147. "read_time, word_count, is_featured, view_count, author_type, audit_status, " +
  5148. "visibility, created_at, updated_at) " +
  5149. "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 'admin', 'approved', 'public', NOW(), NOW())",
  5150. title, summary, "(此处为示例文章正文内容,实际数据应在运营后台编辑发布。)",
  5151. contentType, categoryId, "[\"" + dimensionCode + "\"]", dimensionWeights,
  5152. status, author, articleType, readTime, wordCount);
  5153. }
  5154. }
  5155. private void insertContentSectionSeed(String pageKey, String sectionKey, String title,
  5156. String allowedRoles, int sortOrder) {
  5157. Integer count = jdbcTemplate.queryForObject(
  5158. "SELECT COUNT(*) FROM content_sections WHERE page_key = ? AND section_key = ?",
  5159. Integer.class, pageKey, sectionKey);
  5160. if (count == null || count == 0) {
  5161. jdbcTemplate.update(
  5162. "INSERT INTO content_sections (page_key, section_key, title, allowed_roles, sort_order, status, created_at, updated_at) " +
  5163. "VALUES (?, ?, ?, ?, ?, 'active', NOW(), NOW())",
  5164. pageKey, sectionKey, title, allowedRoles, sortOrder);
  5165. }
  5166. }
  5167. private void runLateMigrations() {
  5168. // 迁移51: 创建 member_subscription 表(家庭订阅记录)
  5169. try {
  5170. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_subscription (" +
  5171. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5172. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  5173. "`level` VARCHAR(10) NOT NULL DEFAULT 'L1' COMMENT 'L1=一生一世, L2=久久一生', " +
  5174. "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/expired/cancelled', " +
  5175. "start_time DATETIME DEFAULT NULL COMMENT '开始时间', " +
  5176. "expire_time DATETIME DEFAULT NULL COMMENT '到期时间', " +
  5177. "payment_id BIGINT DEFAULT NULL COMMENT '关联订单ID', " +
  5178. "auto_renew TINYINT(1) DEFAULT 0 COMMENT '自动续费(L2)', " +
  5179. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5180. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5181. "INDEX idx_family_id (family_id), " +
  5182. "INDEX idx_status (status), " +
  5183. "INDEX idx_expire_time (expire_time)" +
  5184. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭订阅记录'");
  5185. log.info("已创建member_subscription表");
  5186. } catch (Exception e) {
  5187. log.warn("创建member_subscription表失败: {}", e.getMessage());
  5188. }
  5189. // 迁移52: 创建 member_subscription_order 表(订阅订单)
  5190. try {
  5191. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_subscription_order (" +
  5192. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5193. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  5194. "`level` VARCHAR(10) NOT NULL COMMENT 'L1/L2', " +
  5195. "amount INT NOT NULL COMMENT '金额(分)', " +
  5196. "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/cancelled/refunded', " +
  5197. "payment_type VARCHAR(20) DEFAULT NULL COMMENT '支付方式 wechat/alipay', " +
  5198. "transaction_id VARCHAR(64) DEFAULT NULL COMMENT '微信支付订单号', " +
  5199. "order_no VARCHAR(64) NOT NULL COMMENT '订单号', " +
  5200. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5201. "paid_at DATETIME DEFAULT NULL, " +
  5202. "UNIQUE KEY uk_order_no (order_no), " +
  5203. "INDEX idx_family_id (family_id), " +
  5204. "INDEX idx_status (status)" +
  5205. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅订单'");
  5206. log.info("已创建member_subscription_order表");
  5207. } catch (Exception e) {
  5208. log.warn("创建member_subscription_order表失败: {}", e.getMessage());
  5209. }
  5210. // 迁移53: 创建 subscription_benefit_log 表(权益使用日志)
  5211. try {
  5212. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS subscription_benefit_log (" +
  5213. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5214. "subscription_id BIGINT NOT NULL COMMENT '订阅ID', " +
  5215. "benefit_code VARCHAR(50) NOT NULL COMMENT '权益代码', " +
  5216. "used_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5217. "detail TEXT COMMENT '使用详情(JSON)', " +
  5218. "INDEX idx_subscription_id (subscription_id)" +
  5219. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权益使用日志'");
  5220. log.info("已创建subscription_benefit_log表");
  5221. } catch (Exception e) {
  5222. log.warn("创建subscription_benefit_log表失败: {}", e.getMessage());
  5223. }
  5224. // 迁移54: 创建 promotion_tier 表(推广等级 R0-R4)
  5225. try {
  5226. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_tier (" +
  5227. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5228. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  5229. "tier VARCHAR(20) NOT NULL DEFAULT 'R0' COMMENT 'R0利他者/R1传福人/R2聚能师/R3启慧者/R4传承者', " +
  5230. "team_size_1st INT DEFAULT 0 COMMENT '一层团队人数', " +
  5231. "team_size_2nd INT DEFAULT 0 COMMENT '二层团队人数', " +
  5232. "team_size_3rd INT DEFAULT 0 COMMENT '三层团队人数', " +
  5233. "total_team_size INT DEFAULT 0 COMMENT '三层累计总人数', " +
  5234. "total_referral_earnings INT DEFAULT 0 COMMENT '累计推荐佣金(分)', " +
  5235. "total_share_earnings INT DEFAULT 0 COMMENT '累计消费返利(分)', " +
  5236. "promoted_at DATETIME DEFAULT NULL COMMENT '最近晋级时间', " +
  5237. "last_change_at DATETIME DEFAULT NULL COMMENT '最后变更时间', " +
  5238. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5239. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5240. "UNIQUE KEY uk_user_id (user_id), " +
  5241. "INDEX idx_tier (tier)" +
  5242. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广等级'");
  5243. log.info("已创建promotion_tier表");
  5244. } catch (Exception e) {
  5245. log.warn("创建promotion_tier表失败: {}", e.getMessage());
  5246. }
  5247. // 迁移55: 创建 promotion_tier_change_log 表(推广等级变更记录)
  5248. try {
  5249. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_tier_change_log (" +
  5250. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5251. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  5252. "old_tier VARCHAR(20) DEFAULT NULL COMMENT '变更前等级', " +
  5253. "new_tier VARCHAR(20) NOT NULL COMMENT '变更后等级', " +
  5254. "reason VARCHAR(50) DEFAULT NULL COMMENT 'qualify升级/demote降级/admin管理员调整', " +
  5255. "changed_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5256. "INDEX idx_user_id (user_id)" +
  5257. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广等级变更记录'");
  5258. log.info("已创建promotion_tier_change_log表");
  5259. } catch (Exception e) {
  5260. log.warn("创建promotion_tier_change_log表失败: {}", e.getMessage());
  5261. }
  5262. // 迁移56: 创建 referral_tree 表(推荐关系树三层)
  5263. try {
  5264. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS referral_tree (" +
  5265. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5266. "parent_id BIGINT NOT NULL COMMENT '推荐人ID', " +
  5267. "child_id BIGINT NOT NULL COMMENT '被推荐人ID', " +
  5268. "level INT NOT NULL COMMENT '层级深度(1/2/3)', " +
  5269. "path VARCHAR(255) DEFAULT NULL COMMENT 'materialized path如/1/2/3/', " +
  5270. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5271. "UNIQUE KEY uk_child_id (child_id), " +
  5272. "INDEX idx_parent_id (parent_id), " +
  5273. "INDEX idx_level (level)" +
  5274. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐关系树'");
  5275. log.info("已创建referral_tree表");
  5276. } catch (Exception e) {
  5277. log.warn("创建referral_tree表失败: {}", e.getMessage());
  5278. }
  5279. // 迁移57: 创建 product_ppoint 表(产品P点配置)
  5280. try {
  5281. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_ppoint (" +
  5282. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5283. "product_id BIGINT NOT NULL COMMENT '产品ID', " +
  5284. "ppoint INT NOT NULL DEFAULT 0 COMMENT 'P点值(平台利润分)', " +
  5285. "start_date DATE DEFAULT NULL COMMENT '生效日期', " +
  5286. "end_date DATE DEFAULT NULL COMMENT '失效日期', " +
  5287. "created_by BIGINT DEFAULT NULL COMMENT '创建人', " +
  5288. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5289. "INDEX idx_product_id (product_id), " +
  5290. "INDEX idx_dates (start_date, end_date)" +
  5291. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品P点配置'");
  5292. log.info("已创建product_ppoint表");
  5293. } catch (Exception e) {
  5294. log.warn("创建product_ppoint表失败: {}", e.getMessage());
  5295. }
  5296. // 迁移58: 创建 assessment_products 表(测评商品扩展信息)
  5297. try {
  5298. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_products (" +
  5299. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5300. "product_id BIGINT NOT NULL COMMENT '关联products表', " +
  5301. "assessment_type VARCHAR(20) NOT NULL COMMENT 'single=单包, bundle=套餐', " +
  5302. "total_sessions INT NOT NULL DEFAULT 1 COMMENT '包含测评次数', " +
  5303. "validity_days INT NOT NULL DEFAULT 365 COMMENT '有效期(天)', " +
  5304. "guide_scope VARCHAR(50) DEFAULT 'all' COMMENT '规划师范围: all/assigned/auto', " +
  5305. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5306. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5307. "INDEX idx_product_id (product_id)" +
  5308. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评商品扩展信息'");
  5309. log.info("已创建assessment_products表");
  5310. } catch (Exception e) {
  5311. log.warn("创建assessment_products表失败: {}", e.getMessage());
  5312. }
  5313. // 迁移59: 创建 assessment_quotas 表(测评额度)
  5314. try {
  5315. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_quotas (" +
  5316. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5317. "quota_no VARCHAR(32) NOT NULL COMMENT '额度编号', " +
  5318. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  5319. "child_id BIGINT COMMENT '指定孩子ID(NULL=不限)', " +
  5320. "product_order_id BIGINT NOT NULL COMMENT '来源订单ID', " +
  5321. "product_id BIGINT NOT NULL COMMENT '商品ID', " +
  5322. "product_name VARCHAR(100) NOT NULL COMMENT '商品名快照', " +
  5323. "total_sessions INT NOT NULL DEFAULT 1 COMMENT '总次数', " +
  5324. "remaining_sessions INT NOT NULL DEFAULT 1 COMMENT '剩余次数', " +
  5325. "validity_start DATETIME NOT NULL COMMENT '有效期开始', " +
  5326. "validity_end DATETIME NOT NULL COMMENT '有效期截止', " +
  5327. "status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active/expired/used_all/cancelled', " +
  5328. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5329. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5330. "INDEX idx_family (family_id), " +
  5331. "INDEX idx_order (product_order_id), " +
  5332. "UNIQUE KEY uk_quota_no (quota_no)" +
  5333. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评额度表'");
  5334. log.info("已创建assessment_quotas表");
  5335. } catch (Exception e) {
  5336. log.warn("创建assessment_quotas表失败: {}", e.getMessage());
  5337. }
  5338. // 迁移60: 创建 assessment_executions 表(测评执行记录)
  5339. try {
  5340. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_executions (" +
  5341. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5342. "quota_id BIGINT NOT NULL COMMENT '关联额度ID', " +
  5343. "child_id BIGINT NOT NULL COMMENT '被测评孩子', " +
  5344. "guide_id BIGINT COMMENT '执行规划师ID', " +
  5345. "assessment_type VARCHAR(30) COMMENT '本次具体测评类型', " +
  5346. "appointment_id BIGINT COMMENT '关联预约ID', " +
  5347. "result_id BIGINT COMMENT '关联测评结果ID', " +
  5348. "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/in_progress/completed/cancelled', " +
  5349. "scheduled_date DATETIME COMMENT '预约执行时间', " +
  5350. "completed_at DATETIME COMMENT '实际完成时间', " +
  5351. "notes VARCHAR(500) COMMENT '备注', " +
  5352. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5353. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5354. "INDEX idx_quota (quota_id), " +
  5355. "INDEX idx_child (child_id), " +
  5356. "INDEX idx_appointment (appointment_id)" +
  5357. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评执行记录表'");
  5358. log.info("已创建assessment_executions表");
  5359. } catch (Exception e) {
  5360. log.warn("创建assessment_executions表失败: {}", e.getMessage());
  5361. }
  5362. // 迁移61: dan_assessment_results表添加source字段(报告来源)
  5363. ensureColumn("dan_assessment_results", "source",
  5364. "VARCHAR(20) DEFAULT 'planner_entry' COMMENT '报告来源: planner_entry/parent_upload/auto_fetch'");
  5365. // 迁移62: 创建 growth_guidances 表(结构化指导意见)
  5366. try {
  5367. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS growth_guidances (" +
  5368. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5369. "result_id BIGINT NOT NULL COMMENT '关联测评结果ID', " +
  5370. "dimension VARCHAR(30) NOT NULL COMMENT '维度: attention/focus/memory/logic/emotion/general', " +
  5371. "score INT COMMENT '该维度得分(0-100)', " +
  5372. "suggestion TEXT NOT NULL COMMENT '建议内容', " +
  5373. "priority INT DEFAULT 0 COMMENT '优先级(0=普通, 1=重要, 2=紧急)', " +
  5374. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  5375. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5376. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5377. "INDEX idx_result (result_id)" +
  5378. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评指导意见(结构化)'");
  5379. log.info("已创建growth_guidances表");
  5380. } catch (Exception e) {
  5381. log.warn("创建growth_guidances表失败: {}", e.getMessage());
  5382. }
  5383. // 迁移63: 创建 assessment_plan_rules 表(方案生成规则)
  5384. try {
  5385. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS assessment_plan_rules (" +
  5386. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5387. "dimension VARCHAR(30) NOT NULL COMMENT '测评维度: attention/focus/memory/logic/emotion', " +
  5388. "score_min INT NOT NULL COMMENT '分数下限(含)', " +
  5389. "score_max INT NOT NULL COMMENT '分数上限(含)', " +
  5390. "template_id BIGINT NOT NULL COMMENT '关联TaskTemplatePackage ID', " +
  5391. "priority INT DEFAULT 0 COMMENT '优先级', " +
  5392. "is_active TINYINT(1) DEFAULT 1 COMMENT '是否启用', " +
  5393. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5394. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5395. "INDEX idx_dimension (dimension), " +
  5396. "INDEX idx_score (score_min, score_max)" +
  5397. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='方案生成规则'");
  5398. log.info("已创建assessment_plan_rules表");
  5399. } catch (Exception e) {
  5400. log.warn("创建assessment_plan_rules表失败: {}", e.getMessage());
  5401. }
  5402. // 迁移64: task_plan_instances表添加source字段(方案来源)
  5403. ensureColumn("task_plan_instances", "source",
  5404. "VARCHAR(20) DEFAULT 'manual' COMMENT '方案来源: manual/assessment/template'");
  5405. // 迁移65: task_plan_instances表添加source_result_id和remark字段
  5406. ensureColumn("task_plan_instances", "source_result_id",
  5407. "BIGINT COMMENT '关联测评结果ID(来源为assessment时)'");
  5408. ensureColumn("task_plan_instances", "remark",
  5409. "TEXT COMMENT '方案备注(含指导意见摘要)'");
  5410. // 迁移66: task_plan_instances表添加审核确认字段(阶段五)
  5411. ensureColumn("task_plan_instances", "reviewed_by",
  5412. "BIGINT COMMENT '审核人(规划师ID)'");
  5413. ensureColumn("task_plan_instances", "reviewed_at",
  5414. "DATETIME COMMENT '审核时间'");
  5415. ensureColumn("task_plan_instances", "review_comment",
  5416. "VARCHAR(500) COMMENT '审核意见'");
  5417. ensureColumn("task_plan_instances", "confirmed_by",
  5418. "BIGINT COMMENT '确认人(家长ID)'");
  5419. ensureColumn("task_plan_instances", "confirmed_at",
  5420. "DATETIME COMMENT '确认时间'");
  5421. ensureColumn("task_plan_instances", "activated_at",
  5422. "DATETIME COMMENT '激活时间'");
  5423. // 迁移67: family_access_logs 表(G7 familyId 越权访问日志,可选,用于安全审计)
  5424. try {
  5425. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_access_logs (" +
  5426. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5427. "user_id BIGINT NOT NULL COMMENT '访问方用户ID', " +
  5428. "family_id BIGINT NOT NULL COMMENT '被访问的家庭ID', " +
  5429. "request_uri VARCHAR(500) NOT NULL COMMENT '请求路径', " +
  5430. "request_method VARCHAR(10) NOT NULL, " +
  5431. "access_type VARCHAR(20) NOT NULL COMMENT 'check_type: query/path/body', " +
  5432. "result VARCHAR(10) NOT NULL COMMENT 'allowed/denied/log_only', " +
  5433. "client_ip VARCHAR(50), " +
  5434. "user_agent VARCHAR(500), " +
  5435. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5436. "INDEX idx_user_time (user_id, created_at), " +
  5437. "INDEX idx_family_time (family_id, created_at)" +
  5438. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭访问安全日志'");
  5439. log.info("已创建family_access_logs表");
  5440. } catch (Exception e) {
  5441. log.warn("创建family_access_logs表失败: {}", e.getMessage());
  5442. }
  5443. // 迁移68: 创建 five_dimension_scores 表(G1 五维能量分数持久化,记录每次计算的维度分数快照)
  5444. try {
  5445. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS five_dimension_scores (" +
  5446. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5447. "member_id BIGINT NOT NULL COMMENT '成员ID(user或family_member)', " +
  5448. "member_type VARCHAR(20) NOT NULL COMMENT '成员类型: user/member', " +
  5449. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  5450. "dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth', " +
  5451. "score INT NOT NULL COMMENT '分数(0-100)', " +
  5452. "assessed_at DATETIME NOT NULL COMMENT '评估时间', " +
  5453. "source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual', " +
  5454. "source_id BIGINT COMMENT '来源记录ID', " +
  5455. "detail_json TEXT COMMENT '详细数据JSON', " +
  5456. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  5457. "INDEX idx_member_dim (member_id, member_type, dimension_code), " +
  5458. "INDEX idx_family_time (family_id, assessed_at), " +
  5459. "INDEX idx_source (source_type, source_id)" +
  5460. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维能量分数历史记录'");
  5461. log.info("已创建five_dimension_scores表");
  5462. // 迁移69: 创建 promotion_tier_config 表(推广等级R0-R4阈值配置)
  5463. try {
  5464. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_tier_config (" +
  5465. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5466. "tier_code VARCHAR(10) NOT NULL UNIQUE COMMENT '等级编码: R0/R1/R2/R3/R4', " +
  5467. "tier_name VARCHAR(50) NOT NULL COMMENT '等级名称', " +
  5468. "min_team_size_1st INT NOT NULL DEFAULT 0 COMMENT '一级团队人数下限', " +
  5469. "min_team_size_2nd INT NOT NULL DEFAULT 0 COMMENT '二级团队人数下限', " +
  5470. "min_total_referral_earnings INT NOT NULL DEFAULT 0 COMMENT '累计推荐佣金下限(分)', " +
  5471. "min_total_share_earnings INT NOT NULL DEFAULT 0 COMMENT '累计消费返利下限(分)', " +
  5472. "commission_rate_l1 INT NOT NULL DEFAULT 0 COMMENT 'L1佣金比例(%)', " +
  5473. "commission_rate_l2 INT NOT NULL DEFAULT 0 COMMENT 'L2佣金比例(%)', " +
  5474. "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序', " +
  5475. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5476. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  5477. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广等级阈值配置'");
  5478. log.info("已创建promotion_tier_config表");
  5479. } catch (Exception e) {
  5480. log.warn("创建promotion_tier_config表失败: {}", e.getMessage());
  5481. }
  5482. try {
  5483. Integer configCount = jdbcTemplate.queryForObject(
  5484. "SELECT COUNT(*) FROM promotion_tier_config", Integer.class);
  5485. if (configCount == null || configCount == 0) {
  5486. jdbcTemplate.execute("INSERT INTO promotion_tier_config (tier_code, tier_name, min_team_size_1st, min_team_size_2nd, min_total_referral_earnings, min_total_share_earnings, commission_rate_l1, commission_rate_l2, sort_order) VALUES " +
  5487. "('R0', '初始用户', 0, 0, 0, 0, 0, 0, 0), " +
  5488. "('R1', '推广达人', 3, 0, 0, 0, 20, 5, 1), " +
  5489. "('R2', '高级推广', 10, 5, 50000, 20000, 25, 8, 2), " +
  5490. "('R3', '资深推广', 30, 20, 200000, 80000, 30, 10, 3), " +
  5491. "('R4', '首席推广', 100, 50, 500000, 200000, 35, 15, 4)");
  5492. log.info("R0-R4推广等级阈值种子数据已初始化");
  5493. }
  5494. } catch (Exception e) {
  5495. log.warn("初始化推广等级阈值种子数据失败: {}", e.getMessage());
  5496. }
  5497. } catch (Exception e) {
  5498. log.warn("创建five_dimension_scores表失败: {}", e.getMessage());
  5499. }
  5500. // 迁移70: package_orders表添加user_coupon_id列
  5501. try {
  5502. jdbcTemplate.execute("ALTER TABLE package_orders ADD COLUMN user_coupon_id BIGINT COMMENT '使用的用户优惠券ID'");
  5503. log.info("已添加user_coupon_id列到package_orders表");
  5504. } catch (Exception e) {
  5505. log.warn("添加user_coupon_id列失败(可能已存在): {}", e.getMessage());
  5506. }
  5507. // 迁移71: family_invitations表添加max_use_count和used_count列(G9 邀请次数控制)
  5508. ensureColumn("family_invitations", "max_use_count", "INT DEFAULT 1 COMMENT '最大使用次数'");
  5509. ensureColumn("family_invitations", "used_count", "INT DEFAULT 0 COMMENT '已使用次数'");
  5510. // 迁移72: 创建teacher_family_binding_requests表(G10 规划师绑定家庭审批)
  5511. try {
  5512. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS teacher_family_binding_requests (" +
  5513. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5514. "teacher_id BIGINT NOT NULL COMMENT '规划师用户ID', " +
  5515. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  5516. "invitation_token VARCHAR(128) COMMENT '关联邀请码/令牌', " +
  5517. "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/approved/rejected/cancelled', " +
  5518. "requested_at DATETIME COMMENT '申请时间', " +
  5519. "decided_by BIGINT COMMENT '审批人用户ID', " +
  5520. "decided_at DATETIME COMMENT '审批时间', " +
  5521. "reject_reason VARCHAR(200) COMMENT '拒绝原因', " +
  5522. "message VARCHAR(500) COMMENT '申请留言', " +
  5523. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5524. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5525. "UNIQUE KEY uk_teacher_family (teacher_id, family_id), " +
  5526. "INDEX idx_family_status (family_id, status) " +
  5527. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师绑定家庭申请表'");
  5528. log.info("已创建teacher_family_binding_requests表");
  5529. } catch (Exception e) {
  5530. log.warn("创建teacher_family_binding_requests表失败: {}", e.getMessage());
  5531. }
  5532. // 迁移73: 创建chat_conversations表(G14 Dify会话本地镜像)
  5533. try {
  5534. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS chat_conversations (" +
  5535. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5536. "user_id BIGINT NOT NULL, " +
  5537. "dify_conversation_id VARCHAR(64), " +
  5538. "assistant_type VARCHAR(32) COMMENT 'family/nutrition/tongue', " +
  5539. "title VARCHAR(200), " +
  5540. "last_message_at DATETIME, " +
  5541. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5542. "UNIQUE KEY uk_dify_conv (dify_conversation_id), " +
  5543. "INDEX idx_user (user_id) " +
  5544. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  5545. log.info("已创建chat_conversations表");
  5546. } catch (Exception e) {
  5547. log.warn("创建chat_conversations表失败: {}", e.getMessage());
  5548. }
  5549. // 迁移74: 创建chat_messages表(G14 Dify会话本地镜像)
  5550. try {
  5551. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS chat_messages (" +
  5552. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5553. "conversation_id BIGINT NOT NULL, " +
  5554. "dify_message_id VARCHAR(64), " +
  5555. "role VARCHAR(20) NOT NULL COMMENT 'user/assistant', " +
  5556. "content TEXT NOT NULL, " +
  5557. "inputs_json TEXT, " +
  5558. "metadata TEXT, " +
  5559. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5560. "INDEX idx_conv (conversation_id) " +
  5561. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  5562. log.info("已创建chat_messages表");
  5563. } catch (Exception e) {
  5564. log.warn("创建chat_messages表失败: {}", e.getMessage());
  5565. }
  5566. // 迁移75: 创建schema_versions表(G23 迁移版本追踪)
  5567. try {
  5568. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS schema_versions (" +
  5569. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5570. "version_code VARCHAR(64) NOT NULL UNIQUE, " +
  5571. "description VARCHAR(500), " +
  5572. "checksum VARCHAR(64), " +
  5573. "applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5574. "applied_by VARCHAR(100) " +
  5575. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='迁移版本追踪表'");
  5576. log.info("已创建schema_versions表");
  5577. } catch (Exception e) {
  5578. log.warn("创建schema_versions表失败: {}", e.getMessage());
  5579. }
  5580. // 迁移76: DanAssessmentResult添加structured_analysis字段(G3)
  5581. ensureColumn("dan_assessment_results", "structured_analysis", "TEXT COMMENT '结构化分析JSON'");
  5582. // 迁移77: 创建 pending_refund 表(待退款记录)
  5583. try {
  5584. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pending_refund (" +
  5585. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5586. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  5587. "withdrawal_request_id BIGINT COMMENT '提现申请ID', " +
  5588. "amount INT NOT NULL COMMENT '退款金额(分)', " +
  5589. "attempt_count INT DEFAULT 0 COMMENT '当前尝试次数', " +
  5590. "max_attempts INT DEFAULT 10 COMMENT '最大重试次数', " +
  5591. "next_retry_time DATETIME COMMENT '下次重试时间', " +
  5592. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/processing/success/failed', " +
  5593. "failure_reason VARCHAR(500) COMMENT '失败原因', " +
  5594. "refunded_amount INT COMMENT '实际退款金额(分)', " +
  5595. "refunded_at DATETIME COMMENT '退款时间', " +
  5596. "remark VARCHAR(500) COMMENT '备注', " +
  5597. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5598. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5599. "INDEX idx_user_id (user_id), " +
  5600. "INDEX idx_status (status), " +
  5601. "INDEX idx_next_retry (next_retry_time), " +
  5602. "INDEX idx_withdrawal_request (withdrawal_request_id)" +
  5603. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待退款记录表'");
  5604. log.info("已创建pending_refund表");
  5605. } catch (Exception e) {
  5606. log.warn("创建pending_refund表失败: {}", e.getMessage());
  5607. }
  5608. // 迁移78: product_orders添加consignee_id字段(OrderTimeoutCancelTask查询需要)
  5609. ensureColumn("product_orders", "consignee_id", "BIGINT COMMENT '收货人ID'");
  5610. // 迁移79: pending_refund添加order_type/order_no字段(商品退款走待退款队列)
  5611. ensureColumn("pending_refund", "order_type", "VARCHAR(20) DEFAULT 'withdrawal' COMMENT '退款类型: withdrawal/product'");
  5612. ensureColumn("pending_refund", "order_no", "VARCHAR(64) COMMENT '商品订单号'");
  5613. try {
  5614. jdbcTemplate.execute("ALTER TABLE pending_refund ADD INDEX idx_order_type (order_type)");
  5615. } catch (Exception e) {
  5616. }
  5617. try {
  5618. jdbcTemplate.execute("ALTER TABLE pending_refund ADD INDEX idx_order_no (order_no)");
  5619. } catch (Exception e) {
  5620. }
  5621. // 迁移80: dan_assessment_results添加dimension字段(区分mind/wisdom报告上传)
  5622. ensureColumn("dan_assessment_results", "dimension",
  5623. "VARCHAR(10) DEFAULT 'mind' COMMENT '报告维度: mind(心-A2)/wisdom(智-B4)'");
  5624. ensureColumn("dan_assessment_results", "report_file_id", "VARCHAR(100) COMMENT '上传文件媒资ID'");
  5625. ensureColumn("dan_assessment_results", "draft_status",
  5626. "VARCHAR(10) DEFAULT 'pending' COMMENT '草稿状态: pending(待确认)/confirmed(已确认)'");
  5627. // 迁移81: activities添加dimension_weights JSON列(五维权重百分比)
  5628. ensureColumn("activities", "dimension_weights",
  5629. "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
  5630. ensureColumn("products", "dimension_weights",
  5631. "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
  5632. ensureColumn("articles", "dimension_weights",
  5633. "JSON DEFAULT NULL COMMENT '{\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
  5634. // 迁移82: 存量数据回填 — 根据dimension_code/domain/relatedDimensions初始化dimension_weights
  5635. try {
  5636. // 回填activities: 已有dimension_code的记录,设对应维度为100
  5637. int actCount = jdbcTemplate.queryForObject(
  5638. "SELECT COUNT(*) FROM activities WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NULL",
  5639. Integer.class);
  5640. if (actCount > 0) {
  5641. jdbcTemplate.update(
  5642. "UPDATE activities SET dimension_weights = " +
  5643. "JSON_OBJECT(" +
  5644. " 'body', IF(dimension_code='body',100,0)," +
  5645. " 'mind', IF(dimension_code='mind',100,0)," +
  5646. " 'wisdom', IF(dimension_code='wisdom',100,0)," +
  5647. " 'action', IF(dimension_code='action',100,0)," +
  5648. " 'wealth', IF(dimension_code='wealth',100,0)" +
  5649. ") WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NULL");
  5650. log.info("已回填{}条activities的dimension_weights", actCount);
  5651. }
  5652. } catch (Exception e) {
  5653. log.warn("回填activities.dimension_weights失败: {}", e.getMessage());
  5654. }
  5655. try {
  5656. // 回填products: 已有domain的记录,设对应维度为100
  5657. int prodCount = jdbcTemplate.queryForObject(
  5658. "SELECT COUNT(*) FROM products WHERE domain IS NOT NULL AND domain != '' AND dimension_weights IS NULL",
  5659. Integer.class);
  5660. if (prodCount > 0) {
  5661. jdbcTemplate.update(
  5662. "UPDATE products SET dimension_weights = " +
  5663. "JSON_OBJECT(" +
  5664. " 'body', IF(domain='body',100,0)," +
  5665. " 'mind', IF(domain='mind',100,0)," +
  5666. " 'wisdom', IF(domain='wisdom',100,0)," +
  5667. " 'action', IF(domain='action',100,0)," +
  5668. " 'wealth', IF(domain='wealth',100,0)" +
  5669. ") WHERE domain IS NOT NULL AND domain != '' AND dimension_weights IS NULL");
  5670. log.info("已回填{}条products的dimension_weights", prodCount);
  5671. }
  5672. } catch (Exception e) {
  5673. log.warn("回填products.dimension_weights失败: {}", e.getMessage());
  5674. }
  5675. try {
  5676. // 回填articles: 已有relatedDimensions的记录,尝试解析维度名并设权重
  5677. int artCount = jdbcTemplate.queryForObject(
  5678. "SELECT COUNT(*) FROM articles WHERE related_dimensions IS NOT NULL AND related_dimensions != '' AND dimension_weights IS NULL",
  5679. Integer.class);
  5680. if (artCount > 0) {
  5681. // 用MySQL函数解析relatedDimensions中的维度关键词
  5682. jdbcTemplate.update("UPDATE articles SET dimension_weights = JSON_OBJECT(" +
  5683. "'body', IF(INSTR(COALESCE(related_dimensions,''),'body')>0 OR INSTR(COALESCE(related_dimensions,''),'身')>0, 100, 0)," +
  5684. "'mind', IF(INSTR(COALESCE(related_dimensions,''),'mind')>0 OR INSTR(COALESCE(related_dimensions,''),'心')>0, 100, 0)," +
  5685. "'wisdom', IF(INSTR(COALESCE(related_dimensions,''),'wisdom')>0 OR INSTR(COALESCE(related_dimensions,''),'智')>0, 100, 0)," +
  5686. "'action', IF(INSTR(COALESCE(related_dimensions,''),'action')>0 OR INSTR(COALESCE(related_dimensions,''),'行')>0, 100, 0)," +
  5687. "'wealth', IF(INSTR(COALESCE(related_dimensions,''),'wealth')>0 OR INSTR(COALESCE(related_dimensions,''),'富')>0, 100, 0)" +
  5688. ") WHERE related_dimensions IS NOT NULL AND related_dimensions != '' AND dimension_weights IS NULL");
  5689. log.info("已回填{}条articles的dimension_weights", artCount);
  5690. }
  5691. } catch (Exception e) {
  5692. log.warn("回填articles.dimension_weights失败: {}", e.getMessage());
  5693. }
  5694. // 迁移83: 创建dimension_weights表(管理端保存维度权重使用)
  5695. try {
  5696. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dimension_weights (" +
  5697. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  5698. "target_type VARCHAR(20) NOT NULL COMMENT '目标类型: activity/product/article'," +
  5699. "target_id BIGINT NOT NULL COMMENT '目标ID'," +
  5700. "dimension VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth'," +
  5701. "weight INT DEFAULT 0 COMMENT '权重百分比'," +
  5702. "enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用'," +
  5703. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  5704. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  5705. "INDEX idx_target (target_type, target_id)" +
  5706. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度权重表'");
  5707. log.info("已创建dimension_weights表");
  5708. } catch (Exception e) {
  5709. log.warn("创建dimension_weights表失败: {}", e.getMessage());
  5710. }
  5711. // 迁移84: cart表添加skuId和specOptionIds字段(支持SKU选择)
  5712. ensureColumn("cart", "sku_id", "BIGINT COMMENT '选中的SKU ID'");
  5713. ensureColumn("cart", "spec_option_ids", "VARCHAR(500) COMMENT '选中的规格选项IDs JSON'");
  5714. // 迁移86: product_orders表添加purchase_info字段(购买信息快照JSON,OrderTimeoutCancelTask查询需要)
  5715. ensureColumn("product_orders", "purchase_info", "TEXT COMMENT '购买信息JSON: {id_card, hand_signature, ...}'");
  5716. // 迁移87: articles表添加content_type/difficulty_level/dimension_ids/dimension_weights字段
  5717. ensureColumn("articles", "content_type", "VARCHAR(20) DEFAULT 'text' COMMENT '内容格式:text/html/markdown'");
  5718. ensureColumn("articles", "difficulty_level", "INT DEFAULT 0 COMMENT '难度等级:1-5'");
  5719. ensureColumn("articles", "dimension_ids", "VARCHAR(100) DEFAULT '' COMMENT '关联维度IDs JSON'");
  5720. ensureColumn("articles", "dimension_weights", "TEXT COMMENT '五维能量权重JSON'");
  5721. // 迁移88: activities表添加dimension_weights列(权重感知活动查询)
  5722. ensureColumn("activities", "dimension_weights", "TEXT COMMENT '五维能量权重JSON'");
  5723. // 迁移89: products表添加dimension_weights列(权重感知商品查询)
  5724. ensureColumn("products", "dimension_weights", "TEXT COMMENT '五维能量权重JSON'");
  5725. // 迁移85: 创建社交圈子表(珍珠图/圈子系统)
  5726. try {
  5727. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle (" +
  5728. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5729. "name VARCHAR(100) NOT NULL COMMENT '圈子名称', " +
  5730. "type VARCHAR(50) NOT NULL COMMENT '类型: topic/activity/hobby/ability/product/health/provider', " +
  5731. "match_source VARCHAR(50) NOT NULL COMMENT '匹配源', " +
  5732. "source_id BIGINT COMMENT '匹配源ID', " +
  5733. "member_count INT DEFAULT 0 COMMENT '成员数', " +
  5734. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
  5735. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社交圈/珍珠'");
  5736. log.info("已创建social_circle表");
  5737. } catch (Exception e) { /* 表已存在忽略 */ }
  5738. try {
  5739. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_member (" +
  5740. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5741. "circle_id BIGINT NOT NULL, " +
  5742. "member_id BIGINT NOT NULL COMMENT '用户ID', " +
  5743. "member_type VARCHAR(20) NOT NULL COMMENT 'parent/child', " +
  5744. "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5745. "UNIQUE KEY uk_circle_member (circle_id, member_id)" +
  5746. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子成员'");
  5747. log.info("已创建social_circle_member表");
  5748. } catch (Exception e) { /* 表已存在忽略 */ }
  5749. // 迁移96: 创建活动订单评价表
  5750. try {
  5751. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
  5752. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5753. "order_id BIGINT NOT NULL COMMENT '订单ID', " +
  5754. "reviewer_id BIGINT NOT NULL COMMENT '评价人ID', " +
  5755. "rating INT COMMENT '总体评分', " +
  5756. "review_text TEXT COMMENT '评价内容', " +
  5757. "review_at DATETIME COMMENT '评价时间', " +
  5758. "organization_rating INT COMMENT '活动组织评分(0-5)', " +
  5759. "content_rating INT COMMENT '活动内容评分(0-5)', " +
  5760. "experience_rating INT COMMENT '活动体验评分(0-5)', " +
  5761. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5762. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  5763. "UNIQUE KEY uk_order_id (order_id), " +
  5764. "INDEX idx_reviewer_id (reviewer_id)" +
  5765. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动订单评价表'");
  5766. log.info("已创建review_orders表");
  5767. } catch (Exception e) {
  5768. log.error("迁移96 失败: " + e.getMessage());
  5769. }
  5770. // 迁移97: growth_task表扩展 — 支持AI_GENERATED类型 + 五维维度/来源会话字段
  5771. try {
  5772. jdbcTemplate.execute("ALTER TABLE growth_task MODIFY COLUMN type "
  5773. + "VARCHAR(16) NOT NULL COMMENT 'DAILY/NEWBIE/AI_GENERATED'");
  5774. log.info("已扩展growth_task.type为支持AI_GENERATED");
  5775. } catch (Exception e) {
  5776. log.warn("扩展growth_task.type失败(可能已经扩展): " + e.getMessage());
  5777. }
  5778. ensureColumn("growth_task", "dimension", "VARCHAR(20) NULL COMMENT '五维维度(body/mind/wisdom/action/wealth)'");
  5779. ensureColumn("growth_task", "source_conversation_id", "VARCHAR(100) NULL COMMENT '来源AI会话ID'");
  5780. log.info("迁移97: growth_task 表扩展完成");
  5781. // 迁移98: health_gut_flora 表扩展 level 字段长度(支持 CLASS/ORDER/FAMILY)
  5782. try {
  5783. jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN level VARCHAR(20) COMMENT '级别: CLASS/ORDER/FAMILY/GENUS/SPECIES'");
  5784. log.info("已扩展health_gut_flora.level字段长度");
  5785. } catch (Exception e) {
  5786. log.warn("扩展health_gut_flora.level失败: {}", e.getMessage());
  5787. }
  5788. log.info("迁移 98: health_gut_flora 表扩展完成");
  5789. // 迁移 100: health_gut_flora 表扩展 population_level 和 detection_rate 字段长度(支持更长百分比)
  5790. try {
  5791. jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN population_level VARCHAR(100) DEFAULT NULL COMMENT '处于人群%水平'");
  5792. log.info("已扩展 health_gut_flora.population_level 字段长度至 VARCHAR(100)");
  5793. } catch (Exception e) {
  5794. log.warn("扩展 health_gut_flora.population_level 失败:{}", e.getMessage());
  5795. }
  5796. try {
  5797. jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN detection_rate VARCHAR(100) DEFAULT NULL COMMENT '%正常人有检出'");
  5798. log.info("已扩展 health_gut_flora.detection_rate 字段长度至 VARCHAR(100)");
  5799. } catch (Exception e) {
  5800. log.warn("扩展 health_gut_flora.detection_rate 失败:{}", e.getMessage());
  5801. }
  5802. log.info("迁移 100: health_gut_flora 表扩展完成");
  5803. // 迁移99: users.role 从 ENUM 改为 VARCHAR(20)(支持动态角色值)
  5804. try {
  5805. jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role VARCHAR(20) NOT NULL COMMENT '角色: parent/child/teacher'");
  5806. log.info("已修改users.role为VARCHAR(20)");
  5807. } catch (Exception e) {
  5808. log.warn("修改users.role失败(可能已修改): " + e.getMessage());
  5809. }
  5810. // 迁移100: assessment_appointments表添加assessor_id(测评师角色分离)
  5811. ensureColumn("assessment_appointments", "assessor_id",
  5812. "BIGINT COMMENT '测评师ID(供应商体系), 替代guideId语义'");
  5813. // 迁移100: dan_assessment_executions表添加appointment_id
  5814. ensureColumn("dan_assessment_executions", "appointment_id",
  5815. "BIGINT COMMENT '关联assessment_appointments.id'");
  5816. log.info("迁移100: 已添加assessor_id和appointment_id字段");
  5817. // 迁移101: 创建 micro_actions 表(12个预定义1秒微行动)
  5818. try {
  5819. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_actions (" +
  5820. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5821. "code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...', " +
  5822. "name VARCHAR(50) NOT NULL COMMENT '行动名称', " +
  5823. "icon VARCHAR(10) COMMENT '图标emoji', " +
  5824. "description VARCHAR(200) COMMENT '行动描述', " +
  5825. "sort_order INT DEFAULT 0 COMMENT '排序号', " +
  5826. "status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用', " +
  5827. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5828. "UNIQUE KEY uk_code (code), " +
  5829. "INDEX idx_status (status)" +
  5830. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)'");
  5831. log.info("已创建micro_actions表");
  5832. // 插入12个预定义微行动
  5833. jdbcTemplate.execute("INSERT IGNORE INTO micro_actions (code, name, icon, description, sort_order) VALUES " +
  5834. "('DRINK_WATER', '喝一杯水', '☕️', '喝一杯温开水,补充水分', 1), " +
  5835. "('DEEP_BREATH', '深呼吸3次', '🫁', '深吸慢呼3次,放松身心', 2), " +
  5836. "('STRETCH', '站起来伸懒腰', '🧘', '站起来伸个懒腰,活动筋骨', 3), " +
  5837. "('LOOK_FAR', '看远处10秒', '🌿', '看远处绿色植物10秒,缓解眼疲劳', 4), " +
  5838. "('SMILE', '微笑一下', '😊', '对自己微笑,心情会变好', 5), " +
  5839. "('NECK_ROLL', '转转头颈5圈', '🔄', '缓慢转动头颈5圈,放松颈椎', 6), " +
  5840. "('FIST_RELAX', '握拳放松3次', '✊', '用力握拳3秒后放松,重复3次', 7), " +
  5841. "('SIP_WATER', '喝口水润喉', '💧', '小口喝水润润喉咙', 8), " +
  5842. "('BLINK', '眨眨眼5次', '👁️', '快速眨眼5次,湿润眼球', 9), " +
  5843. "('ACUPRESSURE', '按揉合谷穴3下', '🖐️', '按揉虎口合谷穴3下,提神醒脑', 10), " +
  5844. "('TIPTOE', '踮脚尖5次', '⬆️', '踮起脚尖再放下,重复5次', 11), " +
  5845. "('SHRUG', '耸肩放松3次', '↕️', '耸肩至耳朵再放松,重复3次', 12)");
  5846. log.info("已初始化12个微行动");
  5847. } catch (Exception e) {
  5848. log.warn("创建micro_actions表或初始化数据失败: {}", e.getMessage());
  5849. }
  5850. // 迁移102: 创建 micro_action_records 表(微行动完成记录)
  5851. try {
  5852. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_action_records (" +
  5853. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5854. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  5855. "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  5856. "action_id BIGINT NOT NULL COMMENT '行动ID', " +
  5857. "completed_date DATETIME NOT NULL COMMENT '完成日期', " +
  5858. "earned_energy INT DEFAULT 1 COMMENT '获得能量值', " +
  5859. "note VARCHAR(200) COMMENT '备注', " +
  5860. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5861. "INDEX idx_child_date (child_id, completed_date), " +
  5862. "INDEX idx_user_id (user_id)" +
  5863. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录'");
  5864. log.info("已创建micro_action_records表");
  5865. } catch (Exception e) {
  5866. log.warn("创建micro_action_records表失败: {}", e.getMessage());
  5867. }
  5868. // 迁移103: 创建 daily_feedback 表(AI/预设每日健康反馈)
  5869. try {
  5870. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS daily_feedback (" +
  5871. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5872. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  5873. "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  5874. "feedback_date DATETIME NOT NULL COMMENT '反馈日期', " +
  5875. "feedback_type VARCHAR(20) DEFAULT 'daily' COMMENT '反馈类型: daily/weekly/milestone', " +
  5876. "content VARCHAR(500) NOT NULL COMMENT 'AI生成的反馈文本', " +
  5877. "milestone_event VARCHAR(100) COMMENT '关联的里程碑事件', " +
  5878. "is_read TINYINT DEFAULT 0 COMMENT '是否已读: 0未读 1已读', " +
  5879. "source VARCHAR(20) DEFAULT 'preset' COMMENT '来源: ai_generated / preset', " +
  5880. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5881. "INDEX idx_child_date (child_id, feedback_date), " +
  5882. "INDEX idx_feedback_type (feedback_type)" +
  5883. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康反馈'");
  5884. log.info("已创建daily_feedback表");
  5885. } catch (Exception e) {
  5886. log.warn("创建daily_feedback表失败: {}", e.getMessage());
  5887. }
  5888. // 迁移104: 创建 health_alerts 表(健康行为智能预警)
  5889. try {
  5890. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_alerts (" +
  5891. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5892. "user_id BIGINT COMMENT '用户ID', " +
  5893. "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  5894. "rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006', " +
  5895. "alert_text VARCHAR(200) NOT NULL COMMENT '预警文案', " +
  5896. "action_suggestion VARCHAR(200) COMMENT '行动建议', " +
  5897. "severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low', " +
  5898. "dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭', " +
  5899. "dismissed_at DATETIME COMMENT '关闭时间', " +
  5900. "alert_date DATETIME NOT NULL COMMENT '预警日期', " +
  5901. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5902. "INDEX idx_child_date (child_id, alert_date), " +
  5903. "INDEX idx_rule (rule_code)" +
  5904. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录'");
  5905. log.info("已创建health_alerts表");
  5906. } catch (Exception e) {
  5907. log.warn("创建health_alerts表失败: {}", e.getMessage());
  5908. }
  5909. // 迁移105: 创建 health_level 表(身份重塑等级定义)
  5910. try {
  5911. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_level (" +
  5912. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5913. "level_num INT NOT NULL COMMENT '等级编号 1-5', " +
  5914. "title VARCHAR(50) NOT NULL COMMENT '称号', " +
  5915. "badge_icon VARCHAR(20) COMMENT '徽章图标', " +
  5916. "min_score INT DEFAULT 0 COMMENT '所需最低健康积分', " +
  5917. "max_score INT DEFAULT 99999 COMMENT '最高积分上限'" +
  5918. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康等级定义'");
  5919. log.info("已创建health_level表");
  5920. } catch (Exception e) {
  5921. log.warn("创建health_level表失败: {}", e.getMessage());
  5922. }
  5923. // 迁移106: 创建 health_timeline_event 表(健康故事时间线)
  5924. try {
  5925. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_timeline_event (" +
  5926. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5927. "user_id BIGINT COMMENT '用户ID', " +
  5928. "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  5929. "event_type VARCHAR(30) NOT NULL COMMENT '事件类型: streak_7/level_2/first_report', " +
  5930. "event_text VARCHAR(200) NOT NULL COMMENT '事件描述', " +
  5931. "ref_id VARCHAR(50) COMMENT '关联ID', " +
  5932. "event_date DATETIME NOT NULL COMMENT '事件日期', " +
  5933. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  5934. "INDEX idx_child_date (child_id, event_date), " +
  5935. "INDEX idx_event_type (event_type)" +
  5936. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康时间线事件'");
  5937. log.info("已创建health_timeline_event表");
  5938. } catch (Exception e) {
  5939. log.warn("创建health_timeline_event表失败: {}", e.getMessage());
  5940. }
  5941. // 迁移107: 创建 family_challenge 表(P2-2家庭挑战)
  5942. try {
  5943. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge (" +
  5944. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5945. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  5946. "challenge_type VARCHAR(50) NOT NULL COMMENT 'full_checkin/sports/health_week', " +
  5947. "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " +
  5948. "description TEXT COMMENT '挑战描述', " +
  5949. "duration_days INT DEFAULT 7 COMMENT '持续天数', " +
  5950. "reward_points INT DEFAULT 0 COMMENT '奖励积分', " +
  5951. "reward_badge VARCHAR(100) COMMENT '奖励徽章', " +
  5952. "start_date DATE COMMENT '开始日期', " +
  5953. "end_date DATE COMMENT '结束日期', " +
  5954. "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " +
  5955. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  5956. "INDEX idx_family_id (family_id)" +
  5957. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战'");
  5958. log.info("已创建family_challenge表");
  5959. } catch (Exception e) {
  5960. log.warn("创建family_challenge表失败: {}", e.getMessage());
  5961. }
  5962. // 迁移108: 创建 family_challenge_progress 表(P2-2挑战进度)
  5963. try {
  5964. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_progress (" +
  5965. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5966. "challenge_id BIGINT NOT NULL COMMENT '挑战ID', " +
  5967. "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  5968. "progress_value INT DEFAULT 0 COMMENT '进度值', " +
  5969. "completed TINYINT(1) DEFAULT 0 COMMENT '是否完成', " +
  5970. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  5971. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
  5972. "INDEX idx_challenge_id (challenge_id)" +
  5973. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战进度'");
  5974. log.info("已创建family_challenge_progress表");
  5975. } catch (Exception e) {
  5976. log.warn("创建family_challenge_progress表失败: {}", e.getMessage());
  5977. }
  5978. // 迁移109: 创建 health_interaction 表(P2-2点赞记录)
  5979. try {
  5980. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_interaction (" +
  5981. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5982. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  5983. "from_user_id BIGINT NOT NULL COMMENT '点赞用户ID', " +
  5984. "to_user_id BIGINT NOT NULL COMMENT '被赞用户ID', " +
  5985. "action_type VARCHAR(50) NOT NULL COMMENT '行为类型', " +
  5986. "action_id BIGINT COMMENT '关联行为记录ID', " +
  5987. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  5988. "INDEX idx_family_action (family_id, action_type, action_id)" +
  5989. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)'");
  5990. log.info("已创建health_interaction表");
  5991. } catch (Exception e) {
  5992. log.warn("创建health_interaction表失败: {}", e.getMessage());
  5993. }
  5994. // 迁移110: 创建 health_circle 表(P3 健康圈)
  5995. try {
  5996. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle (" +
  5997. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  5998. "name VARCHAR(100) NOT NULL COMMENT '圈名称', " +
  5999. "description VARCHAR(500) COMMENT '圈描述', " +
  6000. "creator_family_id BIGINT COMMENT '创建家庭ID', " +
  6001. "invite_code VARCHAR(32) COMMENT '邀请码', " +
  6002. "member_limit INT DEFAULT 50 COMMENT '成员上限', " +
  6003. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/closed', " +
  6004. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6005. "INDEX idx_invite_code (invite_code), " +
  6006. "INDEX idx_creator (creator_family_id)" +
  6007. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈'");
  6008. log.info("已创建health_circle表");
  6009. } catch (Exception e) {
  6010. log.warn("创建health_circle表失败: {}", e.getMessage());
  6011. }
  6012. // 迁移111: 创建 health_circle_member 表(P3 健康圈成员)
  6013. try {
  6014. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle_member (" +
  6015. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6016. "circle_id BIGINT NOT NULL COMMENT '圈ID', " +
  6017. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  6018. "role VARCHAR(20) DEFAULT 'member' COMMENT '角色 member/admin', " +
  6019. "joined_at DATETIME COMMENT '加入时间', " +
  6020. "left_at DATETIME COMMENT '退出时间', " +
  6021. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/inactive', " +
  6022. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6023. "INDEX idx_circle_id (circle_id), " +
  6024. "INDEX idx_family_id (family_id)" +
  6025. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈成员'");
  6026. log.info("已创建health_circle_member表");
  6027. } catch (Exception e) {
  6028. log.warn("创建health_circle_member表失败: {}", e.getMessage());
  6029. }
  6030. // 迁移112: 创建 circle_challenge 表(P3 圈挑战)
  6031. try {
  6032. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS circle_challenge (" +
  6033. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6034. "circle_id BIGINT NOT NULL COMMENT '圈ID', " +
  6035. "challenge_type VARCHAR(50) NOT NULL COMMENT '挑战类型 checkin/step/meditation', " +
  6036. "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " +
  6037. "description VARCHAR(500) COMMENT '挑战描述', " +
  6038. "duration_days INT DEFAULT 7 COMMENT '持续天数', " +
  6039. "period VARCHAR(20) DEFAULT 'weekly' COMMENT '周期 weekly/monthly', " +
  6040. "start_date DATETIME COMMENT '开始日期', " +
  6041. "end_date DATETIME COMMENT '结束日期', " +
  6042. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/completed/cancelled', " +
  6043. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6044. "INDEX idx_circle_id (circle_id), " +
  6045. "INDEX idx_status (status)" +
  6046. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈挑战'");
  6047. log.info("已创建circle_challenge表");
  6048. } catch (Exception e) {
  6049. log.warn("创建circle_challenge表失败: {}", e.getMessage());
  6050. }
  6051. // 迁移113: 创建 family_commission 表(P2 推广佣金)
  6052. try {
  6053. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_commission (" +
  6054. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6055. "promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID', " +
  6056. "buyer_family_id BIGINT NOT NULL COMMENT '购买方家庭ID', " +
  6057. "order_id BIGINT COMMENT '关联订单ID', " +
  6058. "amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '订单金额', " +
  6059. "commission_rate DECIMAL(5,2) DEFAULT 10.00 COMMENT '佣金比例%', " +
  6060. "commission_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '佣金金额', " +
  6061. "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paid/cancelled', " +
  6062. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6063. "paid_at DATETIME COMMENT '支付时间', " +
  6064. "INDEX idx_promoter (promoter_family_id), " +
  6065. "INDEX idx_buyer (buyer_family_id), " +
  6066. "INDEX idx_order (order_id)" +
  6067. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭推广佣金记录'");
  6068. log.info("已创建family_commission表");
  6069. } catch (Exception e) {
  6070. log.warn("创建family_commission表失败: {}", e.getMessage());
  6071. }
  6072. // 迁移114: 创建 promotion_binding 表(P2 推广绑定)
  6073. try {
  6074. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_binding (" +
  6075. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6076. "promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID', " +
  6077. "invitee_family_id BIGINT NOT NULL COMMENT '被邀请方家庭ID', " +
  6078. "invite_code VARCHAR(32) COMMENT '使用的邀请码', " +
  6079. "binding_type VARCHAR(20) DEFAULT 'direct' COMMENT 'direct/indirect', " +
  6080. "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/inactive', " +
  6081. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6082. "UNIQUE KEY uk_promoter_invitee (promoter_family_id, invitee_family_id), " +
  6083. "INDEX idx_promoter (promoter_family_id), " +
  6084. "INDEX idx_invitee (invitee_family_id)" +
  6085. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广绑定关系'");
  6086. log.info("已创建promotion_binding表");
  6087. } catch (Exception e) {
  6088. log.warn("创建promotion_binding表失败: {}", e.getMessage());
  6089. }
  6090. // 迁移115: 创建 activity_feedback 表(活动评价功能)
  6091. try {
  6092. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_feedback (" +
  6093. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
  6094. "activity_id BIGINT NOT NULL COMMENT '活动ID', " +
  6095. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  6096. "child_id BIGINT COMMENT '孩子ID', " +
  6097. "ratings TEXT COMMENT '评分JSON: {\"组织形式\":5,\"会场气氛\":4}', " +
  6098. "comment TEXT COMMENT '文字评价', " +
  6099. "status TINYINT DEFAULT 0 COMMENT '状态: 0=待审核 1=已发布', " +
  6100. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6101. "INDEX idx_activity_id (activity_id), " +
  6102. "INDEX idx_user_id (user_id), " +
  6103. "INDEX idx_child_id (child_id)" +
  6104. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动评价表'");
  6105. log.info("已创建activity_feedback表");
  6106. } catch (Exception e) {
  6107. // 表已存在,忽略
  6108. }
  6109. // 迁移115b: activity表添加feedback_dimensions字段
  6110. ensureColumn("activities", "feedback_dimensions", "TEXT COMMENT '评价维度列表JSON: [\"组织形式\",\"会场气氛\",\"主持人\",\"专家\"]'");
  6111. // 迁移116: growth_records表添加family_member_id和family_member_age列(修复/record/list 500错误)
  6112. ensureColumn("growth_records", "family_member_id", "BIGINT COMMENT '关联家庭成员ID'");
  6113. ensureColumn("growth_records", "family_member_age", "INT COMMENT '成员年龄快照'");
  6114. // 迁移117: 创建 emotion_checkin 表情绪打卡表(Fix 心理与情绪模块 500 错误)
  6115. try {
  6116. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_checkin (" +
  6117. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6118. "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  6119. "mood_weather VARCHAR(50) COMMENT '心情晴雨表: sunny/cloudy/rainy/stormy', " +
  6120. "emotion_tags VARCHAR(200) COMMENT '情绪标签(逗号分隔)', " +
  6121. "photo_url VARCHAR(500) COMMENT '配图URL', " +
  6122. "note TEXT COMMENT '文字备注', " +
  6123. "energy_awarded INT DEFAULT 0 COMMENT '奖励能量值', " +
  6124. "checkin_date DATE COMMENT '打卡日期', " +
  6125. "mood_score INT COMMENT '心情分数(1-10)', " +
  6126. "emotion_type VARCHAR(50) COMMENT '情绪类型', " +
  6127. "stress_level INT COMMENT '压力等级(1-10)', " +
  6128. "arousal_level INT COMMENT '激活水平(1-10)', " +
  6129. "energy_level INT COMMENT '精力水平(1-10)', " +
  6130. "sleep_hours DECIMAL(4,1) COMMENT '睡眠时长', " +
  6131. "sleep_quality INT COMMENT '睡眠质量(1-10)', " +
  6132. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6133. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6134. "INDEX idx_child_date (child_id, checkin_date)" +
  6135. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪打卡记录'");
  6136. log.info("已创建emotion_checkin表");
  6137. } catch (Exception e) {
  6138. log.warn("创建emotion_checkin表可能已存在: {}", e.getMessage());
  6139. }
  6140. // 迁移118: 创建 emotion_alert 表情绪告警表
  6141. try {
  6142. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_alert (" +
  6143. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6144. "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  6145. "alert_type VARCHAR(50) NOT NULL COMMENT '告警类型: consecutive_low_mood/crisis_keyword/sharp_decline', " +
  6146. "severity VARCHAR(20) NOT NULL DEFAULT 'low' COMMENT '严重级别: low/medium/high', " +
  6147. "title VARCHAR(200) COMMENT '告警标题', " +
  6148. "message TEXT COMMENT '告警具体描述', " +
  6149. "related_checkin_id BIGINT COMMENT '关联情绪打卡ID', " +
  6150. "resolved TINYINT(1) DEFAULT 0 COMMENT '是否已处理', " +
  6151. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6152. "resolved_at DATETIME COMMENT '处理时间', " +
  6153. "INDEX idx_child (child_id), " +
  6154. "INDEX idx_alert_type (alert_type)" +
  6155. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪告警'");
  6156. log.info("已创建emotion_alert表");
  6157. } catch (Exception e) {
  6158. log.warn("创建emotion_alert表可能已存在: {}", e.getMessage());
  6159. }
  6160. // 迁移119: 创建 membership_levels 表(会员等级配置)
  6161. try {
  6162. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_levels (" +
  6163. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6164. "level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/PROFESSIONAL/ENTERPRISE', " +
  6165. "level_name VARCHAR(100) NOT NULL COMMENT '等级名称', " +
  6166. "level_desc TEXT COMMENT '等级描述', " +
  6167. "price_monthly INT COMMENT '月费(分)', " +
  6168. "price_quarterly INT COMMENT '季费(分)', " +
  6169. "price_yearly INT COMMENT '年费(分)', " +
  6170. "features TEXT COMMENT '功能权限列表(JSON)', " +
  6171. "max_children INT DEFAULT 0 COMMENT '可添加孩子数量', " +
  6172. "max_tasks_per_day INT DEFAULT 0 COMMENT '每日任务上限', " +
  6173. "ai_review_enabled TINYINT(1) DEFAULT 0 COMMENT 'AI审核功能', " +
  6174. "priority_support TINYINT(1) DEFAULT 0 COMMENT '优先客服', " +
  6175. "teacher_consultation TINYINT(1) DEFAULT 0 COMMENT '老师咨询', " +
  6176. "level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低)', " +
  6177. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6178. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6179. "UNIQUE KEY uk_level_code (level_code)" +
  6180. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员等级配置'");
  6181. log.info("已创建membership_levels表");
  6182. } catch (Exception e) {
  6183. log.warn("创建membership_levels表可能已存在: {}", e.getMessage());
  6184. }
  6185. // 迁移120: 插入 membership_levels 种子数据
  6186. try {
  6187. jdbcTemplate.execute("INSERT IGNORE INTO membership_levels (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " +
  6188. "('FREE', '免费会员', '基础免费功能', 0, 0, 0, 2, 5, 0, 0, 0), " +
  6189. "('FAMILY', '家庭会员', '家庭会员全部功能', 13140, 32850, 131400, 6, 20, 1, 1, 1), " +
  6190. "('PROVIDER', '标准会员', '标准会员服务', 13140, 32850, 131400, 10, 50, 1, 1, 1)");
  6191. log.info("已插入membership_levels种子数据");
  6192. } catch (Exception e) {
  6193. log.warn("插入membership_levels种子数据可能已存在: {}", e.getMessage());
  6194. }
  6195. // 迁移121: 创建 family_memberships 表(家庭会员订阅记录)
  6196. try {
  6197. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_memberships (" +
  6198. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6199. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  6200. "level_code VARCHAR(50) NOT NULL DEFAULT 'FREE' COMMENT '会员等级编码', " +
  6201. "start_date DATETIME COMMENT '开始时间', " +
  6202. "end_date DATETIME COMMENT '结束时间', " +
  6203. "payment_method VARCHAR(50) COMMENT '支付方式: wechat/alipay/card', " +
  6204. "payment_status VARCHAR(20) DEFAULT 'pending' COMMENT '支付状态: pending/paid/expired/refunded', " +
  6205. "order_no VARCHAR(100) COMMENT '订单号', " +
  6206. "transaction_id VARCHAR(200) COMMENT '交易流水号', " +
  6207. "amount INT COMMENT '支付金额(分)', " +
  6208. "auto_renew TINYINT(1) DEFAULT 0 COMMENT '自动续费', " +
  6209. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6210. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6211. "INDEX idx_family_id (family_id), " +
  6212. "INDEX idx_end_date (end_date)" +
  6213. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭会员订阅'");
  6214. log.info("已创建family_memberships表");
  6215. } catch (Exception e) {
  6216. log.warn("创建family_memberships表可能已存在: {}", e.getMessage());
  6217. }
  6218. // 迁移121: 创建 share_events 表(分享事件记录)
  6219. try {
  6220. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS share_events (" +
  6221. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6222. "user_id BIGINT NOT NULL COMMENT '分享用户ID', " +
  6223. "target_type VARCHAR(50) NOT NULL COMMENT '分享目标类型: article/growth/product', " +
  6224. "target_id BIGINT NOT NULL COMMENT '分享目标ID', " +
  6225. "share_channel VARCHAR(50) COMMENT '分享渠道: wechat/timeline/qq/url', " +
  6226. "share_title VARCHAR(200) COMMENT '分享标题', " +
  6227. "share_url VARCHAR(500) COMMENT '分享URL', " +
  6228. "click_count INT DEFAULT 0 COMMENT '点击次数', " +
  6229. "read_count INT DEFAULT 0 COMMENT '阅读次数', " +
  6230. "reward_points INT DEFAULT 0 COMMENT '奖励积分', " +
  6231. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/expired', " +
  6232. "expires_at DATETIME COMMENT '过期时间', " +
  6233. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6234. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6235. "INDEX idx_user (user_id), " +
  6236. "INDEX idx_target (target_type, target_id)" +
  6237. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享事件'");
  6238. log.info("已创建share_events表");
  6239. } catch (Exception e) {
  6240. log.warn("创建share_events表可能已存在: {}", e.getMessage());
  6241. }
  6242. // 迁移122: 创建 share_reads 表(分享阅读记录)
  6243. try {
  6244. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS share_reads (" +
  6245. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6246. "share_event_id BIGINT NOT NULL COMMENT '分享事件ID', " +
  6247. "reader_id BIGINT COMMENT '阅读者用户ID', " +
  6248. "reader_ip VARCHAR(50) COMMENT '阅读者IP', " +
  6249. "read_duration INT COMMENT '阅读时长(秒)', " +
  6250. "read_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '阅读时间', " +
  6251. "INDEX idx_share_event (share_event_id), " +
  6252. "INDEX idx_reader (reader_id)" +
  6253. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享阅读记录'");
  6254. log.info("已创建share_reads表");
  6255. } catch (Exception e) {
  6256. log.warn("创建share_reads表可能已存在: {}", e.getMessage());
  6257. }
  6258. // 迁移123: 创建 food_recommend_idx 表(饮食推荐指数)
  6259. try {
  6260. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS food_recommend_idx (" +
  6261. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6262. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  6263. "food_id BIGINT NOT NULL COMMENT '食材ID', " +
  6264. "food_name VARCHAR(100) COMMENT '食材名称', " +
  6265. "index_score INT COMMENT '推荐指数(0-100)', " +
  6266. "health_report_id BIGINT COMMENT '关联健康报告ID', " +
  6267. "calculated_at DATETIME COMMENT '计算时间', " +
  6268. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6269. "INDEX idx_user (user_id), " +
  6270. "INDEX idx_food (food_id), " +
  6271. "INDEX idx_report (health_report_id)" +
  6272. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食推荐指数'");
  6273. log.info("已创建food_recommend_idx表");
  6274. } catch (Exception e) {
  6275. log.warn("创建food_recommend_idx表可能已存在: {}", e.getMessage());
  6276. }
  6277. // 迁移124: 创建 points_exchange_products 表(积分兑换商品)
  6278. try {
  6279. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS points_exchange_products (" +
  6280. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6281. "name VARCHAR(200) NOT NULL COMMENT '商品名称', " +
  6282. "description TEXT COMMENT '商品描述', " +
  6283. "cover_image VARCHAR(500) COMMENT '封面图片', " +
  6284. "points_price INT NOT NULL COMMENT '积分价格', " +
  6285. "stock INT DEFAULT 0 COMMENT '库存', " +
  6286. "product_type VARCHAR(50) COMMENT '商品类型', " +
  6287. "category VARCHAR(50) COMMENT '分类', " +
  6288. "expiry_days INT COMMENT '有效期(天)', " +
  6289. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  6290. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/disabled', " +
  6291. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6292. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6293. "INDEX idx_status (status), " +
  6294. "INDEX idx_category (category)" +
  6295. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分兑换商品'");
  6296. log.info("已创建points_exchange_products表");
  6297. } catch (Exception e) {
  6298. log.warn("创建points_exchange_products表可能已存在: {}", e.getMessage());
  6299. }
  6300. // 迁移125: 创建 points_exchange_records 表(积分兑换记录)
  6301. try {
  6302. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS points_exchange_records (" +
  6303. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6304. "user_id BIGINT COMMENT '用户ID', " +
  6305. "child_id BIGINT COMMENT '孩子ID', " +
  6306. "family_member_id BIGINT COMMENT '家庭成员ID', " +
  6307. "product_id BIGINT NOT NULL COMMENT '商品ID', " +
  6308. "product_name VARCHAR(200) COMMENT '商品名称快照', " +
  6309. "points_cost INT NOT NULL COMMENT '消耗积分', " +
  6310. "quantity INT DEFAULT 1 COMMENT '数量', " +
  6311. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/completed/cancelled', " +
  6312. "redeem_code VARCHAR(100) COMMENT '兑换码', " +
  6313. "expired_at DATETIME COMMENT '过期时间', " +
  6314. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6315. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6316. "INDEX idx_user (user_id), " +
  6317. "INDEX idx_product (product_id), " +
  6318. "INDEX idx_status (status)" +
  6319. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分兑换记录'");
  6320. log.info("已创建points_exchange_records表");
  6321. } catch (Exception e) {
  6322. log.warn("创建points_exchange_records表可能已存在: {}", e.getMessage());
  6323. }
  6324. // 迁移126: emotion_checkin表修复列类型与添加新列
  6325. ensureColumn("emotion_checkin", "mood_weather", "VARCHAR(20) DEFAULT NULL COMMENT '心情天气: sunny/cloudy/rainy/stormy'");
  6326. ensureColumn("emotion_checkin", "emotion_tags", "VARCHAR(200) DEFAULT NULL COMMENT '情绪标签,逗号分隔'");
  6327. ensureColumn("emotion_checkin", "energy_awarded", "INT DEFAULT 0 COMMENT '发放能量值'");
  6328. ensureColumn("emotion_checkin", "mood_score", "INT DEFAULT NULL COMMENT '心情分数(0-100)'");
  6329. ensureColumn("emotion_checkin", "stress_level", "INT DEFAULT NULL COMMENT '压力等级(1-5)'");
  6330. ensureColumn("emotion_checkin", "arousal_level", "INT DEFAULT NULL COMMENT '唤醒等级(1-5)'");
  6331. ensureColumn("emotion_checkin", "sleep_hours", "DECIMAL(4,1) DEFAULT NULL COMMENT '睡眠时长(小时)'");
  6332. ensureColumn("emotion_checkin", "sleep_quality", "VARCHAR(20) DEFAULT NULL COMMENT '睡眠质量: good/fair/poor'");
  6333. try {
  6334. jdbcTemplate.execute("ALTER TABLE emotion_checkin MODIFY COLUMN user_id BIGINT NULL COMMENT '用户ID(旧字段,可为空)'");
  6335. log.info("已修改emotion_checkin.user_id为可空");
  6336. } catch (Exception e) {
  6337. log.warn("修改emotion_checkin.user_id可能已处理: {}", e.getMessage());
  6338. }
  6339. try {
  6340. jdbcTemplate.execute("ALTER TABLE emotion_checkin MODIFY COLUMN emotion_type VARCHAR(50) DEFAULT 'custom' COMMENT '情绪类型'");
  6341. log.info("已修改emotion_checkin.emotion_type为VARCHAR");
  6342. } catch (Exception e) {
  6343. log.warn("修改emotion_checkin.emotion_type可能已处理: {}", e.getMessage());
  6344. }
  6345. ensureColumn("family_fortune", "fortune_level_text", "VARCHAR(50) DEFAULT NULL COMMENT '运势等级文字'");
  6346. ensureColumn("family_fortune", "fortune_description", "VARCHAR(500) DEFAULT NULL COMMENT '运势描述'");
  6347. ensureColumn("family_fortune", "mood_emoji", "VARCHAR(20) DEFAULT NULL COMMENT '心情表情'");
  6348. ensureColumn("family_fortune", "mood_text", "VARCHAR(100) DEFAULT NULL COMMENT '心情描述文字'");
  6349. ensureColumn("family_fortune", "weekly_tip", "VARCHAR(500) DEFAULT NULL COMMENT '本周建议'");
  6350. // 迁移129: membership_levels表添加price_quarterly列
  6351. ensureColumn("membership_levels", "price_quarterly", "DECIMAL(10,2) DEFAULT NULL COMMENT '季度价格'");
  6352. // 迁移130: share_events表添加缺失列
  6353. ensureColumn("share_events", "target_type", "VARCHAR(20) DEFAULT NULL COMMENT '分享目标类型'");
  6354. ensureColumn("share_events", "share_channel", "VARCHAR(20) DEFAULT NULL COMMENT '分享渠道'");
  6355. ensureColumn("share_events", "share_title", "VARCHAR(200) DEFAULT NULL COMMENT '分享标题'");
  6356. ensureColumn("share_events", "share_url", "VARCHAR(500) DEFAULT NULL COMMENT '分享URL'");
  6357. ensureColumn("share_events", "read_count", "INT DEFAULT 0 COMMENT '阅读次数'");
  6358. ensureColumn("share_events", "reward_points", "INT DEFAULT 0 COMMENT '奖励积分'");
  6359. ensureColumn("share_events", "status", "VARCHAR(20) DEFAULT 'active' COMMENT '状态'");
  6360. ensureColumn("share_events", "expires_at", "DATETIME DEFAULT NULL COMMENT '过期时间'");
  6361. ensureColumn("share_events", "updated_at", "DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'");
  6362. // 迁移131: share_reads表添加缺失列
  6363. ensureColumn("share_reads", "read_duration", "INT DEFAULT NULL COMMENT '阅读时长(秒)'");
  6364. ensureColumn("share_reads", "read_at", "DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '阅读时间'");
  6365. // 迁移132: points_exchange_records表确保family_member_id列
  6366. ensureColumn("points_exchange_records", "family_member_id", "BIGINT COMMENT '家庭成员ID'");
  6367. // 迁移133: products表列对齐 — rename min_quantity→purchase_count_threshold, add missing columns
  6368. if (columnExists("products", "min_quantity")) {
  6369. try {
  6370. jdbcTemplate.execute("ALTER TABLE products CHANGE COLUMN min_quantity purchase_count_threshold INT DEFAULT 0 COMMENT '最小购买数量限制'");
  6371. log.info("已重命名products.min_quantity→purchase_count_threshold");
  6372. } catch (Exception e) {
  6373. log.warn("重命名products列失败(可能已处理): {}", e.getMessage());
  6374. }
  6375. }
  6376. // 迁移134: products表添加sort_order列(商品按排序字段排序展示)
  6377. ensureColumn("products", "sort_order", "INT DEFAULT 0 COMMENT '排序(值越小越靠前)'");
  6378. ensureColumn("products", "recommendation_tags", "VARCHAR(500) DEFAULT NULL COMMENT '推荐标签'");
  6379. ensureColumn("products", "is_recommended", "TINYINT(1) DEFAULT 0 COMMENT '是否推荐商品 0否 1是'");
  6380. ensureColumn("products", "repurchase_interval_days", "INT DEFAULT 30 COMMENT '复购间隔天数'");
  6381. // 迁移134: 创建 category_ppoint 表(品类P点比例配置)
  6382. try {
  6383. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS category_ppoint (" +
  6384. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6385. "category_id BIGINT NOT NULL COMMENT '商品类目ID', " +
  6386. "ratio INT DEFAULT 0 COMMENT 'P点比例(千分比)', " +
  6387. "start_date DATETIME COMMENT '生效日期', " +
  6388. "end_date DATETIME COMMENT '失效日期(null=永不过期)', " +
  6389. "created_by BIGINT COMMENT '创建人', " +
  6390. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6391. "INDEX idx_category_id (category_id)" +
  6392. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='品类P点比例配置'");
  6393. log.info("已创建category_ppoint表");
  6394. } catch (Exception e) {
  6395. log.warn("创建category_ppoint表失败: {}", e.getMessage());
  6396. }
  6397. // 迁移135: 创建 commission_dist_log 表(P点分润记录)
  6398. try {
  6399. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS commission_dist_log (" +
  6400. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6401. "buyer_id BIGINT NOT NULL COMMENT '买家用户ID', " +
  6402. "order_id BIGINT COMMENT '订单ID', " +
  6403. "product_id BIGINT COMMENT '商品ID', " +
  6404. "source_ppoint INT DEFAULT 0 COMMENT '本次产生的原始P点数量', " +
  6405. "level1_user_id BIGINT COMMENT '一级推荐人用户ID', " +
  6406. "level1_ppoint INT DEFAULT 0 COMMENT '一级推荐人获得P点', " +
  6407. "level1_wealth_percent INT DEFAULT 0 COMMENT '一级推荐人当时财富等级(profitSharePercent)', " +
  6408. "level2_user_id BIGINT COMMENT '二级推荐人用户ID', " +
  6409. "level2_ppoint INT DEFAULT 0 COMMENT '二级推荐人获得P点', " +
  6410. "level2_wealth_percent INT DEFAULT 0 COMMENT '二级推荐人当时财富等级(profitSharePercent)', " +
  6411. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/completed/failed', " +
  6412. "remark VARCHAR(500) COMMENT '备注', " +
  6413. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6414. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
  6415. "INDEX idx_buyer (buyer_id), " +
  6416. "INDEX idx_order (order_id), " +
  6417. "INDEX idx_level1 (level1_user_id), " +
  6418. "INDEX idx_level2 (level2_user_id)" +
  6419. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='P点分润记录'");
  6420. log.info("已创建commission_dist_log表");
  6421. } catch (Exception e) {
  6422. log.warn("创建commission_dist_log表失败: {}", e.getMessage());
  6423. }
  6424. // 迁移136: payment_orders表添加period列(会员订阅周期)
  6425. ensureColumn("payment_orders", "period", "VARCHAR(20) DEFAULT 'yearly' COMMENT '订阅周期: monthly/quarterly/yearly'");
  6426. // 迁移137: 扩大payment_orders.payment_type列长度(原列可能为VARCHAR(5),存不下"yearly")
  6427. try {
  6428. jdbcTemplate.execute("ALTER TABLE payment_orders MODIFY COLUMN payment_type VARCHAR(20) DEFAULT 'yearly' COMMENT '支付类型: yearly/monthly/trial'");
  6429. log.info("已修改payment_orders.payment_type列长度为VARCHAR(20)");
  6430. } catch (Exception e) {
  6431. log.warn("修改payment_orders.payment_type列长度失败: {}", e.getMessage());
  6432. }
  6433. // 迁移138: tasks表添加dimension_code和dimension_weights列(原migration102内从未被调用)
  6434. ensureColumn("tasks", "dimension_code", "VARCHAR(20) COMMENT '五维维度: body/mind/wisdom/action/wealth'");
  6435. ensureColumn("tasks", "dimension_weights", "JSON COMMENT '五维权重: {\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
  6436. // 迁移139: products表添加family_scope列(推荐系统-家庭消费去重,原migration102内从未被调用)
  6437. ensureColumn("products", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除'");
  6438. // 迁移140: activities表添加family_scope列(推荐系统-家庭参与去重,原migration102内从未被调用)
  6439. ensureColumn("activities", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除'");
  6440. // 迁移141: 修正五维权重维度code体系(后台DimensionWeightPicker曾用mind=智/heart=心,标准体系为wisdom=智/mind=心)
  6441. // 旧体系写入的数据导致小程序端按wisdom/mind查询JSON_EXTRACT匹配不到,新活动显示不出来
  6442. try {
  6443. // 1. dimension_weights 表:仅处理含 heart 的目标(旧体系写入的),mind(智)→wisdom,heart(心)→mind
  6444. int dwHeartCount = jdbcTemplate.queryForObject(
  6445. "SELECT COUNT(*) FROM dimension_weights WHERE dimension = 'heart'", Integer.class);
  6446. if (dwHeartCount > 0) {
  6447. jdbcTemplate.update("UPDATE dimension_weights dw " +
  6448. "JOIN (SELECT DISTINCT target_type, target_id FROM dimension_weights WHERE dimension = 'heart') h " +
  6449. "ON dw.target_type = h.target_type AND dw.target_id = h.target_id " +
  6450. "SET dw.dimension = 'wisdom' WHERE dw.dimension = 'mind'");
  6451. jdbcTemplate.update("UPDATE dimension_weights SET dimension = 'mind' WHERE dimension = 'heart'");
  6452. log.info("已修正dimension_weights表维度code: {}条heart记录", dwHeartCount);
  6453. }
  6454. } catch (Exception e) {
  6455. log.warn("修正dimension_weights表维度code失败: {}", e.getMessage());
  6456. }
  6457. try {
  6458. // 2. 主表 dimension_weights JSON 列:含 heart 键的记录,mind键→wisdom键,heart键→mind键
  6459. String[] jsonTables = {"activities", "products", "articles"};
  6460. for (String table : jsonTables) {
  6461. int jsonCount = jdbcTemplate.queryForObject(
  6462. "SELECT COUNT(*) FROM " + table + " WHERE dimension_weights IS NOT NULL " +
  6463. "AND JSON_CONTAINS_PATH(dimension_weights, 'one', '$.heart')", Integer.class);
  6464. if (jsonCount > 0) {
  6465. jdbcTemplate.update("UPDATE " + table + " SET dimension_weights = JSON_SET(" +
  6466. "JSON_REMOVE(JSON_REMOVE(dimension_weights, '$.mind'), '$.heart'), " +
  6467. "'$.wisdom', JSON_EXTRACT(dimension_weights, '$.mind'), " +
  6468. "'$.mind', JSON_EXTRACT(dimension_weights, '$.heart')) " +
  6469. "WHERE dimension_weights IS NOT NULL " +
  6470. "AND JSON_CONTAINS_PATH(dimension_weights, 'one', '$.heart')");
  6471. log.info("已修正{}表dimension_weights JSON: {}条", table, jsonCount);
  6472. }
  6473. }
  6474. } catch (Exception e) {
  6475. log.warn("修正主表dimension_weights JSON失败: {}", e.getMessage());
  6476. }
  6477. // 迁移142: activities表添加报名截止时间与会员/非会员双收费字段(活动升级:报名时间窗口+孩子/家长双收费+会员区分,需求#3#6)
  6478. ensureColumn("activities", "registration_deadline", "DATETIME COMMENT '报名截止时间(NULL=不限制, 默认=活动开始时间)'");
  6479. ensureColumn("activities", "child_fee", "INT DEFAULT 0 COMMENT '孩子·非会员价(分)'");
  6480. ensureColumn("activities", "child_member_fee", "INT DEFAULT 0 COMMENT '孩子·会员价(分), 0=免费'");
  6481. ensureColumn("activities", "parent_fee", "INT DEFAULT 0 COMMENT '家长·非会员价(分)'");
  6482. ensureColumn("activities", "parent_member_fee", "INT DEFAULT 0 COMMENT '家长·会员价(分), 0=免费'");
  6483. ensureColumn("activities", "creator_id", "BIGINT COMMENT '创建人userId(普通用户发布者, admin创建可为空)'");
  6484. // 迁移143: 创建活动报名订单表(活动付费报名:先支付后报名,需求#4)
  6485. try {
  6486. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_orders (" +
  6487. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6488. "order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号(ACT+时间戳+随机)', " +
  6489. "activity_id BIGINT NOT NULL COMMENT '活动ID', " +
  6490. "user_id BIGINT NOT NULL COMMENT '下单人userId', " +
  6491. "total_amount INT NOT NULL COMMENT '实付金额(分)', " +
  6492. "registrants_json TEXT COMMENT '报名人快照JSON: [{name, phone, childId, type: child/parent, fee}]', " +
  6493. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/paid/cancelled', " +
  6494. "transaction_id VARCHAR(64) COMMENT '微信支付单号', " +
  6495. "paid_at DATETIME COMMENT '支付时间', " +
  6496. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6497. "INDEX idx_activity (activity_id), " +
  6498. "INDEX idx_user (user_id), " +
  6499. "INDEX idx_status (status)" +
  6500. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名订单表'");
  6501. log.info("已创建activity_orders表");
  6502. } catch (Exception e) {
  6503. log.warn("创建activity_orders表失败: {}", e.getMessage());
  6504. }
  6505. // 迁移144: 创建活动总结资料表(总结中阶段发布人上传资料,需求#9)
  6506. try {
  6507. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_materials (" +
  6508. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6509. "activity_id BIGINT NOT NULL COMMENT '活动ID', " +
  6510. "title VARCHAR(200) COMMENT '标题', " +
  6511. "file_url VARCHAR(500) NOT NULL COMMENT '文件地址(图片/PDF/外链URL)', " +
  6512. "file_type VARCHAR(20) DEFAULT 'image' COMMENT '类型: image/pdf/link', " +
  6513. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6514. "INDEX idx_activity (activity_id)" +
  6515. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动总结资料表'");
  6516. log.info("已创建activity_materials表");
  6517. } catch (Exception e) {
  6518. log.warn("创建activity_materials表失败: {}", e.getMessage());
  6519. }
  6520. }
  6521. private void runMigration82() {
  6522. try {
  6523. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" +
  6524. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6525. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  6526. "conversation_id VARCHAR(100) NOT NULL COMMENT 'Dify会话ID', " +
  6527. "summary_text TEXT COMMENT '对话摘要文本', " +
  6528. "summary_tokens INT DEFAULT 0 COMMENT '摘要token数', " +
  6529. "message_count INT DEFAULT 0 COMMENT '本次对话消息数', " +
  6530. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6531. "INDEX idx_conversation (conversation_id), " +
  6532. "INDEX idx_user (user_id)" +
  6533. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI对话摘要'");
  6534. log.info("已创建ai_conversation_summary表");
  6535. } catch (Exception e) {
  6536. log.warn("创建ai_conversation_summary表失败: {}", e.getMessage());
  6537. }
  6538. try {
  6539. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_user_facts (" +
  6540. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6541. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  6542. "fact_key VARCHAR(100) NOT NULL COMMENT '事实键', " +
  6543. "fact_value VARCHAR(500) COMMENT '事实值', " +
  6544. "source_conversation VARCHAR(100) COMMENT '来源会话ID', " +
  6545. "confidence DECIMAL(3,2) DEFAULT 1.00 COMMENT '置信度0-1', " +
  6546. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  6547. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
  6548. "INDEX idx_user_key (user_id, fact_key), " +
  6549. "INDEX idx_conversation (source_conversation)" +
  6550. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI用户关键事实'");
  6551. log.info("已创建ai_user_facts表");
  6552. } catch (Exception e) {
  6553. log.warn("创建ai_user_facts表失败: {}", e.getMessage());
  6554. }
  6555. }
  6556. private void runMigration86() {
  6557. try {
  6558. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_hierarchy (" +
  6559. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6560. "system_id BIGINT NOT NULL COMMENT '供应商体系ID', " +
  6561. "parent_id BIGINT NOT NULL COMMENT '上级供应商ID', " +
  6562. "child_id BIGINT NOT NULL COMMENT '下级供应商ID', " +
  6563. "level INT DEFAULT 1 COMMENT '层级深度', " +
  6564. "status VARCHAR(20) DEFAULT 'active', " +
  6565. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6566. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6567. "UNIQUE KEY uk_system_parent_child (system_id, parent_id, child_id), " +
  6568. "KEY idx_system_child (system_id, child_id), " +
  6569. "KEY idx_parent (parent_id)" +
  6570. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商上下级关系表'");
  6571. log.info("已创建supply_hierarchy表");
  6572. } catch (Exception e) {
  6573. log.warn("创建supply_hierarchy表失败: {}", e.getMessage());
  6574. }
  6575. try {
  6576. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_hierarchy_change_request (" +
  6577. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6578. "system_id BIGINT NOT NULL, " +
  6579. "applicant_id BIGINT NOT NULL COMMENT '申请人ID', " +
  6580. "target_parent_id BIGINT NOT NULL COMMENT '目标上级ID', " +
  6581. "status VARCHAR(20) DEFAULT 'pending', " +
  6582. "reason VARCHAR(500), " +
  6583. "reject_reason VARCHAR(500), " +
  6584. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6585. "reviewed_at DATETIME, " +
  6586. "reviewed_by BIGINT, " +
  6587. "KEY idx_system_applicant (system_id, applicant_id), " +
  6588. "KEY idx_status (status)" +
  6589. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商更换上级申请表'");
  6590. log.info("已创建supply_hierarchy_change_request表");
  6591. } catch (Exception e) {
  6592. log.warn("创建supply_hierarchy_change_request表失败: {}", e.getMessage());
  6593. }
  6594. try {
  6595. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_profiles (" +
  6596. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6597. "user_id BIGINT NOT NULL, " +
  6598. "tier VARCHAR(10), " +
  6599. "status VARCHAR(20) DEFAULT 'pending', " +
  6600. "member_count INT DEFAULT 0, " +
  6601. "max_members INT DEFAULT 50, " +
  6602. "annual_fee_l1 BIGINT, " +
  6603. "annual_fee_l2 BIGINT, " +
  6604. "description TEXT, " +
  6605. "certificates TEXT, " +
  6606. "reject_reason VARCHAR(500), " +
  6607. "commission_rate_l1 INT DEFAULT 1000, " +
  6608. "commission_rate_l2 INT DEFAULT 5000, " +
  6609. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6610. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6611. "approved_at DATETIME, " +
  6612. "UNIQUE KEY uk_user_id (user_id), " +
  6613. "INDEX idx_status (status), " +
  6614. "INDEX idx_tier (tier)" +
  6615. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家服务提供商档案'");
  6616. log.info("已创建butler_profiles表");
  6617. } catch (Exception e) {
  6618. log.warn("创建butler_profiles表失败: {}", e.getMessage());
  6619. }
  6620. try {
  6621. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_service_records (" +
  6622. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6623. "butler_id BIGINT NOT NULL, " +
  6624. "member_user_id BIGINT NOT NULL, " +
  6625. "subscription_id BIGINT, " +
  6626. "service_type VARCHAR(20) NOT NULL, " +
  6627. "amount_p BIGINT NOT NULL, " +
  6628. "platform_fee_p BIGINT DEFAULT 0, " +
  6629. "butler_amount_p BIGINT DEFAULT 0, " +
  6630. "member_tier VARCHAR(10), " +
  6631. "`year_month` VARCHAR(7), " +
  6632. "remark VARCHAR(500), " +
  6633. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6634. "INDEX idx_butler_id (butler_id), " +
  6635. "INDEX idx_member_user_id (member_user_id), " +
  6636. "INDEX idx_year_month (`year_month`)" +
  6637. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家服务记录'");
  6638. log.info("已创建butler_service_records表");
  6639. } catch (Exception e) {
  6640. log.warn("创建butler_service_records表失败: {}", e.getMessage());
  6641. }
  6642. try {
  6643. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_commission_settlements (" +
  6644. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6645. "butler_id BIGINT NOT NULL, " +
  6646. "`year_month` VARCHAR(7) NOT NULL, " +
  6647. "member_count INT DEFAULT 0, " +
  6648. "annual_base_amount_p BIGINT DEFAULT 0, " +
  6649. "annual_butler_amount_p BIGINT DEFAULT 0, " +
  6650. "annual_platform_amount_p BIGINT DEFAULT 0, " +
  6651. "per_session_total_p BIGINT DEFAULT 0, " +
  6652. "per_session_platform_total_p BIGINT DEFAULT 0, " +
  6653. "per_session_butler_total_p BIGINT DEFAULT 0, " +
  6654. "total_butler_amount_p BIGINT DEFAULT 0, " +
  6655. "status VARCHAR(20) DEFAULT 'pending', " +
  6656. "settled_at DATETIME, " +
  6657. "settled_by BIGINT, " +
  6658. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6659. "UNIQUE KEY uk_butler_month (butler_id, `year_month`), " +
  6660. "INDEX idx_status (status), " +
  6661. "INDEX idx_year_month (`year_month`)" +
  6662. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管家月度佣金结算单'");
  6663. log.info("已创建butler_commission_settlements表");
  6664. } catch (Exception e) {
  6665. log.warn("创建butler_commission_settlements表失败: {}", e.getMessage());
  6666. }
  6667. try {
  6668. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_status VARCHAR(20)");
  6669. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_info TEXT");
  6670. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN butler_approved_at DATETIME");
  6671. log.info("已添加 butler 相关列到 users 表");
  6672. } catch (Exception e) {
  6673. log.warn("Migration skipped (already exists): {}", e.getMessage());
  6674. }
  6675. // 迁移 86: 创建 butler_assignments 表(会员 - 管家分配记录)
  6676. try {
  6677. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_assignments (" +
  6678. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6679. "family_id BIGINT NOT NULL COMMENT '家庭 ID', " +
  6680. "butler_user_id BIGINT NOT NULL COMMENT '管家用户 ID', " +
  6681. "subscription_id BIGINT COMMENT '关联订阅 ID', " +
  6682. "level VARCHAR(10) NOT NULL COMMENT '订阅级别 L1/L2', " +
  6683. "assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6684. "UNIQUE KEY uk_family_assignment (family_id), " +
  6685. "INDEX idx_butler_user_id (butler_user_id), " +
  6686. "INDEX idx_subscription_id (subscription_id)" +
  6687. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员 - 管家分配记录'");
  6688. log.info("已创建 butler_assignments 表");
  6689. } catch (Exception e) {
  6690. log.warn("创建 butler_assignments 表失败:{}", e.getMessage());
  6691. }
  6692. }
  6693. private void runMigration88() {
  6694. // 迁移 88: 创建家庭公共账户体系(family_earnings / family_earnings_member / family_earnings_record)
  6695. // 表 1: family_earnings(家庭总账)
  6696. try {
  6697. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_earnings (" +
  6698. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6699. "family_id BIGINT NOT NULL UNIQUE COMMENT '家庭 ID', " +
  6700. "total_earned BIGINT NOT NULL DEFAULT 0 COMMENT '累计推荐收入(分)', " +
  6701. "available_amount BIGINT NOT NULL DEFAULT 0 COMMENT '当前可支配余额(分)', " +
  6702. "withdrawn_amount BIGINT NOT NULL DEFAULT 0 COMMENT '已提现金额(分)', " +
  6703. "distributed_amount BIGINT NOT NULL DEFAULT 0 COMMENT '已颁发给成员的金额(分)', " +
  6704. "updated_at DATETIME, " +
  6705. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6706. "INDEX idx_family_id (family_id)" +
  6707. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭公共账户'");
  6708. log.info("已创建 family_earnings 表");
  6709. } catch (Exception e) {
  6710. log.warn("创建 family_earnings 表失败:{}", e.getMessage());
  6711. }
  6712. // 表 2: family_earnings_member(成员已分配余额)
  6713. try {
  6714. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_earnings_member (" +
  6715. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6716. "family_id BIGINT NOT NULL, " +
  6717. "user_id BIGINT NOT NULL, " +
  6718. "allocated_amount BIGINT NOT NULL DEFAULT 0 COMMENT '管理员累计分配(分)', " +
  6719. "consumed_amount BIGINT NOT NULL DEFAULT 0 COMMENT '已消费金额(分)', " +
  6720. "available_amount BIGINT NOT NULL DEFAULT 0 COMMENT '可消费余额=allocated-consumed(分)', " +
  6721. "can_use_family_balance TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许使用家庭公共池', " +
  6722. "updated_at DATETIME, " +
  6723. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6724. "UNIQUE KEY idx_family_user (family_id, user_id)" +
  6725. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员已分配余额'");
  6726. log.info("已创建 family_earnings_member 表");
  6727. } catch (Exception e) {
  6728. log.warn("创建 family_earnings_member 表失败:{}", e.getMessage());
  6729. }
  6730. // 表 3: family_earnings_record(收支流水)
  6731. try {
  6732. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_earnings_record (" +
  6733. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6734. "family_id BIGINT NOT NULL, " +
  6735. "type VARCHAR(20) NOT NULL COMMENT 'earn 收入/withdraw 提现/distribute 颁发/consume 消费', " +
  6736. "amount BIGINT NOT NULL COMMENT '金额(分)', " +
  6737. "from_user_id BIGINT COMMENT '操作人', " +
  6738. "to_user_id BIGINT COMMENT '受益人(颁发/消费时填写)', " +
  6739. "note VARCHAR(255) COMMENT '备注', " +
  6740. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6741. "INDEX idx_family_id (family_id), " +
  6742. "INDEX idx_type (type)" +
  6743. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭收益收支流水'");
  6744. log.info("已创建 family_earnings_record 表");
  6745. } catch (Exception e) {
  6746. log.warn("创建 family_earnings_record 表失败:{}", e.getMessage());
  6747. }
  6748. // commission_records 加 family_id 列
  6749. ensureColumn("commission_records", "family_id", "BIGINT COMMENT '归属家庭 ID'");
  6750. // 迁移90: contacts表添加家庭成员关联字段(联系人→家庭成员升级路径)
  6751. ensureColumn("contacts", "family_member_id", "BIGINT DEFAULT NULL COMMENT '关联的家庭成员ID'");
  6752. ensureColumn("contacts", "invited_status", "VARCHAR(16) DEFAULT 'none' COMMENT '邀请加入家庭状态: none/invited/accepted/declined'");
  6753. // 迁移91: 创建family_member_logs表(家庭成员变更日志)
  6754. try {
  6755. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_logs (" +
  6756. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6757. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  6758. "member_id BIGINT NOT NULL COMMENT '成员ID', " +
  6759. "action VARCHAR(32) NOT NULL COMMENT '操作: join/leave/update/kick', " +
  6760. "old_value VARCHAR(500) COMMENT '旧值', " +
  6761. "new_value VARCHAR(500) COMMENT '新值', " +
  6762. "operator_id BIGINT COMMENT '操作人用户ID', " +
  6763. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6764. "INDEX idx_family_id (family_id), " +
  6765. "INDEX idx_member_id (member_id)" +
  6766. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员变更日志'");
  6767. log.info("已创建family_member_logs表");
  6768. } catch (Exception e) {
  6769. // 表已存在,忽略错误
  6770. }
  6771. // 迁移92: 创建health_meal_record表(饮食打卡记录)
  6772. try {
  6773. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_meal_record (" +
  6774. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6775. "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  6776. "checkin_id BIGINT COMMENT '关联打卡ID', " +
  6777. "meal_type VARCHAR(32) COMMENT '餐次: breakfast/lunch/dinner/snack', " +
  6778. "food_items TEXT COMMENT '食物清单JSON', " +
  6779. "total_calories INT COMMENT '总卡路里', " +
  6780. "protein_g INT COMMENT '蛋白质克数', " +
  6781. "fat_g INT COMMENT '脂肪克数', " +
  6782. "carbs_g INT COMMENT '碳水克数', " +
  6783. "fiber_g INT COMMENT '膳食纤维克数', " +
  6784. "meal_time DATETIME COMMENT '用餐时间', " +
  6785. "remark VARCHAR(500) COMMENT '备注', " +
  6786. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6787. "INDEX idx_member_id (member_id)" +
  6788. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食打卡记录'");
  6789. log.info("已创建health_meal_record表");
  6790. } catch (Exception e) {
  6791. // 表已存在,忽略错误
  6792. }
  6793. // 迁移93: 创建health_meditation_record表(冥想打卡记录)
  6794. try {
  6795. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_meditation_record (" +
  6796. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6797. "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  6798. "checkin_id BIGINT COMMENT '关联打卡ID', " +
  6799. "duration_minutes INT COMMENT '时长(分钟)', " +
  6800. "type VARCHAR(32) COMMENT '类型: mindfulness/guided/breathing/body_scan', " +
  6801. "mood VARCHAR(32) COMMENT '冥想后心情', " +
  6802. "remark VARCHAR(500) COMMENT '备注', " +
  6803. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6804. "INDEX idx_member_id (member_id)" +
  6805. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='冥想打卡记录'");
  6806. log.info("已创建health_meditation_record表");
  6807. } catch (Exception e) {
  6808. // 表已存在,忽略错误
  6809. }
  6810. // 迁移94: 创建ecom_supplier表(电商供应商管理)
  6811. try {
  6812. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ecom_supplier (" +
  6813. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6814. "name VARCHAR(100) NOT NULL COMMENT '供应商名称', " +
  6815. "contact_name VARCHAR(50) COMMENT '联系人', " +
  6816. "contact_phone VARCHAR(20) COMMENT '联系电话', " +
  6817. "status TINYINT DEFAULT 1 COMMENT '启用状态 1启用 0禁用', " +
  6818. "remark VARCHAR(500) COMMENT '备注', " +
  6819. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6820. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  6821. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电商供应商'");
  6822. log.info("已创建ecom_supplier表");
  6823. } catch (Exception e) {
  6824. // 表已存在,忽略错误
  6825. }
  6826. // 迁移95: 创建rebate_commission_config表(佣金返利配置)
  6827. try {
  6828. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS rebate_commission_config (" +
  6829. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6830. "name VARCHAR(100) NOT NULL COMMENT '配置名称', " +
  6831. "rate INT COMMENT '佣金比例(万分比)', " +
  6832. "rate_type TINYINT DEFAULT 1 COMMENT '比例类型 1百分比 2固定金额', " +
  6833. "min_amount INT COMMENT '最低金额(分)', " +
  6834. "max_amount INT COMMENT '最高金额(分)', " +
  6835. "remark VARCHAR(500) COMMENT '备注', " +
  6836. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6837. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  6838. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='佣金返利配置'");
  6839. log.info("已创建rebate_commission_config表");
  6840. } catch (Exception e) {
  6841. // 表已存在,忽略错误
  6842. }
  6843. }
  6844. private void runMigration89() {
  6845. // 迁移 89: supply_system表添加models字段(AI模型供应商配置)
  6846. ensureColumn("supply_system", "models", "TEXT COMMENT 'AI模型列表JSON,如[\"agnes-image-2.0-flash\",\"agnes-2.5-flash\"]'");
  6847. log.info("已添加models列到supply_system表");
  6848. }
  6849. private void runMigration90() {
  6850. // 迁移 90: supply_system表添加api_endpoint和api_key字段
  6851. ensureColumn("supply_system", "api_endpoint", "VARCHAR(512) COMMENT 'API端点'");
  6852. ensureColumn("supply_system", "api_key", "VARCHAR(256) COMMENT 'API密钥'");
  6853. log.info("已添加api_endpoint和api_key列到supply_system表");
  6854. }
  6855. private void runMigration91() {
  6856. // 迁移 91: users表添加营养师审核字段
  6857. ensureColumn("users", "nutritionist_status", "VARCHAR(20) DEFAULT NULL COMMENT '营养师审核状态: pending/approved/rejected'");
  6858. ensureColumn("users", "nutritionist_reject_reason", "VARCHAR(500) DEFAULT NULL COMMENT '营养师审核拒绝原因'");
  6859. log.info("已添加nutritionist_status和nutritionist_reject_reason列到users表");
  6860. }
  6861. private void runMigration92() {
  6862. // 迁移 92: 创建dan_assessment_executions表(DAN测评服务执行记录)
  6863. try {
  6864. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `dan_assessment_executions` (" +
  6865. "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6866. "`assessment_execution_id` BIGINT NOT NULL COMMENT '执行记录ID', " +
  6867. "`assessment_order_id` BIGINT NOT NULL COMMENT '订单ID', " +
  6868. "`child_id` BIGINT NOT NULL COMMENT '孩子ID', " +
  6869. "`family_id` BIGINT NOT NULL COMMENT '家庭ID', " +
  6870. "`quota_id` BIGINT COMMENT '额度ID', " +
  6871. "`assessor_id` BIGINT NOT NULL COMMENT '测评师userId', " +
  6872. "`assessor_status` ENUM('pending','completed','rejected') DEFAULT 'pending' COMMENT '测评师状态', " +
  6873. "`assessor_completed_at` DATETIME COMMENT '测评师完成时间', " +
  6874. "`assessor_notes` VARCHAR(200) COMMENT '测评摘要/服务备注', " +
  6875. "`planner_id` BIGINT COMMENT '规划师userId', " +
  6876. "`planner_status` ENUM('pending','completed','rejected') DEFAULT 'pending' COMMENT '规划师状态', " +
  6877. "`planner_completed_at` DATETIME COMMENT '规划师完成时间', " +
  6878. "`planner_notes` VARCHAR(200) COMMENT '规划服务备注', " +
  6879. "`user_confirmed` TINYINT(1) DEFAULT 0 COMMENT '用户确认:0未确认,1已确认', " +
  6880. "`user_confirmed_at` DATETIME COMMENT '用户确认时间', " +
  6881. "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6882. "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6883. "KEY `idx_assessment_execution_id` (`assessment_execution_id`), " +
  6884. "KEY `idx_assessment_order_id` (`assessment_order_id`), " +
  6885. "KEY `idx_assessor_id` (`assessor_id`), " +
  6886. "KEY `idx_planner_id` (`planner_id`), " +
  6887. "KEY `idx_child_id` (`child_id`), " +
  6888. "KEY `idx_family_id` (`family_id`)" +
  6889. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DAN测评服务执行记录'");
  6890. log.info("已创建dan_assessment_executions表");
  6891. } catch (Exception e) {
  6892. // 表已存在,忽略错误
  6893. }
  6894. // 迁移 92: 创建service_ratings表(服务评分记录)
  6895. try {
  6896. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `service_ratings` (" +
  6897. "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6898. "`execution_id` BIGINT NOT NULL COMMENT '执行记录ID', " +
  6899. "`assessment_order_id` BIGINT NOT NULL COMMENT '订单ID', " +
  6900. "`user_id` BIGINT NOT NULL COMMENT '评分用户ID', " +
  6901. "`assessor_id` BIGINT NOT NULL COMMENT '测评师userId', " +
  6902. "`assessor_rating` TINYINT DEFAULT 0 COMMENT '测评师评分 1-5', " +
  6903. "`assessor_comment` VARCHAR(500) COMMENT '评价内容', " +
  6904. "`planner_id` BIGINT COMMENT '规划师userId', " +
  6905. "`planner_rating` TINYINT COMMENT '规划师评分 1-5', " +
  6906. "`planner_comment` VARCHAR(500) COMMENT '评价内容', " +
  6907. "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6908. "UNIQUE KEY `uniq_execution_user` (`execution_id`, `user_id`)," +
  6909. "KEY `idx_assessor_id` (`assessor_id`)," +
  6910. "KEY `idx_planner_id` (`planner_id`)" +
  6911. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务评分记录'");
  6912. log.info("已创建service_ratings表");
  6913. } catch (Exception e) {
  6914. // 表已存在,忽略错误
  6915. }
  6916. // 迁移 92: 创建service_settlements表(服务结算记录)
  6917. try {
  6918. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `service_settlements` (" +
  6919. "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6920. "`execution_id` BIGINT NOT NULL COMMENT '执行记录ID', " +
  6921. "`assessment_order_id` BIGINT NOT NULL COMMENT '订单ID', " +
  6922. "`provider_id` BIGINT NOT NULL COMMENT '服务提供方userId', " +
  6923. "`provider_type` ENUM('assessor','planner') NOT NULL COMMENT '服务方类型', " +
  6924. "`service_fee` BIGINT DEFAULT 0 COMMENT '服务费用(分)', " +
  6925. "`commission_rate` INT COMMENT '佣金比例(bps)', " +
  6926. "`commission_amount` BIGINT DEFAULT 0 COMMENT '结算金额(分)', " +
  6927. "`status` ENUM('pending','settled','failed') DEFAULT 'pending' COMMENT '结算状态', " +
  6928. "`settled_at` DATETIME COMMENT '结算时间', " +
  6929. "`transaction_id` VARCHAR(100) COMMENT '转账交易ID', " +
  6930. "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6931. "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6932. "KEY `idx_execution_id` (`execution_id`), " +
  6933. "KEY `idx_provider_id` (`provider_id`), " +
  6934. "KEY `idx_status` (`status`)" +
  6935. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务结算记录'");
  6936. log.info("已创建service_settlements表");
  6937. } catch (Exception e) {
  6938. // 表已存在,忽略错误
  6939. }
  6940. // 迁移 96: product_orders 添加活动provision字段 + activity_provisions 表(活动商品统一管理)
  6941. try {
  6942. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN activity_id BIGINT DEFAULT NULL COMMENT '关联活动ID(product_type=activity时有效)'");
  6943. log.info("已添加activity_id列到product_orders表");
  6944. } catch (Exception e) {
  6945. // 列已存在,忽略错误
  6946. }
  6947. try {
  6948. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN provision_status VARCHAR(32) DEFAULT NULL COMMENT '活动provision状态: unprovisioned(待审核)/provisioned(已发货)/signed_in(已签到)/completed(已完成)/cancelled(审核拒绝)'");
  6949. log.info("已添加provision_status列到product_orders表");
  6950. } catch (Exception e) {
  6951. // 列已存在,忽略错误
  6952. }
  6953. try {
  6954. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN auditor_id BIGINT DEFAULT NULL COMMENT '审核人ID'");
  6955. log.info("已添加auditor_id列到product_orders表");
  6956. } catch (Exception e) {
  6957. // 列已存在,忽略错误
  6958. }
  6959. try {
  6960. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN audited_at DATETIME DEFAULT NULL COMMENT '审核时间'");
  6961. log.info("已添加audited_at列到product_orders表");
  6962. } catch (Exception e) {
  6963. // 列已存在,忽略错误
  6964. }
  6965. try {
  6966. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN audit_reason TEXT DEFAULT NULL COMMENT '审核拒绝原因'");
  6967. log.info("已添加audit_reason列到product_orders表");
  6968. } catch (Exception e) {
  6969. // 列已存在,忽略错误
  6970. }
  6971. try {
  6972. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_provisions (" +
  6973. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  6974. "order_id BIGINT NOT NULL COMMENT '商品订单ID', " +
  6975. "order_no VARCHAR(64) NOT NULL COMMENT '订单编号', " +
  6976. "product_id BIGINT NOT NULL COMMENT '商品ID', " +
  6977. "activity_id BIGINT NOT NULL COMMENT '活动ID', " +
  6978. "buyer_id BIGINT NOT NULL COMMENT '购买者用户ID', " +
  6979. "family_id BIGINT COMMENT '家庭ID', " +
  6980. "child_id BIGINT COMMENT '报名孩子ID(签到时填写)', " +
  6981. "status VARCHAR(32) NOT NULL DEFAULT 'unprovisioned' COMMENT 'unprovisioned/provisioned/signed_in/completed/cancelled', " +
  6982. "qr_code VARCHAR(512) COMMENT '签到二维码', " +
  6983. "checked_in_at DATETIME COMMENT '签到时间', " +
  6984. "completed_at DATETIME COMMENT '完成时间', " +
  6985. "refund_amount INT COMMENT '退款金额(分)', " +
  6986. "refund_at DATETIME COMMENT '退款时间', " +
  6987. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  6988. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  6989. "INDEX idx_order (order_id), " +
  6990. "INDEX idx_activity (activity_id), " +
  6991. "INDEX idx_buyer (buyer_id), " +
  6992. "INDEX idx_status (status)" +
  6993. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动provision记录表'");
  6994. log.info("已创建activity_provisions表");
  6995. } catch (Exception e) {
  6996. // 表已存在,忽略错误
  6997. }
  6998. // 迁移 97: DAN测评服务佣金配置
  6999. seedDanCommissionConfig();
  7000. // 迁移 98: 创建 health_knowledge_base 表(菌群/营养知识库)
  7001. try {
  7002. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_knowledge_base` (" +
  7003. "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7004. "`item_type` VARCHAR(50) NOT NULL COMMENT '类型: bacteria/nutrient/indicator/vitamin/amino_acid/disease', " +
  7005. "`item_name` VARCHAR(200) NOT NULL COMMENT '项目名称(精确匹配)', " +
  7006. "`category` VARCHAR(100) COMMENT '分类(如 有益菌/有害菌)', " +
  7007. "`normal_range` VARCHAR(200) COMMENT '正常范围参考', " +
  7008. "`description` TEXT COMMENT '详细说明', " +
  7009. "`suggestion` TEXT COMMENT '相关建议', " +
  7010. "`source` VARCHAR(100) COMMENT '数据来源', " +
  7011. "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7012. "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7013. "UNIQUE KEY `uk_item_type_name` (`item_type`, `item_name`), " +
  7014. "KEY `idx_item_type` (`item_type`)" +
  7015. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康知识库 - 菌属/营养素/指标说明'");
  7016. log.info("已创建health_knowledge_base表");
  7017. } catch (Exception e) {
  7018. // 表已存在,忽略错误
  7019. }
  7020. }
  7021. private void seedDanCommissionConfig() {
  7022. try {
  7023. jdbcTemplate.execute("INSERT IGNORE INTO sys_config (config_key, config_value, description, created_at) VALUES " +
  7024. "('dan_commission_rate', '10', 'DAN测评服务通用佣金比例(%)', NOW()), " +
  7025. "('dan_commission_rate_assessor', '10', 'DAN测评评估师佣金比例(%)', NOW()), " +
  7026. "('dan_commission_rate_planner', '10', 'DAN测评规划师佣金比例(%)', NOW())");
  7027. log.info("已初始化DAN测评佣金配置");
  7028. } catch (Exception e) {
  7029. log.warn("DAN佣金配置种子数据可能已存在: " + e.getMessage());
  7030. }
  7031. // 迁移 98: users 表添加软删除标记(@TableLogic)
  7032. try {
  7033. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN deleted TINYINT DEFAULT 0 COMMENT '软删除标记: 0=未删除, 1=已删除'");
  7034. log.info("已添加deleted列到users表");
  7035. } catch (Exception e) {
  7036. // 列已存在,忽略错误
  7037. }
  7038. // 迁移 103: products表添加min_quantity列(商品最小购买数限制)
  7039. ensureColumn("products", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'");
  7040. // 迁移 104: tianpan_daily_fortune表添加缺失字段(FamilyFortune实体对齐)
  7041. ensureColumn("tianpan_daily_fortune", "lucky_direction", "VARCHAR(20) COMMENT '幸运方向'");
  7042. ensureColumn("tianpan_daily_fortune", "fortune_level", "VARCHAR(20) COMMENT '运势等级'");
  7043. ensureColumn("tianpan_daily_fortune", "fortune_level_text", "VARCHAR(100) COMMENT '运势等级描述'");
  7044. ensureColumn("tianpan_daily_fortune", "fortune_description", "TEXT COMMENT '运势描述'");
  7045. ensureColumn("tianpan_daily_fortune", "mood_score", "INT DEFAULT 50 COMMENT '心情分数'");
  7046. ensureColumn("tianpan_daily_fortune", "mood_emoji", "VARCHAR(10) COMMENT '心情emoji'");
  7047. ensureColumn("tianpan_daily_fortune", "mood_text", "VARCHAR(50) COMMENT '心情文本'");
  7048. ensureColumn("tianpan_daily_fortune", "weekly_tip", "TEXT COMMENT '每周提示'");
  7049. // 验证迁移104: 确认tianpan_daily_fortune关键列存在
  7050. try {
  7051. jdbcTemplate.queryForObject(
  7052. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='tianpan_daily_fortune' AND COLUMN_NAME='fortune_level_text'",
  7053. Integer.class);
  7054. log.info("验证通过: tianpan_daily_fortune.fortune_level_text 列存在");
  7055. } catch (Exception e) {
  7056. log.error("验证失败: tianpan_daily_fortune.fortune_level_text 列仍然不存在,迁移可能失败: {}", e.getMessage());
  7057. }
  7058. // 验证迁移103: 确认products.min_quantity列存在
  7059. try {
  7060. jdbcTemplate.queryForObject(
  7061. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='products' AND COLUMN_NAME='min_quantity'",
  7062. Integer.class);
  7063. log.info("验证通过: products.min_quantity 列存在");
  7064. } catch (Exception e) {
  7065. log.error("验证失败: products.min_quantity 列仍然不存在,迁移可能失败: {}", e.getMessage());
  7066. }
  7067. // 迁移 105: users表添加article_admin_status和activity_provider_status字段
  7068. ensureColumn("users", "article_admin_status", "VARCHAR(20) DEFAULT 'pending' COMMENT '文章管理员审核状态'");
  7069. ensureColumn("users", "activity_provider_status", "VARCHAR(20) DEFAULT 'pending' COMMENT '活动供应商审核状态'");
  7070. }
  7071. private void runMigration93() {
  7072. // 迁移 93: 创建 dan_report_uploads 表(DAN PDF报告独立化)
  7073. try {
  7074. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_report_uploads (" +
  7075. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7076. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  7077. "family_member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  7078. "uploader_user_id BIGINT NOT NULL COMMENT '上传者用户ID', " +
  7079. "dimension VARCHAR(16) NOT NULL COMMENT '维度: mind/wisdom', " +
  7080. "report_type VARCHAR(16) COMMENT 'A2/B4', " +
  7081. "file_url VARCHAR(500) NOT NULL COMMENT 'PDF文件URL', " +
  7082. "file_id VARCHAR(500) COMMENT '媒资文件ID或相对路径', " +
  7083. "file_name VARCHAR(200) COMMENT '原始文件名', " +
  7084. "file_size BIGINT COMMENT '文件大小(字节)', " +
  7085. "uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7086. "parsed_items JSON COMMENT '解析的指标项', " +
  7087. "parsed_extra JSON COMMENT '解析的额外信息', " +
  7088. "parsed_summary TEXT COMMENT '解析的总结文本', " +
  7089. "parsed_suggestions TEXT COMMENT '解析的建议文本', " +
  7090. "parser_version VARCHAR(32) COMMENT '解析器版本', " +
  7091. "parse_status VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/success/failed', " +
  7092. "parse_error VARCHAR(500) COMMENT '解析失败原因', " +
  7093. "parsed_at DATETIME COMMENT '解析完成时间', " +
  7094. "draft_status VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/confirmed/discarded', " +
  7095. "confirmed_at DATETIME COMMENT '确认时间', " +
  7096. "confirmed_by BIGINT COMMENT '确认操作人用户ID', " +
  7097. "edited_items JSON COMMENT '编辑后的指标项', " +
  7098. "edited_extra JSON COMMENT '编辑后的额外信息', " +
  7099. "edited_summary TEXT COMMENT '编辑后的总结', " +
  7100. "edited_suggestions TEXT COMMENT '编辑后的建议', " +
  7101. "edit_count INT DEFAULT 0 COMMENT '编辑次数', " +
  7102. "linked_result_id BIGINT COMMENT '关联 dan_assessment_results.id', " +
  7103. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7104. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7105. "deleted_at DATETIME COMMENT '软删除时间', " +
  7106. "INDEX idx_family_id (family_id), " +
  7107. "INDEX idx_member_id (family_member_id), " +
  7108. "INDEX idx_draft_status (draft_status), " +
  7109. "INDEX idx_dimension (dimension), " +
  7110. "INDEX idx_parse_status (parse_status), " +
  7111. "INDEX idx_linked_result (linked_result_id)" +
  7112. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DAN测评PDF报告独立记录'");
  7113. log.info("已创建dan_report_uploads表");
  7114. } catch (Exception e) {
  7115. // 表已存在,忽略
  7116. }
  7117. }
  7118. private void runMigration94() {
  7119. // 迁移 94: dan_assessment_results表添加独立化扩展字段(关联新表)
  7120. ensureColumn("dan_assessment_results", "source_report_id", "BIGINT DEFAULT NULL COMMENT '关联 dan_report_uploads.id'");
  7121. ensureColumn("dan_assessment_results", "analysis_status", "VARCHAR(16) DEFAULT 'pending' COMMENT '分析状态: pending/confirmed'");
  7122. log.info("已添加独立化扩展列到dan_assessment_results表");
  7123. }
  7124. private void runMigration95() {
  7125. // 迁移 95: 历史 dan_assessment_results 数据 ETL 回填到 dan_report_uploads
  7126. // 条件: source='parent_upload' AND source_report_id IS NULL(真正的历史遗留数据)
  7127. // 注意: 新流程的记录通过 confirmUpload 已正确设置 source_report_id,无需处理
  7128. try {
  7129. // Step 1: 插入 dan_report_uploads 记录(来自 dan_assessment_results 的历史数据)
  7130. jdbcTemplate.execute("INSERT IGNORE INTO dan_report_uploads " +
  7131. "(family_id, family_member_id, uploader_user_id, dimension, report_type, " +
  7132. "file_url, file_id, draft_status, confirmed_at, confirmed_by, " +
  7133. "edited_items, edited_summary, edited_suggestions, linked_result_id, " +
  7134. "parsed_status, parse_status, created_at, updated_at) " +
  7135. "SELECT " +
  7136. " fm.family_id, " +
  7137. " r.family_member_id, " +
  7138. " r.teacher_id, " +
  7139. " r.dimension, " +
  7140. " r.source, " +
  7141. " COALESCE(r.report_file_id, ''), " +
  7142. " r.report_file_id, " +
  7143. " 'confirmed', " +
  7144. " r.created_at, " +
  7145. " r.teacher_id, " +
  7146. " r.structured_analysis, " +
  7147. " r.analysis_report, " +
  7148. " r.growth_suggestions, " +
  7149. " r.id, " +
  7150. " 'success', " +
  7151. " 'confirmed', " +
  7152. " r.created_at, " +
  7153. " r.updated_at " +
  7154. "FROM dan_assessment_results r " +
  7155. "LEFT JOIN family_members fm ON fm.id = r.family_member_id " +
  7156. "WHERE r.source = 'parent_upload' " +
  7157. " AND r.source_report_id IS NULL " +
  7158. " AND r.draft_status = 'confirmed' " +
  7159. " AND NOT EXISTS (SELECT 1 FROM dan_report_uploads u WHERE u.linked_result_id = r.id)"
  7160. );
  7161. log.info("迁移95 Step1: 已将历史 dan_assessment_results 回填到 dan_report_uploads");
  7162. } catch (Exception e) {
  7163. log.error("迁移95 Step1 失败: " + e.getMessage());
  7164. }
  7165. // Step 2: 回填 dan_assessment_results.source_report_id(关联新建的 dan_report_uploads 记录)
  7166. try {
  7167. jdbcTemplate.execute("UPDATE dan_assessment_results r " +
  7168. "INNER JOIN dan_report_uploads u ON u.linked_result_id = r.id " +
  7169. "SET r.source_report_id = u.id " +
  7170. "WHERE r.source = 'parent_upload' " +
  7171. " AND r.source_report_id IS NULL " +
  7172. " AND r.draft_status = 'confirmed'"
  7173. );
  7174. log.info("迁移95 Step2: 已回填 dan_assessment_results.source_report_id");
  7175. } catch (Exception e) {
  7176. log.error("迁移95 Step2 失败: " + e.getMessage());
  7177. }
  7178. }
  7179. private void runMigration97() {
  7180. // 迁移93: users表添加roles字段
  7181. ensureColumn("users", "roles", "VARCHAR(100) COMMENT '角色列表'");
  7182. }
  7183. private void runMigration96() {
  7184. // 迁移96: 修复assessment_appointments.status列类型为TINYINT(原库可能为VARCHAR/ENUM导致Data truncated)
  7185. try {
  7186. jdbcTemplate.execute("ALTER TABLE assessment_appointments MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0=待确认,1=已确认,2=已完成,3=已取消,4=未到'");
  7187. log.info("已修复assessment_appointments.status列类型为TINYINT");
  7188. } catch (Exception e) {
  7189. log.warn("Migration 96 skipped: {}", e.getMessage());
  7190. }
  7191. }
  7192. private void runMigration98() {
  7193. // 迁移98: family_members表新增辈分字段(TASK-P0-001 辈分重构)
  7194. try {
  7195. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN generation INT COMMENT '辈分值(0=家庭创建者,+n=向上,-n=向下)'");
  7196. log.info("已添加generation列到family_members表");
  7197. } catch (Exception e) {
  7198. // 列已存在,忽略错误
  7199. }
  7200. try {
  7201. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN is_spouse TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈中)'");
  7202. log.info("已添加is_spouse列到family_members表");
  7203. } catch (Exception e) {
  7204. // 列已存在,忽略错误
  7205. }
  7206. // 迁移: products表添加min_quantity列(商品最小购买数限制)
  7207. ensureColumn("products", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'");
  7208. // 迁移: 素材库历史数据修复(file_type 回填 + 按分类补标签,幂等)
  7209. backfillAssetLibrary();
  7210. }
  7211. /**
  7212. * 素材库历史数据修复(幂等):
  7213. * <ol>
  7214. * <li>回填 file_record.file_type(历史记录可能为 NULL,导致素材库类型筛选失效)</li>
  7215. * <li>按分类名给记录补自动标签(规则与上传时一致;文件类型不参与标签)</li>
  7216. * </ol>
  7217. */
  7218. private void backfillAssetLibrary() {
  7219. try {
  7220. int n = jdbcTemplate.update(
  7221. "UPDATE file_record SET file_type = CASE " +
  7222. "WHEN content_type LIKE 'image/%' THEN 'image' " +
  7223. "WHEN content_type LIKE 'video/%' THEN 'video' " +
  7224. "WHEN content_type LIKE 'audio/%' THEN 'audio' " +
  7225. "WHEN content_type LIKE 'text/%' THEN 'document' " +
  7226. "WHEN content_type IN ('application/pdf','application/msword'," +
  7227. "'application/vnd.openxmlformats-officedocument.wordprocessingml.document'," +
  7228. "'application/vnd.ms-excel'," +
  7229. "'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'," +
  7230. "'application/vnd.ms-powerpoint'," +
  7231. "'application/vnd.openxmlformats-officedocument.presentationml.presentation') THEN 'document' " +
  7232. "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
  7233. "IN ('jpg','jpeg','png','gif','webp','bmp','svg','ico','heic') THEN 'image' " +
  7234. "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
  7235. "IN ('mp4','webm','ogg','mov','m4v','avi','mkv') THEN 'video' " +
  7236. "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
  7237. "IN ('mp3','wav','m4a','aac','flac','amr') THEN 'audio' " +
  7238. "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
  7239. "IN ('pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','csv','rtf','odt','ods') THEN 'document' " +
  7240. "ELSE 'other' END " +
  7241. "WHERE file_type IS NULL OR file_type = '' " +
  7242. "OR file_type NOT IN ('image','video','audio','document','other')");
  7243. if (n > 0) {
  7244. log.info("素材库回填 file_type: {} 条", n);
  7245. }
  7246. } catch (Exception e) {
  7247. log.warn("素材库回填 file_type 失败: {}", e.getMessage());
  7248. }
  7249. try {
  7250. List<Map<String, Object>> cats = jdbcTemplate.queryForList(
  7251. "SELECT category FROM file_record WHERE category IS NOT NULL AND category <> '' GROUP BY category");
  7252. int tagged = 0;
  7253. for (Map<String, Object> row : cats) {
  7254. String category = String.valueOf(row.get("category"));
  7255. String tagName = com.etotem.cfc.util.AssetCategoryTag.label(category);
  7256. String color = com.etotem.cfc.util.AssetCategoryTag.color(category);
  7257. List<Map<String, Object>> found = jdbcTemplate.queryForList(
  7258. "SELECT id FROM file_tag WHERE name = ? LIMIT 1", tagName);
  7259. Long tagId;
  7260. if (found.isEmpty()) {
  7261. jdbcTemplate.update(
  7262. "INSERT INTO file_tag (name, color, sort_order, created_at) VALUES (?, ?, 0, NOW())",
  7263. tagName, color);
  7264. tagId = jdbcTemplate.queryForObject(
  7265. "SELECT id FROM file_tag WHERE name = ? LIMIT 1", Long.class, tagName);
  7266. } else {
  7267. tagId = ((Number) found.get(0).get("id")).longValue();
  7268. }
  7269. tagged += jdbcTemplate.update(
  7270. "INSERT IGNORE INTO file_tag_rel (file_id, tag_id) " +
  7271. "SELECT id, ? FROM file_record WHERE category = ?",
  7272. tagId, category);
  7273. }
  7274. if (tagged > 0) {
  7275. log.info("素材库按分类补标签: {} 条", tagged);
  7276. }
  7277. } catch (Exception e) {
  7278. log.warn("素材库按分类补标签失败: {}", e.getMessage());
  7279. }
  7280. }
  7281. private void runMigration99() {
  7282. // 迁移99: 旧关系类型数据回填到辈分字段(TASK-P0-001 辈分重构)
  7283. try {
  7284. jdbcTemplate.execute("UPDATE family_members SET generation = 0, is_spouse = 1 WHERE relationship_type = 'spouse'");
  7285. jdbcTemplate.execute("UPDATE family_members SET generation = 1 WHERE relationship_type = 'parent'");
  7286. jdbcTemplate.execute("UPDATE family_members SET generation = -1 WHERE relationship_type = 'child'");
  7287. jdbcTemplate.execute("UPDATE family_members SET generation = 0 WHERE relationship_type = 'sibling'");
  7288. jdbcTemplate.execute("UPDATE family_members SET generation = 0 WHERE relationship_type IS NULL");
  7289. log.info("迁移99: 已回填generation/is_spouse旧数据");
  7290. } catch (Exception e) {
  7291. log.error("迁移99 失败: " + e.getMessage());
  7292. }
  7293. ensureColumn("products", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'");
  7294. ensureColumn("growth_records", "family_member_id", "BIGINT COMMENT '家庭成员ID'");
  7295. ensureColumn("growth_records", "family_member_age", "INT COMMENT '家庭成员年龄(月)'");
  7296. ensureColumn("growth_records", "school", "VARCHAR(100) COMMENT '学校名称'");
  7297. ensureColumn("growth_records", "grade", "INT COMMENT '年级(数字)'");
  7298. ensureColumn("growth_records", "academic_performance", "VARCHAR(200) COMMENT '成绩/学业表现'");
  7299. ensureColumn("growth_records", "school_province", "VARCHAR(50) COMMENT '学校所在省'");
  7300. ensureColumn("growth_records", "school_city", "VARCHAR(50) COMMENT '学校所在市'");
  7301. ensureColumn("growth_records", "school_district", "VARCHAR(50) COMMENT '学校所在区'");
  7302. ensureColumn("growth_records", "school_street", "VARCHAR(100) COMMENT '学校所在街道'");
  7303. }
  7304. private void runMigration100() {
  7305. // 迁移100: 删除废弃列和关系类型配置表(TASK-P0-001 辈分重构)
  7306. try {
  7307. jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN relationship_type");
  7308. log.info("已删除family_members.relationship_type列");
  7309. } catch (Exception e) {
  7310. // 列已不存在,忽略
  7311. }
  7312. try {
  7313. jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN role_override");
  7314. log.info("已删除family_members.role_override列");
  7315. } catch (Exception e) {
  7316. // 列已不存在,忽略
  7317. }
  7318. }
  7319. // 迁移 101: product_skus 表添加 min_quantity 列(商品规格最小购买数限制)
  7320. private void runMigration101() {
  7321. ensureColumn("product_skus", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'");
  7322. }
  7323. // 迁移 102: health_gut_flora 表扩大 population_level 和 detection_rate 字段长度
  7324. private void runMigration102() {
  7325. // 将 VARCHAR(50) 扩大到 TEXT(处理 AI 解析出的长文本)
  7326. ensureColumn("health_gut_flora", "population_level",
  7327. "TEXT DEFAULT NULL COMMENT '处于人群%水平'");
  7328. ensureColumn("health_gut_flora", "detection_rate",
  7329. "TEXT DEFAULT NULL COMMENT '%正常人有检出'");
  7330. }
  7331. private void migration102() {
  7332. // 迁移 102: health_gut_flora 表扩大 population_level 和 detection_rate 字段长度(TEXT)
  7333. ensureColumn("health_gut_flora", "population_level",
  7334. "TEXT DEFAULT NULL COMMENT '处于人群%水平'");
  7335. ensureColumn("health_gut_flora", "detection_rate",
  7336. "TEXT DEFAULT NULL COMMENT '%正常人有检出'");
  7337. // 迁移 102: 创建 health_dimension_scores 表(7 维健康模型)
  7338. try {
  7339. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_dimension_scores (" +
  7340. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7341. "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  7342. "dimension VARCHAR(20) NOT NULL COMMENT '维度编码', " +
  7343. "score INT NOT NULL COMMENT '评分(0-100)', " +
  7344. "percentile INT COMMENT '百分位', " +
  7345. "data_source VARCHAR(20) COMMENT '数据来源', " +
  7346. "tier INT DEFAULT 3 COMMENT '数据可信度层级', " +
  7347. "raw_data TEXT COMMENT '原始数据JSON', " +
  7348. "assess_date DATE COMMENT '评估日期', " +
  7349. "expire_date DATE COMMENT '过期日期', " +
  7350. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7351. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7352. "INDEX idx_member_dim (member_id, dimension), " +
  7353. "INDEX idx_assess_date (assess_date)" +
  7354. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分'");
  7355. log.info("已创建health_dimension_scores表");
  7356. } catch (Exception e) {
  7357. log.warn("创建health_dimension_scores表可能已存在: {}", e.getMessage());
  7358. }
  7359. // 迁移102b: 创建 health_data_sources 表(多媒体数据源记录)
  7360. try {
  7361. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_data_sources (" +
  7362. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7363. "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  7364. "source_type VARCHAR(20) NOT NULL COMMENT '数据源类型: VOICE/PHOTO/REPORT/MANUAL', " +
  7365. "dimension_codes VARCHAR(100) COMMENT '关联维度编码(逗号分隔)', " +
  7366. "parsed_result TEXT COMMENT '解析结果JSON', " +
  7367. "file_url VARCHAR(500) COMMENT '原始文件URL', " +
  7368. "confidence DECIMAL(5,4) COMMENT 'AI解析置信度(0-1)', " +
  7369. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7370. "INDEX idx_member_source (member_id, source_type), " +
  7371. "INDEX idx_member_id (member_id)" +
  7372. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录'");
  7373. log.info("已创建health_data_sources表");
  7374. } catch (Exception e) {
  7375. log.warn("创建health_data_sources表可能已存在: {}", e.getMessage());
  7376. }
  7377. // 迁移103: health_indicators表添加collection_date列(指标采集日期)
  7378. ensureColumn("health_indicators", "collection_date", "DATETIME COMMENT '指标采集日期(报告检测日期)'");
  7379. // 迁移104: promotion_tier表确保team_size_1st/2nd/3rd列存在(MyBatis-Plus camelCase映射修正)
  7380. ensureColumn("promotion_tier", "team_size_1st", "INT DEFAULT 0 COMMENT '一层团队人数'");
  7381. ensureColumn("promotion_tier", "team_size_2nd", "INT DEFAULT 0 COMMENT '二层团队人数'");
  7382. ensureColumn("promotion_tier", "team_size_3rd", "INT DEFAULT 0 COMMENT '三层团队人数'");
  7383. // 迁移105: article_reading_records表添加article_id和updated_at列
  7384. try {
  7385. jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN article_id BIGINT DEFAULT NULL COMMENT '文章ID' AFTER id");
  7386. log.info("已添加article_id列到article_reading_records表");
  7387. } catch (Exception e) {
  7388. // 列已存在,忽略
  7389. }
  7390. try {
  7391. jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN updated_at DATETIME DEFAULT NULL COMMENT '更新时间' AFTER read_at");
  7392. log.info("已添加updated_at列到article_reading_records表");
  7393. } catch (Exception e) {
  7394. // 列已存在,忽略
  7395. }
  7396. try {
  7397. jdbcTemplate.execute("ALTER TABLE article_reading_records ADD INDEX idx_article_child_date (article_id, child_id, read_at)");
  7398. log.info("已添加idx_article_child_date索引");
  7399. } catch (Exception e) {
  7400. // 索引已存在,忽略
  7401. }
  7402. // 迁移106: 创建 report_summary 统一报告汇总表
  7403. try {
  7404. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_summary (" +
  7405. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
  7406. "report_type VARCHAR(32) NOT NULL COMMENT '报告类型', " +
  7407. "source_table VARCHAR(32) NOT NULL COMMENT '来源表', " +
  7408. "source_id BIGINT NOT NULL COMMENT '来源表记录ID', " +
  7409. "user_id BIGINT NOT NULL COMMENT '创建者用户ID', " +
  7410. "family_id BIGINT COMMENT '家庭ID', " +
  7411. "subject_id BIGINT COMMENT '报告主体ID', " +
  7412. "subject_name VARCHAR(50) COMMENT '报告主体名称', " +
  7413. "title VARCHAR(200) COMMENT '报告标题', " +
  7414. "summary TEXT COMMENT '摘要', " +
  7415. "file_url VARCHAR(500) COMMENT '文件URL', " +
  7416. "file_size BIGINT COMMENT '文件大小', " +
  7417. "file_name VARCHAR(200) COMMENT '文件名', " +
  7418. "has_file TINYINT DEFAULT 0 COMMENT '是否有文件', " +
  7419. "overall_score INT COMMENT '综合评分', " +
  7420. "report_date DATE COMMENT '报告日期', " +
  7421. "status VARCHAR(20) COMMENT '状态', " +
  7422. "detail_route VARCHAR(100) COMMENT '详情页路由', " +
  7423. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7424. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7425. "INDEX idx_user_id (user_id), " +
  7426. "INDEX idx_family_id (family_id), " +
  7427. "INDEX idx_subject_id (subject_id), " +
  7428. "INDEX idx_report_type (report_type), " +
  7429. "INDEX idx_report_date (report_date), " +
  7430. "INDEX idx_status (status), " +
  7431. "UNIQUE KEY uk_source (source_table, source_id)" +
  7432. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一报告汇总表'");
  7433. log.info("已创建 report_summary 表");
  7434. } catch (Exception e) {
  7435. // 表已存在,忽略
  7436. }
  7437. // 迁移107: tasks表添加dimension_code和dimension_weights列(五维维度支持)
  7438. try {
  7439. ensureColumn("tasks", "dimension_code", "VARCHAR(20) COMMENT '五维维度: body/mind/wisdom/action/wealth'");
  7440. ensureColumn("tasks", "dimension_weights", "JSON COMMENT '五维权重: {\"body\":0,\"mind\":0,\"wisdom\":0,\"action\":0,\"wealth\":0}'");
  7441. log.info("已完成tasks表维度字段添加");
  7442. } catch (Exception e) {
  7443. log.warn("添加tasks表维度字段失败: {}", e.getMessage());
  7444. }
  7445. // 迁移108: 创建 energy_behavior_config 表(能量行为配置)
  7446. try {
  7447. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS energy_behavior_config (" +
  7448. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7449. "behavior_code VARCHAR(32) NOT NULL COMMENT '行为编码', " +
  7450. "behavior_name VARCHAR(50) NOT NULL COMMENT '行为名称', " +
  7451. "description VARCHAR(200) COMMENT '行为描述', " +
  7452. "default_amount INT DEFAULT 5 COMMENT '默认能量值', " +
  7453. "amount_source VARCHAR(32) DEFAULT 'fixed' COMMENT '能量值来源', " +
  7454. "dimension_assign JSON COMMENT '维度分配JSON', " +
  7455. "daily_limit INT DEFAULT 0 COMMENT '每日上限', " +
  7456. "cooldown_seconds INT DEFAULT 0 COMMENT '冷却时间(秒)', " +
  7457. "expire_days INT DEFAULT 0 COMMENT '过期天数', " +
  7458. "enabled TINYINT DEFAULT 1 COMMENT '是否启用', " +
  7459. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7460. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7461. "UNIQUE KEY uk_behavior_code (behavior_code)" +
  7462. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量行为配置表'");
  7463. log.info("已创建 energy_behavior_config 表");
  7464. // 插入预置数据(仅当表为空时插入)
  7465. Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM energy_behavior_config", Integer.class);
  7466. if (count == null || count == 0) {
  7467. String[][] seedData = {
  7468. {"task_complete", "完成任务", "完成任务获得的能量", "5", "points", "[{\"dim\":\"action\",\"ratio\":100}]", "200", "0", "0"},
  7469. {"game_finish", "完成游戏", "完成小游戏获得的能量", "5", "points", "[{\"dim\":\"mind\",\"ratio\":30},{\"dim\":\"wisdom\",\"ratio\":50},{\"dim\":\"action\",\"ratio\":20}]", "200", "0", "0"},
  7470. {"activity_checkin", "活动签到", "活动签到获得的能量", "5", "fixed", "[{\"dim\":\"body\",\"ratio\":30},{\"dim\":\"action\",\"ratio\":70}]", "100", "0", "0"},
  7471. {"article_read", "阅读文章", "阅读文章获得的能量", "5", "points", "[{\"dim\":\"mind\",\"ratio\":30},{\"dim\":\"wisdom\",\"ratio\":50},{\"dim\":\"action\",\"ratio\":20}]", "100", "0", "0"},
  7472. {"product_purchase", "商品购买", "购买商品获得的能量", "10", "order_amount", "[{\"dim\":\"wealth\",\"ratio\":100}]", "500", "0", "30"},
  7473. {"health_checkin", "健康打卡", "健康打卡获得的能量", "5", "fixed", "[{\"dim\":\"body\",\"ratio\":100}]", "50", "0", "30"},
  7474. {"finance_checkin", "理财打卡", "理财打卡获得的能量", "5", "fixed", "[{\"dim\":\"wealth\",\"ratio\":100}]", "50", "0", "0"},
  7475. {"emotion_checkin", "情绪打卡", "情绪打卡获得的能量", "5", "fixed", "[{\"dim\":\"mind\",\"ratio\":100}]", "40", "0", "0"},
  7476. {"appointment_submit", "提交测评预约", "提交测评预约获得的能量", "10", "fixed", "[{\"dim\":\"action\",\"ratio\":100}]", "50", "0", "0"},
  7477. {"appointment_complete", "完成测评", "完成测评获得的能量", "30", "fixed", "[{\"dim\":\"mind\",\"ratio\":50},{\"dim\":\"wisdom\",\"ratio\":50}]", "100", "0", "0"},
  7478. {"streak_daily", "连续打卡每日", "每日连续打卡获得的能量", "5", "fixed", "[{\"dim\":\"body\",\"ratio\":30},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":50}]", "50", "86400", "0"},
  7479. {"streak_milestone", "打卡里程碑", "达到打卡里程碑获得的能量", "20", "config", "[{\"dim\":\"body\",\"ratio\":30},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":50}]", "100", "0", "0"},
  7480. {"growth_task", "成长任务", "完成每日成长任务获得的能量", "10", "config", "[{\"dim\":\"body\",\"ratio\":20},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"wisdom\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":20},{\"dim\":\"wealth\",\"ratio\":20}]", "100", "0", "1"},
  7481. {"micro_action", "微行动", "完成微行动获得的能量", "1", "fixed", "[{\"dim\":\"action\",\"ratio\":100}]", "20", "60", "30"},
  7482. {"onboarding", "新手引导", "完成新手引导任务获得的能量", "10", "config", "[{\"dim\":\"body\",\"ratio\":20},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"wisdom\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":20},{\"dim\":\"wealth\",\"ratio\":20}]", "50", "0", "30"},
  7483. {"invite_milestone", "邀请里程碑", "达到邀请人数里程碑获得的能量", "30", "config", "[{\"dim\":\"action\",\"ratio\":40},{\"dim\":\"wealth\",\"ratio\":60}]", "200", "0", "30"},
  7484. {"dimension_sync", "维度同步", "健康维度数据上传同步获得的能量", "10", "config", "[{\"dim\":\"body\",\"ratio\":100}]", "100", "0", "30"},
  7485. {"invite_friend", "邀请好友加入", "邀请好友注册加入获得的能量", "20", "fixed", "[{\"dim\":\"action\",\"ratio\":50},{\"dim\":\"wealth\",\"ratio\":50}]", "200", "0", "0"},
  7486. {"invite_family", "邀请家庭成员", "邀请家庭成员加入家庭获得的能量", "10", "fixed", "[{\"dim\":\"mind\",\"ratio\":30},{\"dim\":\"action\",\"ratio\":70}]", "100", "0", "0"},
  7487. {"report_upload", "上传体检报告", "上传体检报告获得的能量", "30", "fixed", "[{\"dim\":\"body\",\"ratio\":100}]", "100", "0", "30"},
  7488. {"plan_complete", "完成成长计划", "完成成长计划复盘获得的能量", "20", "fixed", "[{\"dim\":\"body\",\"ratio\":20},{\"dim\":\"mind\",\"ratio\":20},{\"dim\":\"wisdom\",\"ratio\":20},{\"dim\":\"action\",\"ratio\":20},{\"dim\":\"wealth\",\"ratio\":20}]", "100", "0", "0"},
  7489. {"wisdom_report", "上传智测评报告", "上传智慧测评报告获得的能量", "30", "fixed", "[{\"dim\":\"wisdom\",\"ratio\":100}]", "100", "0", "30"},
  7490. };
  7491. String sql = "INSERT INTO energy_behavior_config (behavior_code, behavior_name, description, default_amount, amount_source, dimension_assign, daily_limit, cooldown_seconds, expire_days, enabled) VALUES (?,?,?,?,?,?,?,?,?,1)";
  7492. for (String[] row : seedData) {
  7493. try {
  7494. jdbcTemplate.update(sql, row[0], row[1], row[2], Integer.parseInt(row[3]), row[4], row[5], Integer.parseInt(row[6]), Integer.parseInt(row[7]), Integer.parseInt(row[8]));
  7495. } catch (Exception e) {
  7496. log.warn("插入能量行为配置失败: behavior_code={}, error={}", row[0], e.getMessage());
  7497. }
  7498. }
  7499. log.info("已插入{}条能量行为配置", seedData.length);
  7500. }
  7501. } catch (Exception e) {
  7502. log.warn("创建 energy_behavior_config 表失败: {}", e.getMessage());
  7503. }
  7504. // 迁移109: finance_checkins表添加type列(记账类型: income/expense)
  7505. try {
  7506. ensureColumn("finance_checkins", "type", "VARCHAR(10) DEFAULT 'expense' COMMENT '类型: income(收入)/expense(支出)'");
  7507. log.info("已完成finance_checkins.type字段添加");
  7508. } catch (Exception e) {
  7509. log.warn("添加finance_checkins.type字段失败: {}", e.getMessage());
  7510. }
  7511. // 迁移110: 创建 article_comments 表(文章评论)
  7512. try {
  7513. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_comments (" +
  7514. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7515. "article_id BIGINT NOT NULL COMMENT '文章ID', " +
  7516. "parent_id BIGINT COMMENT '父评论ID', " +
  7517. "reply_to_id BIGINT COMMENT '引用的评论ID', " +
  7518. "user_id BIGINT NOT NULL COMMENT '评论用户ID', " +
  7519. "content TEXT NOT NULL COMMENT '评论内容', " +
  7520. "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/approved/rejected', " +
  7521. "audit_by BIGINT COMMENT '审核人ID', " +
  7522. "audit_at DATETIME COMMENT '审核时间', " +
  7523. "earned_energy INT DEFAULT 0 COMMENT '获得的能量', " +
  7524. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7525. "INDEX idx_article (article_id), " +
  7526. "INDEX idx_article_status (article_id, status), " +
  7527. "INDEX idx_parent (parent_id)" +
  7528. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章评论'");
  7529. log.info("已创建 article_comments 表");
  7530. } catch (Exception e) {
  7531. log.warn("创建 article_comments 表失败: {}", e.getMessage());
  7532. }
  7533. // 迁移111: 创建 service_role_applications 表(服务角色统一申请)
  7534. try {
  7535. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_role_applications (" +
  7536. "id int(11) NOT NULL AUTO_INCREMENT, " +
  7537. "user_id int(11) NOT NULL COMMENT '申请人ID', " +
  7538. "roles varchar(255) NOT NULL COMMENT '申请角色,JSON数组', " +
  7539. "id_card varchar(18) DEFAULT '' COMMENT '身份证号', " +
  7540. "id_card_images text COMMENT '身份证照片,JSON数组', " +
  7541. "certificate_images text COMMENT '资质证书照片,JSON数组', " +
  7542. "province varchar(50) DEFAULT '' COMMENT '省', " +
  7543. "city varchar(50) DEFAULT '' COMMENT '市', " +
  7544. "district varchar(50) DEFAULT '' COMMENT '区', " +
  7545. "street varchar(50) DEFAULT '' COMMENT '街道', " +
  7546. "address_detail varchar(200) DEFAULT '' COMMENT '详细地址', " +
  7547. "status tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-pending 1-approved 2-rejected', " +
  7548. "reject_reason varchar(500) DEFAULT '' COMMENT '驳回原因', " +
  7549. "reviewed_by int(11) DEFAULT NULL COMMENT '审核人ID', " +
  7550. "reviewed_at datetime DEFAULT NULL COMMENT '审核时间', " +
  7551. "created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  7552. "updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7553. "PRIMARY KEY (id), " +
  7554. "KEY idx_user_id (user_id), " +
  7555. "KEY idx_status (status)" +
  7556. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表'");
  7557. log.info("已创建service_role_applications表");
  7558. } catch (Exception e) {
  7559. // 表已存在,忽略错误
  7560. }
  7561. // 迁移112: users表添加article_admin_status字段(文章管理员状态)
  7562. log.info("迁移112: 开始添加article_admin_status列到users表");
  7563. try {
  7564. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected'");
  7565. log.info("迁移112: 已添加article_admin_status列到users表");
  7566. } catch (Exception e) {
  7567. // 列已存在,幂等忽略(用warn而非error避免日志噪声)
  7568. log.warn("迁移112: 添加article_admin_status列失败(可能已存在): {}", e.getMessage());
  7569. }
  7570. // 迁移113: users表添加activity_provider_status字段(活动提供商状态)
  7571. try {
  7572. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN activity_provider_status VARCHAR(20) DEFAULT NULL COMMENT '活动提供商状态: pending/approved/rejected'");
  7573. log.info("已添加activity_provider_status列到users表");
  7574. } catch (Exception e) {
  7575. // 列已存在,忽略错误
  7576. }
  7577. // 迁移114: 创建 periodic_services 表(周期性服务续费配置)
  7578. try {
  7579. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS periodic_services (" +
  7580. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7581. "product_id BIGINT NOT NULL COMMENT '关联商品ID', " +
  7582. "service_name VARCHAR(100) COMMENT '服务名称', " +
  7583. "cycle_days INT NOT NULL COMMENT '周期天数(如365=年度服务)', " +
  7584. "remind_before_days INT DEFAULT 30 COMMENT '提前提醒天数', " +
  7585. "auto_renew TINYINT DEFAULT 0 COMMENT '是否支持自动续费: 0=否 1=是', " +
  7586. "enabled TINYINT DEFAULT 1 COMMENT '是否启用: 0=禁用 1=启用', " +
  7587. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7588. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7589. "INDEX idx_product (product_id)" +
  7590. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='周期性服务续费配置'");
  7591. log.info("已创建 periodic_services 表");
  7592. } catch (Exception e) {
  7593. log.warn("创建 periodic_services 表失败: {}", e.getMessage());
  7594. }
  7595. // 迁移115: 创建 recommendation_logs 表(推荐行为记录)
  7596. try {
  7597. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_logs (" +
  7598. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7599. "user_id BIGINT COMMENT '用户ID', " +
  7600. "family_id BIGINT COMMENT '家庭ID', " +
  7601. "child_id BIGINT COMMENT '孩子ID', " +
  7602. "content_type VARCHAR(20) NOT NULL COMMENT '内容类型: article/task/product/activity', " +
  7603. "content_id BIGINT NOT NULL COMMENT '内容ID', " +
  7604. "recommend_score INT COMMENT '推荐分值', " +
  7605. "action VARCHAR(20) COMMENT '行为: show/click/consume', " +
  7606. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7607. "INDEX idx_user_content (user_id, content_type, content_id)" +
  7608. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐行为记录'");
  7609. log.info("已创建 recommendation_logs 表");
  7610. } catch (Exception e) {
  7611. log.warn("创建 recommendation_logs 表失败: {}", e.getMessage());
  7612. }
  7613. // 迁移116: products表添加family_scope列(推荐系统-家庭消费去重)
  7614. try {
  7615. ensureColumn("products", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除'");
  7616. log.info("已完成products.family_scope字段添加");
  7617. } catch (Exception e) {
  7618. log.warn("添加products.family_scope字段失败: {}", e.getMessage());
  7619. }
  7620. // 迁移117: activities表添加family_scope列(推荐系统-家庭参与去重)
  7621. try {
  7622. ensureColumn("activities", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除'");
  7623. log.info("已完成activities.family_scope字段添加");
  7624. } catch (Exception e) {
  7625. log.warn("添加activities.family_scope字段失败: {}", e.getMessage());
  7626. }
  7627. // 迁移118: activity_registrations表child_id改为可空 + 添加registrant_name/phone(报名表单-多人报名)
  7628. try {
  7629. ensureColumn("activity_registrations", "registrant_name", "VARCHAR(100) COMMENT '报名人姓名'");
  7630. log.info("已完成activity_registrations.registrant_name字段添加");
  7631. } catch (Exception e) {
  7632. log.warn("添加activity_registrations.registrant_name字段失败: {}", e.getMessage());
  7633. }
  7634. try {
  7635. ensureColumn("activity_registrations", "registrant_phone", "VARCHAR(20) COMMENT '报名人联系方式'");
  7636. log.info("已完成activity_registrations.registrant_phone字段添加");
  7637. } catch (Exception e) {
  7638. log.warn("添加activity_registrations.registrant_phone字段失败: {}", e.getMessage());
  7639. }
  7640. try {
  7641. jdbcTemplate.execute("ALTER TABLE activity_registrations MODIFY COLUMN child_id BIGINT COMMENT '孩子ID(手动输入报名人为空)'");
  7642. log.info("已完成activity_registrations.child_id改为可空");
  7643. } catch (Exception e) {
  7644. log.warn("修改activity_registrations.child_id失败: {}", e.getMessage());
  7645. }
  7646. // 迁移119: package_orders表添加联系人字段(产品页下单)
  7647. try {
  7648. ensureColumn("package_orders", "contact_name", "VARCHAR(50) COMMENT '联系人姓名'");
  7649. ensureColumn("package_orders", "contact_phone", "VARCHAR(20) COMMENT '联系人手机号'");
  7650. ensureColumn("package_orders", "contact_address", "VARCHAR(500) COMMENT '收件地址'");
  7651. log.info("已完成package_orders表联系人字段添加");
  7652. } catch (Exception e) {
  7653. log.warn("添加package_orders联系人字段失败: {}", e.getMessage());
  7654. }
  7655. // 迁移120: activities表添加registration_deadline列(活动中心-报名截止时间展示)
  7656. try {
  7657. ensureColumn("activities", "registration_deadline", "DATETIME COMMENT '报名截止时间'");
  7658. log.info("已完成activities.registration_deadline字段添加");
  7659. } catch (Exception e) {
  7660. log.warn("添加activities.registration_deadline字段失败: {}", e.getMessage());
  7661. }
  7662. // 迁移143: 创建小程序虚拟支付道具映射表
  7663. try {
  7664. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS virtual_goods_config (" +
  7665. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7666. "goods_type VARCHAR(32) NOT NULL COMMENT 'ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION', " +
  7667. "biz_key VARCHAR(64) NOT NULL COMMENT '业务SKU标识: 测评套餐ID / 会员等级code / 订阅档位code', " +
  7668. "product_id VARCHAR(64) NOT NULL COMMENT '微信商户后台道具ID', " +
  7669. "goods_name VARCHAR(128) NOT NULL COMMENT '道具名称', " +
  7670. "status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用', " +
  7671. "create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7672. "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7673. "UNIQUE KEY uk_goods_type_biz (goods_type, biz_key)" +
  7674. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序虚拟支付道具映射'");
  7675. log.info("已创建virtual_goods_config表");
  7676. } catch (Exception e) {
  7677. log.warn("创建virtual_goods_config表失败: {}", e.getMessage());
  7678. }
  7679. // 迁移144: users表添加session_key列(小程序虚拟支付signature签名会话密钥)
  7680. try {
  7681. ensureColumn("users", "session_key", "VARCHAR(64) COMMENT '微信登录会话密钥(虚拟支付签名用)'");
  7682. log.info("已完成users.session_key字段添加");
  7683. } catch (Exception e) {
  7684. log.warn("添加users.session_key字段失败: {}", e.getMessage());
  7685. }
  7686. // 迁移145: 创建家庭天盘周报导出记录表(ReportSummaryService 全量同步依赖,此前从未建表)
  7687. try {
  7688. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_fortune_report (" +
  7689. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7690. "family_id INT NOT NULL COMMENT '家庭ID', " +
  7691. "element VARCHAR(32) COMMENT '主导五行元素', " +
  7692. "lucky_direction VARCHAR(64) COMMENT '本周吉位', " +
  7693. "pdf_path VARCHAR(255) COMMENT 'PDF周报文件路径', " +
  7694. "file_size BIGINT COMMENT '文件大小(字节)', " +
  7695. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7696. "INDEX idx_family_id (family_id)" +
  7697. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭天盘周报导出记录'");
  7698. log.info("已创建family_fortune_report表");
  7699. } catch (Exception e) {
  7700. log.warn("创建family_fortune_report表失败: {}", e.getMessage());
  7701. }
  7702. // 迁移146: 创建五维家庭自检记录表(P0-1 五维自检问卷,书稿终章15题)
  7703. try {
  7704. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS five_dimension_self_checks (" +
  7705. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7706. "user_id BIGINT NOT NULL COMMENT '答卷人用户ID', " +
  7707. "family_member_id BIGINT COMMENT '关联家庭成员ID(可选)', " +
  7708. "answers_json TEXT COMMENT '用户答案JSON', " +
  7709. "scores_json VARCHAR(512) COMMENT '各维度得分JSON', " +
  7710. "total_score INT COMMENT '五维总分(满分45)', " +
  7711. "advice_json TEXT COMMENT '五行相生寻源建议JSON', " +
  7712. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7713. "INDEX idx_user_id (user_id)" +
  7714. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维家庭自检记录'");
  7715. log.info("已创建five_dimension_self_checks表");
  7716. } catch (Exception e) {
  7717. log.warn("创建five_dimension_self_checks表失败: {}", e.getMessage());
  7718. }
  7719. // 迁移147: virtual_goods_config 插入会员年卡道具映射(虚拟支付上线准备)
  7720. try {
  7721. jdbcTemplate.execute("INSERT IGNORE INTO virtual_goods_config (goods_type, biz_key, product_id, goods_name, status) " +
  7722. "VALUES ('MEMBERSHIP', 'member1314', 'TBD', '会员年卡', 1)");
  7723. log.info("已插入会员年卡道具映射: member1314");
  7724. } catch (Exception e) {
  7725. log.warn("插入会员年卡道具映射失败: {}", e.getMessage());
  7726. }
  7727. // 迁移148: 修复 membership_levels 表重复行 + 添加 UNIQUE KEY uk_level_code
  7728. // 原因:早期建表缺少 UNIQUE KEY uk_level_code,加上 INSERT IGNORE 在无唯一键时反复插入,
  7729. // 导致生产环境 FREE/FAMILY/PROVIDER 三个等级各 69 行重复,
  7730. // MembershipService.createOrder() 调用 selectOne(level_code) 触发 TooManyResultsException。
  7731. try {
  7732. // 1) 删除重复行:保留每个 level_code 最早插入的(id 最小),其余删除
  7733. int deleted = jdbcTemplate.update(
  7734. "DELETE m1 FROM membership_levels m1 " +
  7735. "INNER JOIN membership_levels m2 " +
  7736. "ON m1.level_code = m2.level_code AND m1.id > m2.id");
  7737. log.info("已清理 membership_levels 重复行: 删除{}条", deleted);
  7738. } catch (Exception e) {
  7739. log.warn("清理 membership_levels 重复行失败: {}", e.getMessage());
  7740. }
  7741. try {
  7742. // 2) 添加 UNIQUE KEY(幂等:先查再 ADD)
  7743. Integer idxCount = jdbcTemplate.queryForObject(
  7744. "SELECT COUNT(*) FROM information_schema.statistics " +
  7745. "WHERE table_schema = DATABASE() AND table_name = 'membership_levels' AND index_name = 'uk_level_code'",
  7746. Integer.class);
  7747. if (idxCount == null || idxCount == 0) {
  7748. jdbcTemplate.execute("ALTER TABLE membership_levels ADD UNIQUE KEY uk_level_code (level_code)");
  7749. log.info("已添加 uk_level_code 唯一索引到 membership_levels 表");
  7750. } else {
  7751. log.info("uk_level_code 唯一索引已存在,跳过添加");
  7752. }
  7753. } catch (Exception e) {
  7754. log.warn("添加 uk_level_code 唯一索引失败: {}", e.getMessage());
  7755. }
  7756. // 迁移149: payment_orders表添加user_coupon_id列(VirtualPayTimeoutTask selectList 触发 Unknown column)
  7757. // 原因:旧库建表时缺少此列,entity PaymentOrder 已声明 userCouponId,导致 MyBatis-Plus 全列查询失败。
  7758. // 本地与远端新库已有,本迁移对它们幂等。
  7759. try {
  7760. ensureColumn("payment_orders", "user_coupon_id", "BIGINT COMMENT '使用的用户优惠券ID'");
  7761. log.info("已确保payment_orders.user_coupon_id列存在");
  7762. } catch (Exception e) {
  7763. log.warn("添加payment_orders.user_coupon_id列失败: {}", e.getMessage());
  7764. }
  7765. // 迁移150: activities表添加member_price列(会员价支持)
  7766. try {
  7767. ensureColumn("activities", "member_price", "INT COMMENT '会员价(分)' AFTER price");
  7768. log.info("已确保activities.member_price列存在");
  7769. } catch (Exception e) {
  7770. log.warn("添加activities.member_price列失败: {}", e.getMessage());
  7771. }
  7772. // 迁移151: growth_records表补齐学校/年级字段(GrowthRecord 实体全列查询 Unknown column 'school')
  7773. // 原因:实体已声明 school/grade/academicPerformance/schoolProvince/schoolCity/schoolDistrict/schoolStreet,
  7774. // 且 @TableField 为 camelCase 列名,但旧库建表时缺失这些列;
  7775. // runMigration99() 为死代码从未执行,且其 snake_case 列名与实体 camelCase 不匹配。
  7776. ensureColumn("growth_records", "school", "VARCHAR(100) COMMENT '学校名称'");
  7777. ensureColumn("growth_records", "grade", "INT COMMENT '年级(数字)'");
  7778. ensureColumn("growth_records", "academicPerformance", "VARCHAR(200) COMMENT '成绩/学业表现'");
  7779. ensureColumn("growth_records", "schoolProvince", "VARCHAR(50) COMMENT '学校所在省'");
  7780. ensureColumn("growth_records", "schoolCity", "VARCHAR(50) COMMENT '学校所在市'");
  7781. ensureColumn("growth_records", "schoolDistrict", "VARCHAR(50) COMMENT '学校所在区'");
  7782. ensureColumn("growth_records", "schoolStreet", "VARCHAR(100) COMMENT '学校所在街道'");
  7783. log.info("已确保growth_records学校/年级相关列存在");
  7784. // 迁移152: 创建 product_gift_rules 表(商品赠送会员规则)
  7785. try {
  7786. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_rules (" +
  7787. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7788. "product_id BIGINT NOT NULL COMMENT '关联products表', " +
  7789. "level_code VARCHAR(50) NOT NULL COMMENT '赠送会员等级编码(FAMILY/PREMIUM等)', " +
  7790. "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " +
  7791. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7792. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7793. "UNIQUE KEY uk_product_level (product_id, level_code), " +
  7794. "INDEX idx_product_id (product_id)" +
  7795. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠送会员规则'");
  7796. log.info("创建product_gift_rules表成功");
  7797. } catch (Exception e) {
  7798. log.warn("创建product_gift_rules表失败: {}", e.getMessage());
  7799. }
  7800. // 迁移153: 创建 growth_archive_products 表(成长档案关联商品配置)
  7801. try {
  7802. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS growth_archive_products (" +
  7803. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7804. "product_id BIGINT NOT NULL COMMENT '关联products表', " +
  7805. "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " +
  7806. "sort_order INT DEFAULT 0 COMMENT '展示排序(升序)', " +
  7807. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  7808. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
  7809. "UNIQUE KEY uk_product_id (product_id), " +
  7810. "KEY idx_status (status)" +
  7811. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长档案关联商品配置'");
  7812. log.info("已创建growth_archive_products表");
  7813. } catch (Exception e) {
  7814. log.warn("创建growth_archive_products表失败: {}", e.getMessage());
  7815. }
  7816. // 迁移154: membership_levels 表插入 PREMIUM 等级种子数据(前端升级页可选"高级家庭会员",createOrder 查表为 null 抛"会员等级不存在")
  7817. try {
  7818. jdbcTemplate.execute("INSERT IGNORE INTO membership_levels (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " +
  7819. "('PREMIUM', '高级家庭会员', '高级家庭会员专属功能', 131400, NULL, 1316800, 10, 50, 1, 1, 1)");
  7820. log.info("已插入PREMIUM等级种子数据");
  7821. } catch (Exception e) {
  7822. log.warn("插入PREMIUM等级种子数据失败: {}", e.getMessage());
  7823. }
  7824. // 迁移155: 创建 interaction_logs 表(关系互动记录)
  7825. try {
  7826. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS interaction_logs (" +
  7827. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7828. "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " +
  7829. "from_member_id BIGINT NOT NULL COMMENT '互动发起方成员ID', " +
  7830. "to_member_id BIGINT NOT NULL COMMENT '互动接收方成员ID', " +
  7831. "interaction_type VARCHAR(20) NOT NULL COMMENT '互动类型: dining/outgoing/call/video/chat/play/gift', " +
  7832. "description VARCHAR(500) COMMENT '互动描述', " +
  7833. "happened_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间', " +
  7834. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7835. "INDEX idx_ilog_family (family_id), " +
  7836. "INDEX idx_ilog_to_member (to_member_id) " +
  7837. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员互动记录'");
  7838. log.info("已创建interaction_logs表");
  7839. } catch (Exception e) {
  7840. // 表已存在,忽略
  7841. }
  7842. // 迁移156: 创建 relationship_questionnaire_snapshots 表(关系问卷快照)
  7843. try {
  7844. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (" +
  7845. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7846. "family_member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  7847. "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " +
  7848. "member_name VARCHAR(50) COMMENT '成员名称', " +
  7849. "relationship_type VARCHAR(20) COMMENT '关系类型: parent/child', " +
  7850. "ai_generated_json TEXT COMMENT 'AI生成的题目JSON', " +
  7851. "version VARCHAR(20) COMMENT '版本号', " +
  7852. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7853. "INDEX idx_q_snap_member (family_member_id) " +
  7854. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷快照'");
  7855. log.info("已创建relationship_questionnaire_snapshots表");
  7856. } catch (Exception e) {
  7857. // 表已存在,忽略
  7858. }
  7859. // 迁移158: users.family_id 允许 NULL(新用户微信登录时无家庭)
  7860. try {
  7861. jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN family_id BIGINT COMMENT '家庭ID'");
  7862. log.info("已修改users.family_id允许NULL");
  7863. } catch (Exception e) {
  7864. // 忽略错误
  7865. }
  7866. // 迁移157: 创建 relationship_questionnaire_responses 表(关系问卷回答)
  7867. try {
  7868. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (" +
  7869. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7870. "snapshot_id BIGINT NOT NULL COMMENT '问卷快照ID', " +
  7871. "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " +
  7872. "answers_json TEXT COMMENT '用户回答JSON', " +
  7873. "trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度得分', " +
  7874. "intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度得分', " +
  7875. "communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量得分', " +
  7876. "interaction_bonus DECIMAL(5,2) DEFAULT 0 COMMENT '互动记录加分', " +
  7877. "total_score DECIMAL(5,2) DEFAULT 0 COMMENT '综合得分', " +
  7878. "calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7879. "INDEX idx_q_resp_snap (snapshot_id) " +
  7880. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷回答记录'");
  7881. log.info("已创建relationship_questionnaire_responses表");
  7882. } catch (Exception e) {
  7883. // 表已存在,忽略
  7884. }
  7885. // 迁移158: family_challenge 添加 creator_id 列(用户自定义挑战)
  7886. ensureColumn("family_challenge", "creator_id", "BIGINT COMMENT '创建者ID(NULL=系统创建)'");
  7887. // 迁移159: guide_orders表order_no加唯一约束(防重复下单,整改R1)
  7888. try {
  7889. // 先清理历史重复数据:每组order_no保留id最小的一行
  7890. jdbcTemplate.execute("DELETE g1 FROM guide_orders g1 INNER JOIN guide_orders g2 " +
  7891. "ON g1.order_no = g2.order_no AND g1.id > g2.id");
  7892. log.info("已清理guide_orders重复order_no");
  7893. } catch (Exception e) {
  7894. // 忽略:无重复数据时删除0行,不报错
  7895. }
  7896. try {
  7897. jdbcTemplate.execute("ALTER TABLE guide_orders ADD UNIQUE KEY uk_order_no (order_no)");
  7898. log.info("已为guide_orders.order_no添加唯一约束");
  7899. } catch (Exception e) {
  7900. // 约束已存在,忽略
  7901. }
  7902. // 迁移160: payment_orders表order_no升级为唯一约束(防支付回调重复,整改R2)
  7903. try {
  7904. jdbcTemplate.execute("DELETE p1 FROM payment_orders p1 INNER JOIN payment_orders p2 " +
  7905. "ON p1.order_no = p2.order_no AND p1.id > p2.id");
  7906. log.info("已清理payment_orders重复order_no");
  7907. } catch (Exception e) {
  7908. // 忽略
  7909. }
  7910. try {
  7911. // 先删除原普通索引,再加唯一索引(同名列不可并存)
  7912. jdbcTemplate.execute("ALTER TABLE payment_orders DROP INDEX idx_order_no");
  7913. jdbcTemplate.execute("ALTER TABLE payment_orders ADD UNIQUE KEY uk_order_no (order_no)");
  7914. log.info("已为payment_orders.order_no添加唯一约束");
  7915. } catch (Exception e) {
  7916. // 索引已存在或已处理,忽略
  7917. }
  7918. // 迁移161: 创建 daily_checkin 表(每日健康综合打卡,含饮食/运动/睡眠/饮水/心情评分)
  7919. try {
  7920. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS daily_checkin (" +
  7921. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7922. "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  7923. "checkin_date DATE COMMENT '打卡日期', " +
  7924. "diet_score INT COMMENT '饮食评分', " +
  7925. "exercise_score INT COMMENT '运动评分', " +
  7926. "sleep_score INT COMMENT '睡眠评分', " +
  7927. "water_intake INT COMMENT '饮水量(ml)', " +
  7928. "mood_score INT COMMENT '心情评分', " +
  7929. "weight_kg DECIMAL(5,2) COMMENT '体重(kg)', " +
  7930. "height_cm DECIMAL(5,2) COMMENT '身高(cm)', " +
  7931. "remark TEXT COMMENT '备注', " +
  7932. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7933. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  7934. "INDEX idx_member_id (member_id)" +
  7935. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康打卡表'");
  7936. log.info("已创建daily_checkin表");
  7937. } catch (Exception ex) {
  7938. log.warn("创建daily_checkin表失败: {}", ex.getMessage());
  7939. }
  7940. // 迁移162: 创建 exercise_record 表(运动打卡记录)
  7941. try {
  7942. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS exercise_record (" +
  7943. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7944. "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  7945. "checkin_id BIGINT COMMENT '关联打卡ID', " +
  7946. "exercise_type VARCHAR(50) COMMENT '运动类型', " +
  7947. "duration_minutes INT COMMENT '时长(分钟)', " +
  7948. "intensity VARCHAR(20) COMMENT '强度: low/medium/high', " +
  7949. "calories_burned INT COMMENT '消耗卡路里', " +
  7950. "heart_rate_avg INT COMMENT '平均心率', " +
  7951. "heart_rate_max INT COMMENT '最大心率', " +
  7952. "step_count INT COMMENT '微信运动步数', " +
  7953. "start_time DATETIME COMMENT '开始时间', " +
  7954. "end_time DATETIME COMMENT '结束时间', " +
  7955. "remark TEXT COMMENT '备注', " +
  7956. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7957. "INDEX idx_member_id (member_id)" +
  7958. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动打卡记录'");
  7959. log.info("已创建exercise_record表");
  7960. } catch (Exception ex) {
  7961. log.warn("创建exercise_record表失败: {}", ex.getMessage());
  7962. }
  7963. // 迁移163: family_challenge 添加 creator_id 列(用户自定义挑战,原迁移158在runMigration100死代码中从未执行)
  7964. ensureColumn("family_challenge", "creator_id", "BIGINT COMMENT '创建者ID(NULL=系统创建)'");
  7965. // 迁移164: coupon表扩展会员券字段 + 创建coupon_grant_log表(会员体系重构: 统一价+优惠券发放/积分兑换)
  7966. try {
  7967. ensureColumn("coupon", "discount_rate", "INT COMMENT '折扣券折扣率(千分比,9000=9折)'");
  7968. ensureColumn("coupon", "product_id", "BIGINT COMMENT '绑定商品ID,NULL=全场通用'");
  7969. ensureColumn("coupon", "points_price", "INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换'");
  7970. ensureColumn("coupon", "grant_type", "VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放'");
  7971. ensureColumn("coupon", "grant_level_code", "VARCHAR(32) COMMENT '发放规则对应会员等级'");
  7972. ensureColumn("coupon", "grant_period", "VARCHAR(16) COMMENT 'PERIODIC周期:MONTHLY/QUARTERLY'");
  7973. ensureColumn("coupon", "grant_quantity", "INT DEFAULT 1 COMMENT '每次发放数量'");
  7974. log.info("已为coupon表添加会员券字段");
  7975. } catch (Exception e) {
  7976. log.warn("添加coupon表会员券字段失败: {}", e.getMessage());
  7977. }
  7978. try {
  7979. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS coupon_grant_log (" +
  7980. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  7981. "user_id BIGINT NOT NULL COMMENT '收券用户ID', " +
  7982. "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
  7983. "grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE', " +
  7984. "period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " +
  7985. "quantity INT DEFAULT 1 COMMENT '发放数量', " +
  7986. "source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " +
  7987. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  7988. "UNIQUE KEY uk_grant (user_id, coupon_id, grant_type, period), " +
  7989. "INDEX idx_coupon (coupon_id), " +
  7990. "INDEX idx_user (user_id)" +
  7991. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放流水表'");
  7992. log.info("已创建coupon_grant_log表");
  7993. } catch (Exception e) {
  7994. log.warn("创建coupon_grant_log表失败: {}", e.getMessage());
  7995. }
  7996. // 迁移165: virtual_goods_config 添加 goods_price 列(会员道具价格,用于拆单判断)
  7997. ensureColumn("virtual_goods_config", "goods_price", "INT COMMENT '道具价格(分), 用于拆单判断'");
  7998. // 迁移166: payment_orders 添加 group_no/period_no 列(会员拆单支付,配置驱动拆单)
  7999. ensureColumn("payment_orders", "group_no", "VARCHAR(64) COMMENT '拆单组号(同一拆单组共享)'");
  8000. ensureColumn("payment_orders", "period_no", "INT DEFAULT 1 COMMENT '期数序号(1开始)'");
  8001. // 迁移167: 回填 families.creator_id(早期版本从未写入,导致会员开通/虚拟支付兜底失败)
  8002. // 优先 is_family_admin=true 的用户
  8003. try {
  8004. jdbcTemplate.execute("UPDATE families f JOIN users u ON f.id = u.family_id AND u.is_family_admin = 1 " +
  8005. "SET f.creator_id = u.id WHERE f.creator_id IS NULL");
  8006. log.info("已回填families.creator_id(优先家庭管理员)");
  8007. } catch (Exception e) {
  8008. log.warn("回填families.creator_id(管理员)失败: {}", e.getMessage());
  8009. }
  8010. // 剩余家庭:任一 openid 非空用户(子查询确定性取一条)
  8011. try {
  8012. jdbcTemplate.execute("UPDATE families f SET f.creator_id = (" +
  8013. "SELECT u.id FROM users u WHERE u.family_id = f.id " +
  8014. "AND u.openid IS NOT NULL AND u.openid <> '' " +
  8015. "ORDER BY (u.is_family_admin = 1) DESC, u.id ASC LIMIT 1" +
  8016. ") WHERE f.creator_id IS NULL");
  8017. log.info("已回填families.creator_id(任一成员)");
  8018. } catch (Exception e) {
  8019. log.warn("回填families.creator_id(任一成员)失败: {}", e.getMessage());
  8020. }
  8021. // 迁移168: 修复文章表 null status 为 'draft'(旧版新建文章未设默认值导致无法编辑)
  8022. try {
  8023. jdbcTemplate.execute("UPDATE articles SET status = 'draft' WHERE status IS NULL");
  8024. log.info("已修复articles表中status为null的记录为draft");
  8025. } catch (Exception e) {
  8026. log.warn("修复articles表null status失败: {}", e.getMessage());
  8027. }
  8028. // 迁移169: 清理已废弃的 relationship_types 表(schema.sql 已移除,迁移代码中 ALTER 语句已清理)
  8029. try {
  8030. jdbcTemplate.execute("DROP TABLE IF EXISTS relationship_types");
  8031. log.info("已清理废弃的relationship_types表");
  8032. } catch (Exception e) {
  8033. log.warn("清理relationship_types表失败: {}", e.getMessage());
  8034. }
  8035. // 迁移170: 创建 family_join_requests 表(家庭加入请求待审批)
  8036. try {
  8037. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_join_requests (" +
  8038. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8039. "requester_user_id BIGINT NOT NULL COMMENT '申请加入的用户ID', " +
  8040. "target_family_id BIGINT NOT NULL COMMENT '目标家庭ID', " +
  8041. "join_method VARCHAR(20) NOT NULL COMMENT '加入方式: by_code/by_token', " +
  8042. "invite_ref VARCHAR(50) COMMENT '邀请码或令牌', " +
  8043. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected/cancelled', " +
  8044. "approver_user_id BIGINT COMMENT '审批人ID', " +
  8045. "comment TEXT COMMENT '审批备注', " +
  8046. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8047. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8048. "INDEX idx_requester (requester_user_id), " +
  8049. "INDEX idx_target_family (target_family_id), " +
  8050. "INDEX idx_status (status) " +
  8051. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭加入请求(待审批)'");
  8052. log.info("已创建family_join_requests表");
  8053. } catch (Exception e) {
  8054. // 表已存在,忽略
  8055. }
  8056. // 迁移171: 创建 knowledge_base_section 表(统一知识库按模块存储JSON,DB为查询源,classpath种子兜底)
  8057. try {
  8058. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS knowledge_base_section (" +
  8059. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8060. "module_key VARCHAR(100) NOT NULL COMMENT '模块键(如 食物营养库/菌属功能库/报告指标分级标准)', " +
  8061. "module_name VARCHAR(200) COMMENT '模块中文名', " +
  8062. "content LONGTEXT COMMENT '模块内容(JSON序列化)', " +
  8063. "version VARCHAR(50) COMMENT '数据版本(来自元数据.版本)', " +
  8064. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8065. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8066. "UNIQUE KEY uk_module_key (module_key) " +
  8067. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一知识库 - 按模块存储JSON'");
  8068. log.info("已创建knowledge_base_section表");
  8069. } catch (Exception e) {
  8070. // 表已存在,忽略
  8071. }
  8072. // 迁移172: products 添加 min_stock_alert 列(库存预警阈值,配合库存管理模块)
  8073. // 迁移173: 商品配送与履约扩展(自取/线上服务),需求: 配送方式1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人
  8074. // 173a: product_orders 新增履约字段
  8075. ensureColumn("product_orders", "pickup_location_id", "BIGINT COMMENT '自取地点ID'");
  8076. ensureColumn("product_orders", "pickup_location_snapshot", "TEXT COMMENT '自取地点快照(JSON)'");
  8077. ensureColumn("product_orders", "time_slot_id", "BIGINT COMMENT '所选时间段ID'");
  8078. ensureColumn("product_orders", "time_slot_snapshot", "TEXT COMMENT '时间段快照(JSON)'");
  8079. ensureColumn("product_orders", "service_person_id", "BIGINT COMMENT '所选服务者ID(deliveryMethod=4)'");
  8080. ensureColumn("product_orders", "service_person_snapshot", "TEXT COMMENT '服务者快照(JSON)'");
  8081. ensureColumn("product_orders", "fulfill_status", "VARCHAR(32) DEFAULT 'pending' COMMENT '履约状态: pending/confirmed_by_vendor/received_by_user/completed'");
  8082. ensureColumn("product_orders", "fulfill_confirmed_at", "DATETIME COMMENT '供应商确认时间'");
  8083. ensureColumn("product_orders", "received_at", "DATETIME COMMENT '用户确认收货时间'");
  8084. // 173b: 自取地点表
  8085. try {
  8086. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_pickup_locations (" +
  8087. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
  8088. "product_id BIGINT NOT NULL COMMENT '所属商品ID', " +
  8089. "name VARCHAR(100) NOT NULL COMMENT '地点名称', " +
  8090. "address VARCHAR(500) NOT NULL COMMENT '详细地址', " +
  8091. "longitude DECIMAL(10,7) COMMENT '经度', " +
  8092. "latitude DECIMAL(10,7) COMMENT '纬度', " +
  8093. "contact_name VARCHAR(50) COMMENT '联系人', " +
  8094. "contact_phone VARCHAR(20) COMMENT '联系电话', " +
  8095. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  8096. "enabled TINYINT DEFAULT 1 COMMENT '启用: 0/1', " +
  8097. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8098. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8099. "INDEX idx_product_id (product_id)" +
  8100. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品自取地点'");
  8101. log.info("已创建product_pickup_locations表");
  8102. } catch (Exception ex) {
  8103. log.warn("创建product_pickup_locations表失败: {}", ex.getMessage());
  8104. }
  8105. // 173c: 自取时间段模板表
  8106. try {
  8107. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_pickup_time_slots (" +
  8108. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
  8109. "location_id BIGINT NOT NULL COMMENT '所属地点ID', " +
  8110. "start_time TIME NOT NULL COMMENT '开始时间 HH:MM', " +
  8111. "end_time TIME NOT NULL COMMENT '结束时间 HH:MM', " +
  8112. "weekday TINYINT COMMENT '周几(1-7), null=按具体日期', " +
  8113. "specific_date DATE COMMENT '具体日期, null=按周循环', " +
  8114. "max_capacity INT DEFAULT 1 COMMENT '该时段最大接待人数', " +
  8115. "booked_count INT DEFAULT 0 COMMENT '已预订数', " +
  8116. "enabled TINYINT DEFAULT 1, " +
  8117. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8118. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8119. "INDEX idx_location_id (location_id), " +
  8120. "INDEX idx_specific_date (specific_date)" +
  8121. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自取时间段模板'");
  8122. log.info("已创建product_pickup_time_slots表");
  8123. } catch (Exception ex) {
  8124. log.warn("创建product_pickup_time_slots表失败: {}", ex.getMessage());
  8125. }
  8126. // 173d: 线上服务者表
  8127. try {
  8128. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_service_persons (" +
  8129. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
  8130. "product_id BIGINT NOT NULL COMMENT '所属商品ID', " +
  8131. "user_id BIGINT NOT NULL COMMENT '服务者用户ID(关联users表)', " +
  8132. "name VARCHAR(50) NOT NULL COMMENT '展示名称', " +
  8133. "title VARCHAR(100) COMMENT '头衔(如高级营养师)', " +
  8134. "intro TEXT COMMENT '个人介绍', " +
  8135. "avatar VARCHAR(500) COMMENT '头像URL', " +
  8136. "price INT NOT NULL COMMENT '该人专属价格(分)', " +
  8137. "original_price INT COMMENT '原价(分)', " +
  8138. "sort_order INT DEFAULT 0, " +
  8139. "enabled TINYINT DEFAULT 1, " +
  8140. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8141. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8142. "INDEX idx_product_id (product_id), " +
  8143. "INDEX idx_user_id (user_id)" +
  8144. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品线上服务者'");
  8145. log.info("已创建product_service_persons表");
  8146. } catch (Exception ex) {
  8147. log.warn("创建product_service_persons表失败: {}", ex.getMessage());
  8148. }
  8149. // 173e: 服务者可预约时间段表
  8150. try {
  8151. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_service_person_slots (" +
  8152. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
  8153. "person_id BIGINT NOT NULL COMMENT '所属服务者ID', " +
  8154. "start_time DATETIME NOT NULL COMMENT '开始时间', " +
  8155. "end_time DATETIME NOT NULL COMMENT '结束时间', " +
  8156. "max_capacity INT DEFAULT 1 COMMENT '容量', " +
  8157. "booked_count INT DEFAULT 0 COMMENT '已预订数', " +
  8158. "enabled TINYINT DEFAULT 1, " +
  8159. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8160. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8161. "INDEX idx_person_id (person_id), " +
  8162. "INDEX idx_start_time (start_time)" +
  8163. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务者可预约时间段'");
  8164. log.info("已创建product_service_person_slots表");
  8165. } catch (Exception ex) {
  8166. log.warn("创建product_service_person_slots表失败: {}", ex.getMessage());
  8167. }
  8168. // 173f: 单人线上服务时间段表
  8169. try {
  8170. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_online_slots (" +
  8171. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
  8172. "product_id BIGINT NOT NULL COMMENT '所属商品ID', " +
  8173. "start_time DATETIME NOT NULL COMMENT '开始时间', " +
  8174. "end_time DATETIME NOT NULL COMMENT '结束时间', " +
  8175. "max_capacity INT DEFAULT 1 COMMENT '容量', " +
  8176. "booked_count INT DEFAULT 0 COMMENT '已预订数', " +
  8177. "enabled TINYINT DEFAULT 1, " +
  8178. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8179. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8180. "INDEX idx_product_id (product_id), " +
  8181. "INDEX idx_start_time (start_time)" +
  8182. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单人线上服务时间段'");
  8183. log.info("已创建product_online_slots表");
  8184. } catch (Exception ex) {
  8185. log.warn("创建product_online_slots表失败: {}", ex.getMessage());
  8186. }
  8187. // 迁移166: 创建 membership_benefits 表(会员权益配置)
  8188. try {
  8189. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_benefits (" +
  8190. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8191. "level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/PROFESSIONAL/ENTERPRISE', " +
  8192. "benefit_code VARCHAR(50) NOT NULL COMMENT '权益编码: AI_CHAT,FAMILY_MEMBERS等', " +
  8193. "benefit_name VARCHAR(100) NOT NULL COMMENT '权益名称', " +
  8194. "benefit_desc VARCHAR(500) COMMENT '权益描述', " +
  8195. "icon VARCHAR(50) COMMENT '图标emoji', " +
  8196. "bg_color VARCHAR(20) COMMENT '背景色', " +
  8197. "sort_order INT DEFAULT 0 COMMENT '排序顺序', " +
  8198. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8199. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8200. "UNIQUE KEY uk_level_benefit (level_code, benefit_code), " +
  8201. "INDEX idx_level (level_code)" +
  8202. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员权益配置'");
  8203. log.info("已创建membership_benefits表");
  8204. } catch (Exception ex) {
  8205. log.warn("创建membership_benefits表失败: {}", ex.getMessage());
  8206. }
  8207. // 迁移167: membership_benefits 种子数据
  8208. try {
  8209. jdbcTemplate.execute("INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
  8210. "('FREE', 'NO_BENEFIT', '暂无会员权益', '开通会员解锁更多权益', '🔒', '#F3F4F6', 0), " +
  8211. "('FREE', 'UPGRADE_HINT', '升级会员', '开通会员享专属权益', '👑', '#FEF3C7', 99), " +
  8212. "('FAMILY', 'AI_CHAT', 'AI 健康顾问', '7×24 小时不限次数问答', '🤖', '#DBEAFE', 1), " +
  8213. "('FAMILY', 'FAMILY_MEMBERS', '家庭档案管理', '管理最多 10 位家庭成员', '👨‍👩‍👧‍👦', '#D1FAE5', 2), " +
  8214. "('FAMILY', 'HEALTH_PLAN', '月度健康计划', '个性化健康改善方案', '📋', '#FEF3C7', 3), " +
  8215. "('FAMILY', 'REPORT_READING', '报告智能解读', '体检/菌群/测评报告 AI 解读', '📊', '#FCE7F3', 4), " +
  8216. "('FAMILY', 'MANAGER_SERVICE', '专属健康教练服务', '认证健康教练月度随访', '👨‍🏫', '#FFEDD5', 5), " +
  8217. "('FAMILY', 'PRODUCT_DISCOUNT', '商品会员折扣', '全场商品 8-9 折优惠', '🛒', '#E0E7FF', 6), " +
  8218. "('FAMILY', 'COUPON_ACCESS', '优惠券使用权限', '可使用平台优惠券享更多优惠', '🎫', '#FEE2E2', 7), " +
  8219. "('PREMIUM', 'AI_CHAT', 'AI 健康顾问', '7×24 小时不限次数问答', '🤖', '#DBEAFE', 1), " +
  8220. "('PREMIUM', 'FAMILY_MEMBERS', '家庭档案管理', '管理最多 20 位家庭成员', '👨‍👩‍👧‍👦', '#D1FAE5', 2), " +
  8221. "('PREMIUM', 'HEALTH_PLAN', '月度健康计划', '个性化健康改善方案 + 定制营养建议', '📋', '#FEF3C7', 3), " +
  8222. "('PREMIUM', 'REPORT_READING', '报告智能解读', '体检/菌群/测评报告 AI 深度解读', '📊', '#FCE7F3', 4), " +
  8223. "('PREMIUM', 'MANAGER_SERVICE', '专属健康教练服务', '每周 1 次电话随访 + 在线答疑', '👨‍🏫', '#FFEDD5', 5), " +
  8224. "('PREMIUM', 'PRODUCT_DISCOUNT', '商品会员折扣', '全场商品 6-8 折优惠', '🛒', '#E0E7FF', 6), " +
  8225. "('PREMIUM', 'COUPON_ACCESS', '优惠券使用权限', '可使用平台优惠券 + 会员专属券', '🎫', '#FEE2E2', 7), " +
  8226. "('PREMIUM', 'PRIORITY_SUPPORT', '优先客服', '专属客服通道,快速响应', '🎧', '#EDE9FE', 8), " +
  8227. "('PREMIUM', 'TEACHER_CONSULT', '规划师一对一咨询', '每月 1 次成长规划师专业咨询', '👩‍🏫', '#FBCFE8', 9)");
  8228. log.info("已插入membership_benefits种子数据");
  8229. } catch (Exception ex) {
  8230. log.warn("插入membership_benefits种子数据失败: {}", ex.getMessage());
  8231. }
  8232. // 迁移168: 创建用户同意记录表(法律合规)
  8233. try {
  8234. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_consent_records (" +
  8235. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8236. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  8237. "consent_type VARCHAR(50) NOT NULL COMMENT '同意类型: privacy/agreement/children_data/ai_processing', " +
  8238. "version VARCHAR(20) NOT NULL COMMENT '协议版本号', " +
  8239. "status VARCHAR(20) NOT NULL DEFAULT 'agreed' COMMENT 'agreed/withdrawn/archived', " +
  8240. "ip_address VARCHAR(45) COMMENT '用户IP地址', " +
  8241. "created_at DATETIME NOT NULL, " +
  8242. "updated_at DATETIME, " +
  8243. "INDEX idx_user_consent (user_id, consent_type), " +
  8244. "INDEX idx_consent_type (consent_type)" +
  8245. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户同意记录'");
  8246. log.info("已创建user_consent_records表");
  8247. } catch (Exception e) {
  8248. log.warn("创建user_consent_records表失败(可能已存在): " + e.getMessage());
  8249. }
  8250. // 迁移169: 创建用户数据授权表(法律合规)
  8251. try {
  8252. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_data_permissions (" +
  8253. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8254. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  8255. "allow_ai_analysis TINYINT(1) DEFAULT 0 COMMENT '允许AI分析对话数据', " +
  8256. "allow_personalized_recommend TINYINT(1) DEFAULT 1 COMMENT '允许个性化推荐', " +
  8257. "allow_share_teacher TINYINT(1) DEFAULT 1 COMMENT '允许向成长规划师共享数据', " +
  8258. "allow_third_party_share TINYINT(1) DEFAULT 0 COMMENT '允许与第三方共享数据', " +
  8259. "created_at DATETIME NOT NULL, " +
  8260. "updated_at DATETIME, " +
  8261. "INDEX idx_user_perm (user_id)" +
  8262. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户数据使用授权'");
  8263. log.info("已创建user_data_permissions表");
  8264. } catch (Exception e) {
  8265. log.warn("创建user_data_permissions表失败(可能已存在): " + e.getMessage());
  8266. }
  8267. // 迁移174: family_members 添加饮食相关字段(饮食模块需求)
  8268. try {
  8269. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN is_living_together TINYINT(1) DEFAULT 1 COMMENT '是否同住(0=不同住,不出现在食谱推荐中)'");
  8270. log.info("已添加is_living_together列到family_members表");
  8271. } catch (Exception e) {
  8272. log.warn("family_members 添加 is_living_together 列失败(可能已存在): " + e.getMessage());
  8273. }
  8274. try {
  8275. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_breakfast_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日早餐同餐'");
  8276. log.info("已添加meal_with_breakfast_weekday列到family_members表");
  8277. } catch (Exception e) {
  8278. log.warn("family_members 添加 meal_with_breakfast_weekday 列失败(可能已存在): " + e.getMessage());
  8279. }
  8280. try {
  8281. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_lunch_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日午餐同餐'");
  8282. log.info("已添加meal_with_lunch_weekday列到family_members表");
  8283. } catch (Exception e) {
  8284. log.warn("family_members 添加 meal_with_lunch_weekday 列失败(可能已存在): " + e.getMessage());
  8285. }
  8286. try {
  8287. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_dinner_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日晚餐同餐'");
  8288. log.info("已添加meal_with_dinner_weekday列到family_members表");
  8289. } catch (Exception e) {
  8290. log.warn("family_members 添加 meal_with_dinner_weekday 列失败(可能已存在): " + e.getMessage());
  8291. }
  8292. try {
  8293. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_breakfast_weekend TINYINT(1) DEFAULT 0 COMMENT '周末早餐同餐'");
  8294. log.info("已添加meal_with_breakfast_weekend列到family_members表");
  8295. } catch (Exception e) {
  8296. log.warn("family_members 添加 meal_with_breakfast_weekend 列失败(可能已存在): " + e.getMessage());
  8297. }
  8298. try {
  8299. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_lunch_weekend TINYINT(1) DEFAULT 0 COMMENT '周末午餐同餐'");
  8300. log.info("已添加meal_with_lunch_weekend列到family_members表");
  8301. } catch (Exception e) {
  8302. log.warn("family_members 添加 meal_with_lunch_weekend 列失败(可能已存在): " + e.getMessage());
  8303. }
  8304. try {
  8305. jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_dinner_weekend TINYINT(1) DEFAULT 0 COMMENT '周末晚餐同餐'");
  8306. log.info("已添加meal_with_dinner_weekend列到family_members表");
  8307. } catch (Exception e) {
  8308. log.warn("family_members 添加 meal_with_dinner_weekend 列失败(可能已存在): " + e.getMessage());
  8309. }
  8310. // 迁移175: 创建 diet_preferences 表(饮食偏好调研表)
  8311. try {
  8312. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_preferences (" +
  8313. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8314. "family_member_id BIGINT NOT NULL COMMENT '绑定家庭成员', " +
  8315. "allergies JSON COMMENT '过敏原列表', " +
  8316. "absolute_avoid JSON COMMENT '绝对忌口列表', " +
  8317. "must_eat JSON COMMENT '必吃食物列表', " +
  8318. "religious_diet VARCHAR(50) DEFAULT 'none' COMMENT '宗教饮食', " +
  8319. "spice_level TINYINT DEFAULT NULL COMMENT '辣度 0-5', " +
  8320. "flavor_pref JSON COMMENT '口味偏好', " +
  8321. "cuisine_pref JSON COMMENT '菜系偏好', " +
  8322. "cooking_methods JSON COMMENT '烹饪方式偏好', " +
  8323. "health_goals JSON COMMENT '健康目标', " +
  8324. "goal_source VARCHAR(20) DEFAULT NULL COMMENT 'manual/system_recommend', " +
  8325. "goal_confirmed TINYINT(1) DEFAULT 0 COMMENT '用户已确认', " +
  8326. "filled_at TIMESTAMP NULL COMMENT '首次填写时间', " +
  8327. "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8328. "UNIQUE KEY uk_member (family_member_id), " +
  8329. "INDEX idx_updated (updated_at)" +
  8330. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食偏好调研表'");
  8331. log.info("已创建diet_preferences表");
  8332. } catch (Exception e) {
  8333. log.warn("创建 diet_preferences 表失败(可能已存在): " + e.getMessage());
  8334. }
  8335. // 迁移176: 创建 meal_configs 表(共餐配置表)
  8336. try {
  8337. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS meal_configs (" +
  8338. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8339. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8340. "config_date_type VARCHAR(20) NOT NULL COMMENT '工作日/周末/节假日', " +
  8341. "meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner', " +
  8342. "participant_member_ids JSON NOT NULL COMMENT '参与成员ID列表', " +
  8343. "notes VARCHAR(255) DEFAULT NULL COMMENT '备注', " +
  8344. "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " +
  8345. "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8346. "UNIQUE KEY uk_config (family_id, config_date_type, meal_type), " +
  8347. "INDEX idx_family (family_id)" +
  8348. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='共餐配置表'");
  8349. log.info("已创建meal_configs表");
  8350. } catch (Exception e) {
  8351. log.warn("创建 meal_configs 表失败(可能已存在): " + e.getMessage());
  8352. }
  8353. // 迁移177: 创建 diet_recommendations 表(食谱推荐方案表)
  8354. try {
  8355. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_recommendations (" +
  8356. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8357. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8358. "recommendation_date DATE NOT NULL COMMENT '推荐日期', " +
  8359. "meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner', " +
  8360. "participant_member_ids JSON COMMENT '参与成员ID列表', " +
  8361. "menu_json MEDIUMTEXT NOT NULL COMMENT '菜品列表JSON', " +
  8362. "nutrition_summary JSON COMMENT '营养汇总', " +
  8363. "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/accepted/adjusted/completed/skipped', " +
  8364. "version INT DEFAULT 1 COMMENT '版本号', " +
  8365. "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " +
  8366. "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8367. "INDEX idx_family_date (family_id, recommendation_date), " +
  8368. "INDEX idx_meal (meal_type, recommendation_date)" +
  8369. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食谱推荐方案表'");
  8370. log.info("已创建diet_recommendations表");
  8371. } catch (Exception e) {
  8372. log.warn("创建 diet_recommendations 表失败(可能已存在): " + e.getMessage());
  8373. }
  8374. // 迁移178: 创建 diet_records 表(饮食记录表)
  8375. try {
  8376. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_records (" +
  8377. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8378. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8379. "member_id BIGINT NOT NULL COMMENT '吃的成员ID', " +
  8380. "meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner/snack', " +
  8381. "record_date DATE NOT NULL COMMENT '记录日期', " +
  8382. "record_method VARCHAR(20) DEFAULT NULL COMMENT 'photo/manual/from_recommendation', " +
  8383. "image_url VARCHAR(500) DEFAULT NULL COMMENT '原图OSS路径', " +
  8384. "ai_recognized_foods JSON DEFAULT NULL COMMENT 'AI识别结果', " +
  8385. "user_confirmed_foods JSON NOT NULL COMMENT '用户最终确认食材', " +
  8386. "notes VARCHAR(500) DEFAULT NULL COMMENT '备注', " +
  8387. "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " +
  8388. "INDEX idx_member_date (member_id, record_date), " +
  8389. "INDEX idx_family_date (family_id, record_date)" +
  8390. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录表'");
  8391. log.info("已创建diet_records表");
  8392. } catch (Exception e) {
  8393. log.warn("创建 diet_records 表失败(可能已存在): " + e.getMessage());
  8394. }
  8395. // 迁移179: 创建 diet_record_items 表(饮食记录食材明细表)
  8396. try {
  8397. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_record_items (" +
  8398. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8399. "record_id BIGINT NOT NULL COMMENT 'diet_records.id', " +
  8400. "food_name VARCHAR(100) NOT NULL COMMENT '食材名称', " +
  8401. "food_id BIGINT DEFAULT NULL COMMENT '关联foods表ID', " +
  8402. "confidence DECIMAL(3,2) DEFAULT NULL COMMENT 'AI置信度0-1', " +
  8403. "source VARCHAR(20) DEFAULT NULL COMMENT 'ai_recognized/manual/from_recommendation', " +
  8404. "INDEX idx_record (record_id)" +
  8405. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录食材明细表'");
  8406. log.info("已创建diet_record_items表");
  8407. } catch (Exception e) {
  8408. log.warn("创建 diet_record_items 表失败(可能已存在): " + e.getMessage());
  8409. }
  8410. // 迁移180: health_reports 添加代上传字段(规划师/营养师代用户提交报告,修复 Unknown column 'proxy_uploader_id' 错误)
  8411. ensureColumn("health_reports", "proxy_uploader_id", "BIGINT COMMENT '代上传人ID(规划师/营养师)'");
  8412. ensureColumn("health_reports", "proxy_upload_note", "TEXT COMMENT '代上传备注'");
  8413. ensureColumn("health_reports", "ai_analysis", "TEXT COMMENT 'AI解读结果JSON'");
  8414. // 迁移180b: health_reports 添加报告来源格式(beijing/guhe,两种菌群报告数据结构不兼容,需区分)
  8415. ensureColumn("health_reports", "report_format", "VARCHAR(32) COMMENT '报告来源格式: beijing(北京菌群)/guhe(谷禾)'");
  8416. // 迁移181: health_report_drafts 添加备注字段(代上传说明文字)
  8417. ensureColumn("health_report_drafts", "remark", "VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)'");
  8418. // 迁移182: 创建 family_challenge_participant 表(挑战参与成员,任意成员发起/可选参与)
  8419. try {
  8420. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_participant (" +
  8421. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8422. "challenge_id BIGINT NOT NULL COMMENT '挑战ID', " +
  8423. "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
  8424. "status VARCHAR(20) NOT NULL COMMENT 'pending/agreed/rejected', " +
  8425. "agreed_at DATETIME NULL COMMENT '同意/拒绝时间', " +
  8426. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8427. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8428. "UNIQUE KEY uk_challenge_member (challenge_id, member_id), " +
  8429. "INDEX idx_member (member_id)" +
  8430. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战参与成员'");
  8431. log.info("已创建family_challenge_participant表");
  8432. } catch (Exception e) {
  8433. log.warn("创建 family_challenge_participant 表失败(可能已存在): " + e.getMessage());
  8434. }
  8435. // 迁移183: 创建 family_relationship_scores 表(方向性关系分数,A对B ≠ B对A)
  8436. try {
  8437. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_relationship_scores (" +
  8438. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8439. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8440. "from_member_id BIGINT NOT NULL COMMENT '分数持有方(A对B的行)', " +
  8441. "to_member_id BIGINT NOT NULL COMMENT '分数对象方', " +
  8442. "trust_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的信任', " +
  8443. "intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的亲近', " +
  8444. "communication_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的沟通', " +
  8445. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8446. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8447. "UNIQUE KEY uk_from_to (from_member_id, to_member_id), " +
  8448. "INDEX idx_family (family_id)" +
  8449. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='方向性关系分数'");
  8450. log.info("已创建family_relationship_scores表");
  8451. } catch (Exception e) {
  8452. log.warn("创建 family_relationship_scores 表失败(可能已存在): " + e.getMessage());
  8453. }
  8454. // 迁移184: 创建 family_challenge_score_config 表(挑战评分配置,后台可配置)+ 默认数据
  8455. try {
  8456. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_score_config (" +
  8457. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8458. "action VARCHAR(20) NOT NULL COMMENT 'initiate/agree/reject', " +
  8459. "score_type VARCHAR(20) NOT NULL COMMENT 'energy/trust/intimacy/communication', " +
  8460. "value INT NOT NULL DEFAULT 0 COMMENT '增减值(可正可负,默认±1)', " +
  8461. "enabled TINYINT DEFAULT 1, " +
  8462. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8463. "UNIQUE KEY uk_action_type (action, score_type)" +
  8464. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战评分配置'");
  8465. jdbcTemplate.execute("INSERT IGNORE INTO family_challenge_score_config (action, score_type, value, enabled) VALUES " +
  8466. "('initiate','energy',1,1),('initiate','trust',0,1),('initiate','intimacy',1,1),('initiate','communication',1,1)," +
  8467. "('agree','energy',1,1),('agree','trust',1,1),('agree','intimacy',1,1),('agree','communication',1,1)," +
  8468. "('reject','energy',-1,1),('reject','trust',-1,1),('reject','intimacy',-1,1),('reject','communication',1,1)");
  8469. log.info("已创建family_challenge_score_config表并写入默认配置");
  8470. } catch (Exception e) {
  8471. log.warn("创建 family_challenge_score_config 表失败(可能已存在): " + e.getMessage());
  8472. }
  8473. // 迁移185: 存量挑战补全 participant 行(全员 agreed,兼容旧数据,不破坏)
  8474. try {
  8475. jdbcTemplate.execute("INSERT IGNORE INTO family_challenge_participant (challenge_id, member_id, status, agreed_at) " +
  8476. "SELECT fc.id, fm.id, 'agreed', NOW() FROM family_challenge fc " +
  8477. "JOIN family_members fm ON fm.family_id = fc.family_id " +
  8478. "WHERE NOT EXISTS (SELECT 1 FROM family_challenge_participant fcp WHERE fcp.challenge_id = fc.id)");
  8479. log.info("已为存量挑战补全participant行");
  8480. } catch (Exception e) {
  8481. log.warn("存量挑战补全participant失败(可能已处理): " + e.getMessage());
  8482. }
  8483. // ===== 报告指纹检测与解析器调度系统 =====
  8484. // 迁移186: 创建 report_type_registry 表(报告类型注册表)
  8485. try {
  8486. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_type_registry (" +
  8487. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8488. "type_id VARCHAR(64) NOT NULL COMMENT '类型标识,如 dan_a1/gut_beijing', " +
  8489. "family VARCHAR(32) DEFAULT NULL COMMENT '报告家族: dan/gut_flora/其他', " +
  8490. "display_name VARCHAR(100) NOT NULL COMMENT '显示名称', " +
  8491. "description VARCHAR(500) DEFAULT NULL COMMENT '描述', " +
  8492. "parser_path VARCHAR(255) DEFAULT NULL COMMENT 'Python解析器脚本路径', " +
  8493. "min_confidence INT DEFAULT 10 COMMENT '识别阈值', " +
  8494. "is_active TINYINT(1) DEFAULT 1 COMMENT '启用状态', " +
  8495. "source VARCHAR(20) DEFAULT 'prebuilt' COMMENT '来源: prebuilt/imported/auto_generated', " +
  8496. "review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected', " +
  8497. "version VARCHAR(20) DEFAULT '1.0' COMMENT '版本', " +
  8498. "created_by BIGINT DEFAULT NULL COMMENT '创建人ID', " +
  8499. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8500. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8501. "UNIQUE KEY uk_type_id (type_id)" +
  8502. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告类型注册表'");
  8503. log.info("已创建report_type_registry表");
  8504. } catch (Exception e) {
  8505. log.warn("创建 report_type_registry 表失败(可能已存在): " + e.getMessage());
  8506. }
  8507. // 迁移187: 创建 report_fingerprint 表(指纹规则)
  8508. try {
  8509. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_fingerprint (" +
  8510. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8511. "type_id VARCHAR(64) NOT NULL COMMENT '关联report_type_registry.type_id', " +
  8512. "pattern VARCHAR(200) NOT NULL COMMENT '文本特征', " +
  8513. "weight INT DEFAULT 10 COMMENT '权重(正=加分,负=排除扣分)', " +
  8514. "is_exclusion TINYINT(1) DEFAULT 0 COMMENT '是否为排除规则', " +
  8515. "sort_order INT DEFAULT 0 COMMENT '排序', " +
  8516. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8517. "INDEX idx_fp_type (type_id)" +
  8518. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告指纹规则表'");
  8519. log.info("已创建report_fingerprint表");
  8520. } catch (Exception e) {
  8521. log.warn("创建 report_fingerprint 表失败(可能已存在): " + e.getMessage());
  8522. }
  8523. // 迁移188: 创建 report_unknown_cluster 表(未知报告聚类,自学习用)
  8524. try {
  8525. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_cluster (" +
  8526. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8527. "cluster_key VARCHAR(64) NOT NULL COMMENT '聚类哈希键(同类报告分组)', " +
  8528. "common_signatures TEXT COMMENT '公共文本特征JSON(用于生成指纹)', " +
  8529. "llm_template TEXT COMMENT 'LLM解析结果模板JSON', " +
  8530. "report_count INT DEFAULT 0 COMMENT '累计报告数', " +
  8531. "status VARCHAR(20) DEFAULT 'collecting' COMMENT '状态: collecting/ready/generated/imported', " +
  8532. "generated_type_id VARCHAR(64) DEFAULT NULL COMMENT '生成的类型ID', " +
  8533. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8534. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8535. "UNIQUE KEY uk_cluster_key (cluster_key)" +
  8536. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告聚类表'");
  8537. log.info("已创建report_unknown_cluster表");
  8538. } catch (Exception e) {
  8539. log.warn("创建 report_unknown_cluster 表失败(可能已存在): " + e.getMessage());
  8540. }
  8541. // 迁移189: 创建 report_unknown_upload 表(未知报告明细)
  8542. try {
  8543. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_upload (" +
  8544. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8545. "cluster_id BIGINT DEFAULT NULL COMMENT '关联report_unknown_cluster.id', " +
  8546. "file_url VARCHAR(500) DEFAULT NULL COMMENT '文件路径', " +
  8547. "file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件哈希', " +
  8548. "llm_result TEXT COMMENT 'LLM解析结果JSON', " +
  8549. "uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8550. "INDEX idx_unknown_cluster (cluster_id)" +
  8551. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告上传明细表'");
  8552. log.info("已创建report_unknown_upload表");
  8553. } catch (Exception e) {
  8554. log.warn("创建 report_unknown_upload 表失败(可能已存在): " + e.getMessage());
  8555. }
  8556. // 迁移190: 创建 report_parser_import 表(外部导入记录)
  8557. try {
  8558. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_parser_import (" +
  8559. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8560. "package_name VARCHAR(100) NOT NULL COMMENT '导入包名称', " +
  8561. "package_file_url VARCHAR(500) DEFAULT NULL COMMENT '导入包文件路径', " +
  8562. "fingerprint_json TEXT COMMENT '指纹规则JSON', " +
  8563. "parser_script TEXT COMMENT '解析器脚本内容', " +
  8564. "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected', " +
  8565. "imported_by BIGINT DEFAULT NULL COMMENT '导入人ID', " +
  8566. "reviewed_by BIGINT DEFAULT NULL COMMENT '审核人ID', " +
  8567. "review_note VARCHAR(500) DEFAULT NULL COMMENT '审核意见', " +
  8568. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8569. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  8570. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告解析器导入记录表'");
  8571. log.info("已创建report_parser_import表");
  8572. } catch (Exception e) {
  8573. log.warn("创建 report_parser_import 表失败(可能已存在): " + e.getMessage());
  8574. }
  8575. // 迁移191: foods 添加 report_id/score 列(Food 实体已含字段,生产库缺列导致 Unknown column 'report_id' 错误)
  8576. ensureColumn("foods", "report_id", "BIGINT DEFAULT NULL COMMENT '关联报告ID(从PDF解析的食物推荐)'");
  8577. ensureColumn("foods", "score", "INT DEFAULT NULL COMMENT '食物评分'");
  8578. // 迁移263: foods 补齐 energy_kj/starch/cholesterol/diet_type 列(前三列原迁移在文件重组时丢失、生产靠历史执行残留;
  8579. // dietType 为实体孤立字段从未建列,MyBatis-Plus 全字段 SELECT 报 Unknown column)
  8580. ensureColumn("foods", "energy_kj", "DECIMAL(8,2) DEFAULT NULL COMMENT '能量(kJ/100g)'");
  8581. ensureColumn("foods", "starch", "DECIMAL(8,2) DEFAULT NULL COMMENT '淀粉(g/100g)'");
  8582. ensureColumn("foods", "cholesterol", "DECIMAL(8,2) DEFAULT NULL COMMENT '胆固醇(mg/100g)'");
  8583. ensureColumn("foods", "diet_type", "VARCHAR(50) DEFAULT NULL COMMENT '饮食类型'");
  8584. // 迁移192: verification_codes.type 添加默认值(旧版插入未携带 type 字段,DB 列 NOT NULL 无默认值导致报错)
  8585. try {
  8586. jdbcTemplate.execute("ALTER TABLE verification_codes MODIFY COLUMN `type` VARCHAR(20) NOT NULL DEFAULT 'login' COMMENT '验证码类型: login/register/reset'");
  8587. log.info("已为verification_codes.type添加默认值");
  8588. } catch (Exception e) {
  8589. log.warn("verification_codes.type 默认值修改失败(可能已修改): " + e.getMessage());
  8590. }
  8591. // 迁移193: family_relationships表添加关系质量评分三列(关系条展示增强)
  8592. ensureColumn("family_relationships", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度(答卷人视角)'");
  8593. ensureColumn("family_relationships", "intimacy_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度(答卷人视角)'");
  8594. ensureColumn("family_relationships", "communication_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量(答卷人视角)'");
  8595. // 迁移194: family_join_requests 添加 family_role 列(保存申请人角色)
  8596. ensureColumn("family_join_requests", "family_role", "VARCHAR(20) DEFAULT NULL COMMENT '申请人在家庭中的角色:爸爸/妈妈/爷爷/奶奶等'");
  8597. // 迁移195: tasks 表新增 accept_deadline 列 + 扩展 status 枚举(支持 assigned/in_progress/rejected)
  8598. ensureColumn("tasks", "accept_deadline", "DATETIME DEFAULT NULL COMMENT '接受截止时间(分配给其他家庭成员的任务)'");
  8599. try {
  8600. jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN `status` ENUM('pending','assigned','in_progress','completed','rejected','overdue','cancelled') DEFAULT 'pending' COMMENT '状态: pending=待审核 assigned=待接受 in_progress=进行中 completed=已完成 rejected=已拒绝 overdue=已过期 cancelled=已取消'");
  8601. log.info("已更新tasks.status枚举值");
  8602. } catch (Exception e) {
  8603. log.warn("tasks.status枚举修改失败(可能已修改): " + e.getMessage());
  8604. }
  8605. // 迁移196: 创建 user_platform_balance 表(CF值余额)
  8606. try {
  8607. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_platform_balance (" +
  8608. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8609. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  8610. "family_id BIGINT COMMENT '归属家庭ID', " +
  8611. "total_earned INT DEFAULT 0 COMMENT '累计获得', " +
  8612. "available INT DEFAULT 0 COMMENT '可用余额', " +
  8613. "frozen INT DEFAULT 0 COMMENT '冻结(提现审核中)', " +
  8614. "withdrawn INT DEFAULT 0 COMMENT '已提现', " +
  8615. "exchanged INT DEFAULT 0 COMMENT '已兑换', " +
  8616. "updated_at DATETIME, " +
  8617. "created_at DATETIME, " +
  8618. "UNIQUE KEY uk_user (user_id)" +
  8619. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户CF值余额'");
  8620. log.info("已创建user_platform_balance表");
  8621. } catch (Exception e) {
  8622. log.warn("创建user_platform_balance表失败: " + e.getMessage());
  8623. }
  8624. // 迁移197: 创建 platform_balance_log 表(CF值流水)
  8625. try {
  8626. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS platform_balance_log (" +
  8627. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8628. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  8629. "family_id BIGINT COMMENT '归属家庭ID', " +
  8630. "type VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw', " +
  8631. "amount INT NOT NULL COMMENT '变动数量', " +
  8632. "balance_after INT NOT NULL COMMENT '变动后可用余额', " +
  8633. "ref_type VARCHAR(30) COMMENT 'product_order/activity/withdrawal/migration', " +
  8634. "ref_id BIGINT COMMENT '关联业务ID', " +
  8635. "remark VARCHAR(255) COMMENT '备注', " +
  8636. "created_at DATETIME, " +
  8637. "INDEX idx_user_time (user_id, created_at)" +
  8638. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流水'");
  8639. log.info("已创建platform_balance_log表");
  8640. } catch (Exception e) {
  8641. log.warn("创建platform_balance_log表失败: " + e.getMessage());
  8642. }
  8643. // 迁移198: 存量佣金迁移为CF值(settled→available, pending→frozen)
  8644. try {
  8645. jdbcTemplate.execute("INSERT INTO user_platform_balance (user_id, total_earned, available, created_at, updated_at) " +
  8646. "SELECT referrer_id, SUM(commission_amount), SUM(commission_amount), NOW(), NOW() " +
  8647. "FROM commission_records WHERE status='settled' GROUP BY referrer_id " +
  8648. "ON DUPLICATE KEY UPDATE available=available+VALUES(available), " +
  8649. "total_earned=total_earned+VALUES(total_earned), updated_at=NOW()");
  8650. log.info("已迁移settled佣金为可用CF值");
  8651. } catch (Exception e) {
  8652. log.warn("迁移settled佣金失败: " + e.getMessage());
  8653. }
  8654. // 迁移199: pending 佣金 → 冻结CF
  8655. try {
  8656. jdbcTemplate.execute("INSERT INTO user_platform_balance (user_id, total_earned, frozen, created_at, updated_at) " +
  8657. "SELECT referrer_id, SUM(commission_amount), SUM(commission_amount), NOW(), NOW() " +
  8658. "FROM commission_records WHERE status='pending' GROUP BY referrer_id " +
  8659. "ON DUPLICATE KEY UPDATE frozen=frozen+VALUES(frozen), " +
  8660. "total_earned=total_earned+VALUES(total_earned), updated_at=NOW()");
  8661. log.info("已迁移pending佣金为冻结CF值");
  8662. } catch (Exception e) {
  8663. log.warn("迁移pending佣金失败: " + e.getMessage());
  8664. }
  8665. // 迁移200: activities表添加platform_points字段
  8666. ensureColumn("activities", "platform_points", "INT DEFAULT 0 COMMENT '活动奖励CF值(0=不发)'");
  8667. // 迁移201: 新增CF值配置项
  8668. insertSysConfigSeed("product_platform_points_share", "1000", "商品CF值利润分成比例(bps)");
  8669. insertSysConfigSeed("platform_withdraw_rate", "100", "CF值提现汇率(CF数/元)");
  8670. // 迁移202: withdrawal_requests表添加 type 字段(提现来源)
  8671. ensureColumn("withdrawal_requests", "type", "VARCHAR(20) DEFAULT 'platform_points' COMMENT '提现来源: platform_points(CF值) / commission(历史佣金)'");
  8672. // 迁移203: membership_levels 添加原价字段(LIFETIME 需求:价格来源统一迁移至表内)
  8673. ensureColumn("membership_levels", "original_price_monthly", "INT NULL COMMENT '原价-月费(分)'");
  8674. ensureColumn("membership_levels", "original_price_quarterly", "INT NULL COMMENT '原价-季费(分)'");
  8675. ensureColumn("membership_levels", "original_price_yearly", "INT NULL COMMENT '原价-年费(分)'");
  8676. // 迁移203.1: 幂等数据同步(sys_config 原价/现值 → membership_levels,仅当表内为 0/NULL 时)
  8677. try {
  8678. // FAMILY 原价年费(sys_config member_fee_family_original_yearly / member_fee_family_original,默认 199900)
  8679. jdbcTemplate.update("UPDATE membership_levels SET original_price_yearly = 199900 " +
  8680. "WHERE level_code = 'FAMILY' AND (original_price_yearly IS NULL OR original_price_yearly = 0)");
  8681. // PREMIUM 原价年费/月费(sys_config member_fee_premium_original / member_fee_premium_original_monthly)
  8682. jdbcTemplate.update("UPDATE membership_levels SET original_price_yearly = 1599900, original_price_monthly = 159900 " +
  8683. "WHERE level_code = 'PREMIUM' AND (original_price_yearly IS NULL OR original_price_yearly = 0)");
  8684. // FAMILY 现价补齐(现值 13140/32850/131400)
  8685. jdbcTemplate.update("UPDATE membership_levels SET price_monthly = 13140, price_quarterly = 32850, price_yearly = 131400 " +
  8686. "WHERE level_code = 'FAMILY' AND (price_yearly IS NULL OR price_yearly = 0)");
  8687. // PREMIUM 现价补齐(现值 131400/1316800;季费保持 NULL=不支持)
  8688. jdbcTemplate.update("UPDATE membership_levels SET price_monthly = 131400, price_yearly = 1316800 " +
  8689. "WHERE level_code = 'PREMIUM' AND (price_yearly IS NULL OR price_yearly = 0)");
  8690. log.info("已同步会员价格/原价至membership_levels");
  8691. } catch (Exception e) {
  8692. log.warn("同步membership_levels价格失败: {}", e.getMessage());
  8693. }
  8694. // 迁移204: 插入 LIFETIME 终身会员等级行(不可购买,仅管理员授予,价格全 0)
  8695. try {
  8696. jdbcTemplate.execute("INSERT IGNORE INTO membership_levels " +
  8697. "(level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, " +
  8698. "original_price_monthly, original_price_quarterly, original_price_yearly, " +
  8699. "features, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " +
  8700. "('LIFETIME', '终身会员', '终身家庭会员权益,永久有效无需续费', 0, 0, 0, 0, 0, 0, " +
  8701. "'[\"free_activities\",\"free_courses\",\"full_price_purchase\",\"referral_commission\"," +
  8702. "\"discount_purchase\",\"member_activities\",\"create_family\",\"invite_family\",\"purchase_commission\"]', " +
  8703. "6, 20, 1, 1, 1)");
  8704. log.info("已插入LIFETIME等级种子数据");
  8705. } catch (Exception e) {
  8706. log.warn("插入LIFETIME等级种子数据失败: {}", e.getMessage());
  8707. }
  8708. // 迁移205: 创建平台公告表
  8709. try {
  8710. jdbcTemplate.execute(
  8711. "CREATE TABLE IF NOT EXISTS notices (" +
  8712. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8713. "title VARCHAR(200) NOT NULL COMMENT '公告标题', " +
  8714. "content TEXT NOT NULL COMMENT '公告内容', " +
  8715. "type VARCHAR(20) DEFAULT 'platform' COMMENT '类型: platform=平台公告 activity=活动 promotion=优惠', " +
  8716. "status VARCHAR(20) DEFAULT 'active' COMMENT '状态: draft=草稿 active=已发布 deleted=已删除', " +
  8717. "admin_id BIGINT DEFAULT NULL COMMENT '发布管理员ID', " +
  8718. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8719. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  8720. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公告'"
  8721. );
  8722. log.info("已创建notices表");
  8723. } catch (Exception e) {
  8724. log.warn("创建notices表失败: " + e.getMessage());
  8725. }
  8726. // 迁移206: member_upgrade_records 添加审批字段(LIFETIME 授予审批需求,修复升级记录查询 Unknown column 500)
  8727. ensureColumn("member_upgrade_records", "approve_remark", "VARCHAR(500) DEFAULT NULL COMMENT '审批备注'");
  8728. ensureColumn("member_upgrade_records", "approve_status", "VARCHAR(20) DEFAULT 'approved' COMMENT '审批状态: approved/rejected'");
  8729. ensureColumn("member_upgrade_records", "approve_user_id", "BIGINT DEFAULT NULL COMMENT '审批人ID'");
  8730. ensureColumn("member_upgrade_records", "approve_time", "DATETIME DEFAULT NULL COMMENT '审批时间'");
  8731. // 迁移207: 创建 health_plans 表(健康方案汇总记录)
  8732. try {
  8733. jdbcTemplate.execute(
  8734. "CREATE TABLE IF NOT EXISTS health_plans (" +
  8735. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8736. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8737. "member_ids VARCHAR(200) NOT NULL COMMENT '目标成员ID列表(逗号分隔)', " +
  8738. "dimensions VARCHAR(100) NOT NULL COMMENT '维度列表(逗号分隔)', " +
  8739. "goal TEXT NOT NULL COMMENT '用户输入的目标', " +
  8740. "plan_content TEXT COMMENT 'LangGraph生成的方案内容', " +
  8741. "member_name VARCHAR(50) COMMENT '成员名称摘要', " +
  8742. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8743. "INDEX idx_family (family_id), " +
  8744. "INDEX idx_created (created_at)" +
  8745. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康方案汇总记录'"
  8746. );
  8747. log.info("已创建health_plans表");
  8748. } catch (Exception e) {
  8749. log.warn("创建health_plans表失败(可能已存在): " + e.getMessage());
  8750. }
  8751. // 迁移208: health_gut_flora.detection_rate 列扩容(VARCHAR(50)→VARCHAR(255),源数据超长导致 MysqlDataTruncation)
  8752. try {
  8753. jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN detection_rate VARCHAR(255) DEFAULT NULL COMMENT '检出率'");
  8754. log.info("health_gut_flora.detection_rate 已扩容为 VARCHAR(255)");
  8755. } catch (Exception e) {
  8756. log.warn("health_gut_flora.detection_rate 扩容失败(可能已修改): " + e.getMessage());
  8757. }
  8758. // 迁移225: articles 添加 growth_category 列(成长分类筛选,Article 实体已含字段,生产库缺列导致 Unknown column 'growth_category')
  8759. ensureColumn("articles", "growth_category", "VARCHAR(50) DEFAULT NULL COMMENT '成长分类'");
  8760. // 迁移209: 将 ecom_supplier 合并至 supply_system(商品供应商管理 → 供应商体系管理统一)
  8761. // 1. 给 supply_system 加联系人字段
  8762. ensureColumn("supply_system", "contact_name", "VARCHAR(50) DEFAULT NULL COMMENT '联系人'");
  8763. ensureColumn("supply_system", "contact_phone", "VARCHAR(20) DEFAULT NULL COMMENT '联系电话'");
  8764. ensureColumn("supply_system", "remark", "VARCHAR(500) DEFAULT NULL COMMENT '备注'");
  8765. log.info("迁移209: supply_system 已添加 contact_name/contact_phone/remark 列");
  8766. // 2. 将 ecom_supplier 存量数据迁移到 supply_system(以平台管理员 id=1 为默认 admin)
  8767. try {
  8768. jdbcTemplate.update(
  8769. "INSERT INTO supply_system (name, admin_id, settlement_period_days, platform_profit_rate, status, " +
  8770. "contact_name, contact_phone, remark, created_at, updated_at) " +
  8771. "SELECT name, 1, 30, 0, " +
  8772. "CASE WHEN status = 1 THEN 'active' ELSE 'disabled' END, " +
  8773. "contact_name, contact_phone, remark, created_at, COALESCE(updated_at, created_at) " +
  8774. "FROM ecom_supplier WHERE NOT EXISTS (" +
  8775. " SELECT 1 FROM supply_system ss WHERE ss.name = ecom_supplier.name LIMIT 1)"
  8776. );
  8777. log.info("迁移209: ecom_supplier 存量数据已迁移至 supply_system");
  8778. } catch (Exception e) {
  8779. log.warn("迁移209: ecom_supplier 迁移失败(可能已存在): {}", e.getMessage());
  8780. }
  8781. }
  8782. private void runFullRegionDataMigrations() {
  8783. // 迁移226: 创建 family_platform_balance 表 + family_platform_balance_log 表 + products.points_multiplier 列
  8784. try {
  8785. jdbcTemplate.execute(
  8786. "CREATE TABLE IF NOT EXISTS family_platform_balance (" +
  8787. " id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8788. " family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8789. " total_earned INT DEFAULT 0 COMMENT '累计获得', " +
  8790. " available INT DEFAULT 0 COMMENT '可用余额', " +
  8791. " frozen INT DEFAULT 0 COMMENT '冻结(提现审核中)', " +
  8792. " withdrawn INT DEFAULT 0 COMMENT '已提现', " +
  8793. " exchanged INT DEFAULT 0 COMMENT '已兑换', " +
  8794. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8795. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8796. " UNIQUE KEY uk_family (family_id)" +
  8797. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭平台积分池余额'"
  8798. );
  8799. log.info("已创建family_platform_balance表");
  8800. } catch (Exception e) {
  8801. log.warn("创建family_platform_balance表失败: " + e.getMessage());
  8802. }
  8803. try {
  8804. jdbcTemplate.execute(
  8805. "CREATE TABLE IF NOT EXISTS family_platform_balance_log (" +
  8806. " id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8807. " family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8808. " type VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust/migration', " +
  8809. " amount INT NOT NULL COMMENT '变动数量', " +
  8810. " balance_after INT NOT NULL COMMENT '变动后可用余额', " +
  8811. " ref_type VARCHAR(50) COMMENT 'product_order/referral_dist/activity/withdrawal/adjust/migration/refund', " +
  8812. " ref_id BIGINT COMMENT '关联业务ID', " +
  8813. " remark VARCHAR(255) COMMENT '备注', " +
  8814. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8815. " INDEX idx_family (family_id), " +
  8816. " INDEX idx_ref (ref_type, ref_id)" +
  8817. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭平台积分流水'"
  8818. );
  8819. log.info("已创建family_platform_balance_log表");
  8820. } catch (Exception e) {
  8821. log.warn("创建family_platform_balance_log表失败: " + e.getMessage());
  8822. }
  8823. ensureColumn("products", "points_multiplier", "DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT '平台积分倍数,1元=1积分×倍数'");
  8824. log.info("已添加products.points_multiplier列");
  8825. // 迁移227: 创建 family_platform_exchange_record 表(优惠券CF兑换记录;券挂家庭,CF扣兑换人个人账本)
  8826. try {
  8827. jdbcTemplate.execute(
  8828. "CREATE TABLE IF NOT EXISTS family_platform_exchange_record (" +
  8829. " id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8830. " user_id BIGINT NOT NULL COMMENT '操作用户ID', " +
  8831. " family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  8832. " coupon_id BIGINT NOT NULL COMMENT '关联优惠券ID', " +
  8833. " coupon_name VARCHAR(200) COMMENT '优惠券名称', " +
  8834. " cf_cost INT NOT NULL COMMENT '消耗CF值数量', " +
  8835. " status VARCHAR(20) NOT NULL DEFAULT 'completed' COMMENT 'completed/failed', " +
  8836. " redeem_code VARCHAR(32) COMMENT '兑换码', " +
  8837. " remark VARCHAR(255) COMMENT '备注', " +
  8838. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8839. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8840. " INDEX idx_family_time (family_id, created_at), " +
  8841. " INDEX idx_user (user_id)" +
  8842. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券CF兑换记录(券挂家庭,CF扣兑换人个人账本cf_balance)'"
  8843. );
  8844. log.info("已创建family_platform_exchange_record表");
  8845. } catch (Exception e) {
  8846. log.warn("创建family_platform_exchange_record表失败: " + e.getMessage());
  8847. }
  8848. // 迁移228: nutrition_product 添加 ingredients 列 + suitable_for 升级为 TEXT(存放详情页图像提取的成份/配料表与适用说明)
  8849. ensureColumn("nutrition_product", "ingredients", "TEXT DEFAULT NULL COMMENT '成份/配料表(从详情页图片提取)'");
  8850. try {
  8851. jdbcTemplate.execute("ALTER TABLE nutrition_product MODIFY COLUMN suitable_for TEXT DEFAULT NULL COMMENT '适用人群/使用方法'");
  8852. log.info("已将nutrition_product.suitable_for升级为TEXT类型");
  8853. } catch (Exception e) {
  8854. // 修改失败,忽略错误(幂等)
  8855. }
  8856. // 迁移222: 创建 file_tag 表(文件标签字典)
  8857. try {
  8858. jdbcTemplate.execute(
  8859. "CREATE TABLE IF NOT EXISTS file_tag (" +
  8860. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8861. "name VARCHAR(50) NOT NULL COMMENT '标签名称', " +
  8862. "color VARCHAR(7) DEFAULT '#409EFF' COMMENT '标签颜色', " +
  8863. "sort_order INT DEFAULT 0 COMMENT '排序号', " +
  8864. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8865. "UNIQUE KEY uk_name (name)" +
  8866. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件标签字典'"
  8867. );
  8868. log.info("已创建file_tag表");
  8869. } catch (Exception e) {
  8870. log.warn("创建file_tag表失败: " + e.getMessage());
  8871. }
  8872. String[][] presetTags = {
  8873. {"测评报告", "#409EFF"}, {"健康报告", "#67C23A"}, {"文章配图", "#E6A23C"},
  8874. {"成长档案", "#909399"}, {"身", "#FF8C42"}, {"智", "#6366F1"},
  8875. {"富", "#F59E0B"}, {"行", "#10B981"}, {"心", "#FF6B9D"}, {"重要", "#F56C6C"}
  8876. };
  8877. for (int i = 0; i < presetTags.length; i++) {
  8878. try {
  8879. jdbcTemplate.update("INSERT IGNORE INTO file_tag (name, color, sort_order) VALUES (?, ?, ?)",
  8880. presetTags[i][0], presetTags[i][1], i);
  8881. } catch (Exception e) { /* 忽略重复 */ }
  8882. }
  8883. log.info("已预置文件标签");
  8884. // 迁移223: 创建 file_tag_rel 表
  8885. try {
  8886. jdbcTemplate.execute(
  8887. "CREATE TABLE IF NOT EXISTS file_tag_rel (" +
  8888. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8889. "file_id BIGINT NOT NULL COMMENT 'file_record.id', " +
  8890. "tag_id BIGINT NOT NULL COMMENT 'file_tag.id', " +
  8891. "UNIQUE KEY uk_file_tag (file_id, tag_id), " +
  8892. "INDEX idx_tag (tag_id)" +
  8893. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件-标签关联'"
  8894. );
  8895. log.info("已创建file_tag_rel表");
  8896. } catch (Exception e) {
  8897. log.warn("创建file_tag_rel表失败: " + e.getMessage());
  8898. }
  8899. // 迁移229: 新增GROWTH_COACH权益(7X24小时成长教练)到FAMILY/PREMIUM,及mascot_ai功能开关
  8900. try {
  8901. jdbcTemplate.execute("INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
  8902. "('FAMILY', 'GROWTH_COACH', '7X24小时成长教练', 'AI智能教练随时解答家庭教育问题', '🎯', '#FEF3C7', 10), " +
  8903. "('PREMIUM', 'GROWTH_COACH', '7X24小时成长教练', 'AI智能教练随时解答家庭教育问题 + 优先响应', '🎯', '#FEF3C7', 10)");
  8904. log.info("已插入GROWTH_COACH权益种子数据");
  8905. } catch (Exception ex) {
  8906. log.warn("插入GROWTH_COACH权益种子数据失败: {}", ex.getMessage());
  8907. }
  8908. // 迁移232: recommendation_logs 追加 recommend_type 字段
  8909. ensureColumn("recommendation_logs", "recommend_type", "VARCHAR(20) DEFAULT 'tag' COMMENT '推荐方式: profile/dimension/tag'");
  8910. log.info("已添加recommendation_logs.recommend_type列");
  8911. // 迁移233: 将 Article/Activity 现有 dimension_weights JSON 同步到 dimension_weights 表
  8912. // (修正:源表名 article→articles、activity→activities;activity与article同为五维JSON对象按维度键展开;
  8913. // dimension_weights表无唯一键,INSERT IGNORE不去重,用sys_config一次性标记+自连接去重兜底)
  8914. try {
  8915. Integer synced = jdbcTemplate.queryForObject(
  8916. "SELECT COUNT(*) FROM sys_config WHERE config_key = 'dimension_weights_legacy_sync_done'",
  8917. Integer.class);
  8918. if (synced == null || synced == 0) {
  8919. jdbcTemplate.execute(
  8920. "INSERT IGNORE INTO dimension_weights (target_type, target_id, dimension, weight, enabled) " +
  8921. "SELECT 'article', a.id, 'body', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.body') AS SIGNED), 0), 1 " +
  8922. "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.body') IS NOT NULL " +
  8923. "UNION ALL " +
  8924. "SELECT 'article', a.id, 'mind', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.mind') AS SIGNED), 0), 1 " +
  8925. "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.mind') IS NOT NULL " +
  8926. "UNION ALL " +
  8927. "SELECT 'article', a.id, 'wisdom', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.wisdom') AS SIGNED), 0), 1 " +
  8928. "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.wisdom') IS NOT NULL " +
  8929. "UNION ALL " +
  8930. "SELECT 'article', a.id, 'action', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.action') AS SIGNED), 0), 1 " +
  8931. "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.action') IS NOT NULL " +
  8932. "UNION ALL " +
  8933. "SELECT 'article', a.id, 'wealth', COALESCE(CAST(JSON_EXTRACT(a.dimension_weights, '$.wealth') AS SIGNED), 0), 1 " +
  8934. "FROM articles a WHERE a.dimension_weights IS NOT NULL AND a.dimension_weights != '' AND JSON_EXTRACT(a.dimension_weights, '$.wealth') IS NOT NULL"
  8935. );
  8936. jdbcTemplate.execute(
  8937. "INSERT IGNORE INTO dimension_weights (target_type, target_id, dimension, weight, enabled) " +
  8938. "SELECT 'activity', act.id, 'body', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.body') AS SIGNED), 0), 1 " +
  8939. "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.body') IS NOT NULL " +
  8940. "UNION ALL " +
  8941. "SELECT 'activity', act.id, 'mind', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.mind') AS SIGNED), 0), 1 " +
  8942. "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.mind') IS NOT NULL " +
  8943. "UNION ALL " +
  8944. "SELECT 'activity', act.id, 'wisdom', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.wisdom') AS SIGNED), 0), 1 " +
  8945. "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.wisdom') IS NOT NULL " +
  8946. "UNION ALL " +
  8947. "SELECT 'activity', act.id, 'action', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.action') AS SIGNED), 0), 1 " +
  8948. "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.action') IS NOT NULL " +
  8949. "UNION ALL " +
  8950. "SELECT 'activity', act.id, 'wealth', COALESCE(CAST(JSON_EXTRACT(act.dimension_weights, '$.wealth') AS SIGNED), 0), 1 " +
  8951. "FROM activities act WHERE act.dimension_weights IS NOT NULL AND act.dimension_weights != '' AND JSON_EXTRACT(act.dimension_weights, '$.wealth') IS NOT NULL"
  8952. );
  8953. jdbcTemplate.execute(
  8954. "DELETE dw1 FROM dimension_weights dw1 INNER JOIN dimension_weights dw2 ON " +
  8955. "dw1.target_type = dw2.target_type AND dw1.target_id = dw2.target_id AND dw1.dimension = dw2.dimension " +
  8956. "AND dw1.id > dw2.id"
  8957. );
  8958. jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('dimension_weights_legacy_sync_done', '1', 'Article/Activity五维权重已同步到dimension_weights表')");
  8959. log.info("已完成Article/Activity维度权重到dimension_weights表的一次性迁移");
  8960. } else {
  8961. log.info("迁移233: 维度权重已同步过,跳过");
  8962. }
  8963. } catch (Exception ex) {
  8964. log.warn("Article/Activity维度权重同步失败: {}", ex.getMessage());
  8965. }
  8966. // 迁移234: 创建 problem_domains 表(问题域定义,改版v1:需求驱动·逐步解锁)
  8967. try {
  8968. jdbcTemplate.execute(
  8969. "CREATE TABLE IF NOT EXISTS problem_domains (" +
  8970. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8971. "code VARCHAR(50) NOT NULL COMMENT '唯一编码: weight_loss/sleep/constipation', " +
  8972. "name VARCHAR(50) NOT NULL COMMENT '名称: 减肥/睡眠/便秘', " +
  8973. "icon VARCHAR(255) DEFAULT '' COMMENT '图标', " +
  8974. "description VARCHAR(500) DEFAULT '' COMMENT '一句话描述', " +
  8975. "flow_intro_json TEXT COMMENT '服务流程介绍(7步) JSON', " +
  8976. "survey_template_id BIGINT DEFAULT NULL COMMENT '专项调研问卷模板ID(SurveyTemplate.id)', " +
  8977. "content_tags VARCHAR(500) DEFAULT '' COMMENT '内容标签,逗号分隔(过滤文章/知识库)', " +
  8978. "recommended_product_ids VARCHAR(500) DEFAULT '' COMMENT '推荐产品ID,逗号分隔(Product.id)', " +
  8979. "task_template_package_ids VARCHAR(500) DEFAULT '' COMMENT '套餐绑定的任务模板包ID(TaskTemplatePackage.id)', " +
  8980. "reward_cf_value INT DEFAULT 0 COMMENT '全部完成奖励CF值', " +
  8981. "status TINYINT DEFAULT 1 COMMENT '1启用 0停用', " +
  8982. "sort_order INT DEFAULT 0, " +
  8983. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  8984. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  8985. "UNIQUE KEY uk_code (code)" +
  8986. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问题域定义(改版v1)'"
  8987. );
  8988. log.info("已创建problem_domains表");
  8989. } catch (Exception e) {
  8990. log.warn("创建problem_domains表失败: " + e.getMessage());
  8991. }
  8992. // 迁移235: 创建 user_intents 表(用户意图与服务旅程)
  8993. try {
  8994. jdbcTemplate.execute(
  8995. "CREATE TABLE IF NOT EXISTS user_intents (" +
  8996. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  8997. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  8998. "intent_type VARCHAR(20) DEFAULT '' COMMENT 'A/B/C 用户类型', " +
  8999. "problem_domain_code VARCHAR(50) DEFAULT '' COMMENT '问题域编码(仅C类)', " +
  9000. "journey_stage INT DEFAULT 0 COMMENT '旅程阶段 0-7', " +
  9001. "stage_updated_at DATETIME DEFAULT NULL COMMENT '最近阶段变化时间', " +
  9002. "survey_template_id BIGINT DEFAULT NULL COMMENT '最近作答问卷模板ID', " +
  9003. "survey_submitted_at DATETIME DEFAULT NULL COMMENT '最近问卷提交时间', " +
  9004. "survey_result_json TEXT COMMENT '问卷答案→推荐结果快照', " +
  9005. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9006. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9007. "UNIQUE KEY uk_user (user_id)" +
  9008. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意图与服务旅程(改版v1)'"
  9009. );
  9010. log.info("已创建user_intents表");
  9011. } catch (Exception e) {
  9012. log.warn("创建user_intents表失败: " + e.getMessage());
  9013. }
  9014. // 迁移236: 创建 user_notifications 表(用户级站内主动通知,与notices平台公告并存)
  9015. try {
  9016. jdbcTemplate.execute(
  9017. "CREATE TABLE IF NOT EXISTS user_notifications (" +
  9018. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9019. "user_id BIGINT NOT NULL COMMENT '接收用户ID', " +
  9020. "type VARCHAR(30) DEFAULT 'report_ready' COMMENT 'report_ready/reward_granted/logistics/system', " +
  9021. "title VARCHAR(200) NOT NULL, " +
  9022. "content VARCHAR(1000) DEFAULT '', " +
  9023. "ref_type VARCHAR(30) DEFAULT '' COMMENT '关联对象类型: report/package/task', " +
  9024. "ref_id BIGINT DEFAULT NULL COMMENT '关联对象ID', " +
  9025. "is_read TINYINT DEFAULT 0, " +
  9026. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9027. "INDEX idx_user_read (user_id, is_read)" +
  9028. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(改版v1)'"
  9029. );
  9030. log.info("已创建user_notifications表");
  9031. } catch (Exception e) {
  9032. log.warn("创建user_notifications表失败: " + e.getMessage());
  9033. }
  9034. // 迁移237: tasks 表添加 member_only 字段(1=会员任务,仅方案/套餐生成)
  9035. ensureColumn("tasks", "member_only", "TINYINT DEFAULT 0 COMMENT '1=会员任务(方案/套餐生成),执行需会员; 0=普通任务可免费执行'");
  9036. // 迁移238: package_orders 表添加物流状态字段(试剂盒寄送/样本寄回)
  9037. ensureColumn("package_orders", "logistics_status", "VARCHAR(20) DEFAULT 'none' COMMENT 'none/kit_shipped/kit_received/sample_shipped/sample_received'");
  9038. ensureColumn("package_orders", "tracking_no", "VARCHAR(100) DEFAULT '' COMMENT '快递单号'");
  9039. log.info("已添加package_orders物流字段");
  9040. // 迁移239: 创建 health_status 表(当前状态调研-健康现状档案,SS-2026-08-13)
  9041. try {
  9042. jdbcTemplate.execute(
  9043. "CREATE TABLE IF NOT EXISTS health_status (" +
  9044. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9045. "user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)', " +
  9046. "height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)', " +
  9047. "weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)', " +
  9048. "blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80', " +
  9049. "blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)', " +
  9050. "blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]', " +
  9051. "disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]', " +
  9052. "medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]', " +
  9053. "notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明', " +
  9054. "filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间', " +
  9055. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
  9056. "UNIQUE KEY uk_user (user_id)" +
  9057. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案'"
  9058. );
  9059. log.info("已创建health_status表");
  9060. } catch (Exception e) {
  9061. log.warn("创建health_status表失败: " + e.getMessage());
  9062. }
  9063. // 迁移240: 创建 system_prompts 表(系统提示词统一管理,cfc-web后台可维护)
  9064. try {
  9065. jdbcTemplate.execute(
  9066. "CREATE TABLE IF NOT EXISTS system_prompts (" +
  9067. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9068. "prompt_key VARCHAR(64) NOT NULL COMMENT '提示词键(如 health_coach/analysis/chat)', " +
  9069. "title VARCHAR(100) NOT NULL COMMENT '提示词标题', " +
  9070. "content TEXT NOT NULL COMMENT '提示词内容', " +
  9071. "default_content TEXT COMMENT '出厂默认内容(重置时恢复)', " +
  9072. "version INT NOT NULL DEFAULT 1 COMMENT '版本号', " +
  9073. "status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用', " +
  9074. "remark VARCHAR(255) COMMENT '备注', " +
  9075. "updated_by VARCHAR(64) COMMENT '更新人', " +
  9076. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  9077. "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9078. "UNIQUE KEY uk_prompt_key (prompt_key)" +
  9079. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统提示词管理'"
  9080. );
  9081. log.info("已创建system_prompts表");
  9082. } catch (Exception e) {
  9083. log.warn("创建system_prompts表失败: " + e.getMessage());
  9084. }
  9085. // 迁移240b: 插入默认系统提示词(仅当表为空时)
  9086. try {
  9087. jdbcTemplate.execute(
  9088. "INSERT IGNORE INTO system_prompts (prompt_key, title, content, default_content, version, remark) VALUES " +
  9089. "('health_butler', '家庭健康管家', " +
  9090. "'你是一个家庭AI健康管家, 为用户提供专业、温暖的个性化健康建议。\\n你的能力:\\n1. 基于健康知识库(菌属功能、指标解读、营养素、食物)提供科学建议\\n2. 解读健康报告数据, 结合知识库给出改善方案\\n3. 根据菌群检测结果推荐精准营养方案\\n4. 提供可执行的健康微行动\\n回答原则:\\n1. 用中文, 像家庭医生一样亲切但专业\\n2. 引用知识库中的菌属/指标/食物信息作为证据\\n3. 优先使用知识库数据, 其次使用通用健康知识\\n4. 需要产品推荐时使用搜索工具\\n5. 生成 [TASK: {\"title\": \"任务名\", \"dimension\": \"身\", \"points\": 10, \"frequency\": \"每天\"}] 标记创建健康行动任务\\n6. 超出知识库范围的慢性病/急症问题, 建议咨询专业医生\\n7. 不要编造医学建议, 不夸大效果', " +
  9091. "'你是一个家庭AI健康管家, 为用户提供专业、温暖的个性化健康建议。\\n你的能力:\\n1. 基于健康知识库(菌属功能、指标解读、营养素、食物)提供科学建议\\n2. 解读健康报告数据, 结合知识库给出改善方案\\n3. 根据菌群检测结果推荐精准营养方案\\n4. 提供可执行的健康微行动\\n回答原则:\\n1. 用中文, 像家庭医生一样亲切但专业\\n2. 引用知识库中的菌属/指标/食物信息作为证据\\n3. 优先使用知识库数据, 其次使用通用健康知识\\n4. 需要产品推荐时使用搜索工具\\n5. 生成 [TASK: {\"title\": \"任务名\", \"dimension\": \"身\", \"points\": 10, \"frequency\": \"每天\"}] 标记创建健康行动任务\\n6. 超出知识库范围的慢性病/急症问题, 建议咨询专业医生\\n7. 不要编造医学建议, 不夸大效果', " +
  9092. "1, 'AI健康管家对话'), " +
  9093. "('health_coach', '儿童健康教练', " +
  9094. "'你是一个儿童健康教练,喜欢用提问的方式引导家长发现问题和解决方案。\\n核心原则:\\n1. 先问后答:不要直接给答案,用问题引导家长思考\\n2. 用开放式问题了解情况,再给针对性建议\\n3. 语气温暖,像一个耐心的教练在陪伴家长成长\\n你的职责:\\n1. 儿童体质调理(如挑食、瘦弱、易疲劳、反复生病)—— 用提问了解情况\\n2. 科学喂养和营养建议 —— 先问饮食现状再给建议\\n3. 日常保健(睡眠、运动、季节防护)—— 引导家长发现生活中的问题\\n4. 体质辨识基础咨询 —— 通过提问帮助家长理解孩子体质特点\\n5. 健康问题判断 —— 先收集症状信息再建议是否就医\\n回答结构: 提问-分析-建议-跟进', " +
  9095. "'你是一个儿童健康教练,喜欢用提问的方式引导家长发现问题和解决方案。\\n核心原则:\\n1. 先问后答:不要直接给答案,用问题引导家长思考\\n2. 用开放式问题了解情况,再给针对性建议\\n3. 语气温暖,像一个耐心的教练在陪伴家长成长\\n你的职责:\\n1. 儿童体质调理(如挑食、瘦弱、易疲劳、反复生病)—— 用提问了解情况\\n2. 科学喂养和营养建议 —— 先问饮食现状再给建议\\n3. 日常保健(睡眠、运动、季节防护)—— 引导家长发现生活中的问题\\n4. 体质辨识基础咨询 —— 通过提问帮助家长理解孩子体质特点\\n5. 健康问题判断 —— 先收集症状信息再建议是否就医\\n回答结构: 提问-分析-建议-跟进', " +
  9096. "1, '儿童健康咨询对话'), " +
  9097. "('chat_assistant', '儿童成长助手', " +
  9098. "'你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。', " +
  9099. "'你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。', " +
  9100. "1, '通用对话助手')"
  9101. );
  9102. log.info("已插入默认系统提示词");
  9103. } catch (Exception e) {
  9104. log.warn("插入默认系统提示词失败: " + e.getMessage());
  9105. }
  9106. // 迁移242: 创建 sys_menu 表(管理后台侧边栏菜单,数据库驱动 + 角色授权)
  9107. try {
  9108. jdbcTemplate.execute(
  9109. "CREATE TABLE IF NOT EXISTS sys_menu (" +
  9110. " id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9111. " parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父菜单ID,0=顶级', " +
  9112. " title VARCHAR(50) COMMENT '目录标题(有子菜单时用,对应 el-submenu)', " +
  9113. " label VARCHAR(50) COMMENT '叶子菜单显示名(对应 el-menu-item)', " +
  9114. " path VARCHAR(200) COMMENT '路由路径(叶子菜单必填,如 /families)', " +
  9115. " icon VARCHAR(100) COMMENT 'Element UI 图标类名,如 el-icon-s-custom', " +
  9116. " perm VARCHAR(100) COMMENT '权限标识,如 family:list / task:* / system:config', " +
  9117. " sort INT DEFAULT 0 COMMENT '排序号,小的在前', " +
  9118. " visible TINYINT DEFAULT 1 COMMENT '是否显示 1=显示 0=隐藏', " +
  9119. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9120. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9121. " INDEX idx_parent (parent_id)" +
  9122. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台侧边栏菜单表'"
  9123. );
  9124. log.info("已创建sys_menu表");
  9125. } catch (Exception e) {
  9126. log.warn("创建sys_menu表失败: " + e.getMessage());
  9127. }
  9128. // 迁移243: 创建 sys_role_menu 表(角色-菜单授权)
  9129. try {
  9130. jdbcTemplate.execute(
  9131. "CREATE TABLE IF NOT EXISTS sys_role_menu (" +
  9132. " id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9133. " role VARCHAR(50) NOT NULL COMMENT '角色标识: admin/teacher/nutritionist/article_manager/activity_manager/supplier_admin', " +
  9134. " menu_id BIGINT NOT NULL COMMENT 'sys_menu.id', " +
  9135. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9136. " UNIQUE KEY uk_role_menu (role, menu_id)" +
  9137. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色-菜单授权表'"
  9138. );
  9139. log.info("已创建sys_role_menu表");
  9140. } catch (Exception e) {
  9141. log.warn("创建sys_role_menu表失败: " + e.getMessage());
  9142. }
  9143. // 迁移244: sys_menu 菜单种子数据(对应 Layout.vue 硬编码 menuItems,表为空才插入,幂等)
  9144. try {
  9145. Integer menuCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_menu", Integer.class);
  9146. if (menuCount == null || menuCount == 0) {
  9147. seedSysMenus();
  9148. log.info("sys_menu 种子数据已插入");
  9149. } else {
  9150. log.info("sys_menu 已有 {} 条数据,跳过种子插入", menuCount);
  9151. }
  9152. } catch (Exception e) {
  9153. log.warn("初始化sys_menu种子数据失败: " + e.getMessage());
  9154. }
  9155. // 迁移245: 创建 notification_templates 表(通知模板管理 W3)
  9156. try {
  9157. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS notification_templates (" +
  9158. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9159. "type VARCHAR(30) NOT NULL COMMENT '通知类型: report_ready/reward_granted/system', " +
  9160. "title VARCHAR(200) NOT NULL COMMENT '默认标题(支持占位符)', " +
  9161. "content VARCHAR(1000) NOT NULL COMMENT '默认内容模板(支持占位符)', " +
  9162. "channels VARCHAR(100) DEFAULT 'in_app' COMMENT '推送通道: in_app,sms,wechat,phone 逗号分隔', " +
  9163. "status TINYINT DEFAULT 1 COMMENT '1启用 0停用', " +
  9164. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9165. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9166. "UNIQUE KEY uk_type (type)" +
  9167. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板(改版v1 W3)'");
  9168. log.info("已创建notification_templates表");
  9169. } catch (Exception e) {
  9170. log.warn("创建notification_templates表可能已存在: {}", e.getMessage());
  9171. }
  9172. // 迁移245b: notification_templates 种子数据(表为空才插入,幂等)
  9173. try {
  9174. Integer tplCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notification_templates", Integer.class);
  9175. if (tplCount == null || tplCount == 0) {
  9176. jdbcTemplate.execute("INSERT INTO notification_templates (type, title, content, channels) VALUES " +
  9177. "('report_ready', '健康报告已完成分析', '您的健康报告已完成解析,可前往查看结果与成长方案。', 'in_app,sms'), " +
  9178. "('reward_granted', '问题域完成奖励已到账', '恭喜完成「{domainName}」全部任务,奖励 {reward} CF 已发放至家庭账户。', 'in_app,sms,wechat'), " +
  9179. "('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app'), " +
  9180. "('energy_change', '能量变动', '{description}获得{amount}能量', 'in_app'), " +
  9181. "('points_change', '积分变动', '{description}变动{amount}积分', 'in_app'), " +
  9182. "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app'), " +
  9183. "('procurement_completed', '采购任务已完成', '您的【{comboTier}】套餐采购任务已完成,共{productCount}件商品,下单时间:{createTime},产品即将送达,请及时查收。', 'in_app,wechat')");
  9184. log.info("notification_templates 种子数据已插入");
  9185. } else {
  9186. log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);
  9187. }
  9188. } catch (Exception e) {
  9189. log.warn("初始化notification_templates种子数据失败: " + e.getMessage());
  9190. }
  9191. // 迁移246: product_skus 存量价格 ×100(元→分),sys_config 幂等标记
  9192. try {
  9193. Integer migrated = jdbcTemplate.queryForObject(
  9194. "SELECT COUNT(*) FROM sys_config WHERE config_key = 'sku_price_to_cents_done'",
  9195. Integer.class);
  9196. if (migrated == null || migrated == 0) {
  9197. jdbcTemplate.update("UPDATE product_skus SET price = IFNULL(price, 0) * 100, original_price = IFNULL(original_price, 0) * 100");
  9198. jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('sku_price_to_cents_done', '1', 'SKU价格已迁移为分单位')");
  9199. log.info("迁移246: product_skus 价格已×100(元→分)");
  9200. } else {
  9201. log.info("迁移246: product_skus 价格已迁移过,跳过");
  9202. }
  9203. } catch (Exception e) {
  9204. log.warn("迁移246失败: {}", e.getMessage());
  9205. }
  9206. // 迁移247: product_skus 添加 linked_product_id 和 label 列(SKU关联商品选择器)
  9207. ensureColumn("product_skus", "linked_product_id", "BIGINT COMMENT '关联商品ID,null=Legacy SKU'");
  9208. ensureColumn("product_skus", "label", "VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name'");
  9209. // 迁移248: families 添加 butler_id(B-5 管家行级隔离)
  9210. ensureColumn("families", "butler_id", "BIGINT COMMENT '绑定的管家userId'");
  9211. try {
  9212. jdbcTemplate.execute("CREATE INDEX idx_families_butler_id ON families(butler_id)");
  9213. log.info("已创建families.butler_id索引");
  9214. } catch (Exception e) {
  9215. String msg = e.getMessage();
  9216. if (msg == null || !(msg.contains("Duplicate") || msg.contains("exists"))) {
  9217. log.warn("创建families.butler_id索引失败: {}", msg);
  9218. }
  9219. }
  9220. // 迁移250: 清理测试脏数据(B-7:TEST-Energy-Grant 测试任务、乱码昵称)
  9221. try {
  9222. int deletedTasks = jdbcTemplate.update("DELETE FROM tasks WHERE title LIKE 'TEST-Energy-Grant%'");
  9223. int fixedNicknames = jdbcTemplate.update("UPDATE users SET nickname = '测试家庭' WHERE nickname LIKE '娴嬭瘯%' AND nickname <> '测试家庭'");
  9224. if (deletedTasks > 0 || fixedNicknames > 0) {
  9225. log.info("迁移250: 已清理测试任务 {} 条,修复乱码昵称 {} 个", deletedTasks, fixedNicknames);
  9226. }
  9227. } catch (Exception e) {
  9228. log.warn("迁移250失败: {}", e.getMessage());
  9229. }
  9230. // 迁移252: 创建用户画像表
  9231. try {
  9232. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS profile_snapshot (" +
  9233. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9234. "member_id BIGINT NOT NULL UNIQUE, " +
  9235. "dimension_scores JSON DEFAULT NULL, " +
  9236. "body_metrics JSON DEFAULT NULL, " +
  9237. "mind_metrics JSON DEFAULT NULL, " +
  9238. "wisdom_metrics JSON DEFAULT NULL, " +
  9239. "action_metrics JSON DEFAULT NULL, " +
  9240. "wealth_metrics JSON DEFAULT NULL, " +
  9241. "problem_domains VARCHAR(500) DEFAULT NULL, " +
  9242. "computed_at DATETIME DEFAULT NULL, " +
  9243. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9244. "INDEX idx_member_id (member_id)" +
  9245. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  9246. log.info("已创建profile_snapshot表");
  9247. } catch (Exception e) {
  9248. log.warn("创建profile_snapshot表可能已存在: {}", e.getMessage());
  9249. }
  9250. try {
  9251. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS profile_history (" +
  9252. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9253. "member_id BIGINT NOT NULL, " +
  9254. "snapshot_date DATE NOT NULL, " +
  9255. "all_metrics JSON NOT NULL, " +
  9256. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9257. "INDEX idx_member_date (member_id, snapshot_date)" +
  9258. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  9259. log.info("已创建profile_history表");
  9260. } catch (Exception e) {
  9261. log.warn("创建profile_history表可能已存在: {}", e.getMessage());
  9262. }
  9263. // 迁移254: 创建 report_template 表(报告展示模板配置)
  9264. try {
  9265. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_template (" +
  9266. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9267. "name VARCHAR(100) NOT NULL COMMENT '模板名称', " +
  9268. "report_type VARCHAR(50) NOT NULL COMMENT '报告类型: gut_flora/dan/physical_exam/tongue', " +
  9269. "config JSON NOT NULL COMMENT '模板配置JSON: fixedBlocks+freeDisplay', " +
  9270. "is_active TINYINT DEFAULT 1 COMMENT '0=停用 1=启用', " +
  9271. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9272. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9273. "UNIQUE KEY uk_report_type (report_type)" +
  9274. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告展示模板'");
  9275. log.info("已创建report_template表");
  9276. } catch (Exception e) {
  9277. log.warn("report_template表可能已存在: {}", e.getMessage());
  9278. }
  9279. // 迁移255: report_unknown_upload 添加 annotation 列(管理员标注训练数据)
  9280. ensureColumn("report_unknown_upload", "annotation", "TEXT COMMENT '管理员标注: 手动指定类型+确认提取结果JSON'");
  9281. // 迁移256: social_circle 添加圈子管理字段
  9282. ensureColumn("social_circle", "description", "VARCHAR(500) NULL COMMENT '圈子描述'");
  9283. ensureColumn("social_circle", "cover_image", "VARCHAR(500) NULL COMMENT '封面图URL'");
  9284. ensureColumn("social_circle", "join_condition", "TINYINT(1) DEFAULT 0 COMMENT '加入条件: 0=公开/1=审核/2=邀请'");
  9285. ensureColumn("social_circle", "review_required", "TINYINT(1) DEFAULT 0 COMMENT '是否需要审核: 0=否/1=是'");
  9286. ensureColumn("social_circle", "is_public", "TINYINT(1) DEFAULT 1 COMMENT '是否公开可见: 0=否/1=是'");
  9287. // 迁移257: social_circle_member 添加审核状态和角色字段
  9288. ensureColumn("social_circle_member", "status", "VARCHAR(20) DEFAULT 'approved' COMMENT '申请状态: pending/approved/rejected'");
  9289. ensureColumn("social_circle_member", "role", "VARCHAR(20) DEFAULT 'member' COMMENT '角色: member/admin'");
  9290. // 迁移258: 创建圈子动态帖子表
  9291. try {
  9292. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_post (" +
  9293. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9294. "circle_id BIGINT NOT NULL COMMENT '圈子ID', " +
  9295. "member_id BIGINT NOT NULL COMMENT '发布者成员ID', " +
  9296. "member_type VARCHAR(20) NOT NULL COMMENT 'parent/child', " +
  9297. "type VARCHAR(50) NOT NULL COMMENT '帖子类型: checkin/diet/exercise/sleep/water/mood/text', " +
  9298. "content TEXT COMMENT '文字内容', " +
  9299. "checkin_id BIGINT COMMENT '关联打卡记录ID', " +
  9300. "image_urls TEXT COMMENT '图片URL列表JSON', " +
  9301. "like_count INT DEFAULT 0 COMMENT '点赞数', " +
  9302. "comment_count INT DEFAULT 0 COMMENT '评论数', " +
  9303. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9304. "deleted TINYINT(1) DEFAULT 0, " +
  9305. "INDEX idx_circle_time (circle_id, created_at), " +
  9306. "INDEX idx_member_time (member_id, created_at)" +
  9307. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子动态帖子'");
  9308. log.info("已创建social_circle_post表");
  9309. } catch (Exception e) {
  9310. log.warn("social_circle_post表可能已存在");
  9311. }
  9312. // 迁移259: 创建圈子点赞表
  9313. try {
  9314. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_like (" +
  9315. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9316. "circle_id BIGINT NOT NULL, " +
  9317. "post_id BIGINT NOT NULL COMMENT '被点赞的帖子ID', " +
  9318. "member_id BIGINT NOT NULL COMMENT '点赞成员ID', " +
  9319. "member_type VARCHAR(20) NOT NULL, " +
  9320. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9321. "UNIQUE KEY uk_like (circle_id, post_id, member_id)" +
  9322. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子点赞记录'");
  9323. log.info("已创建social_circle_like表");
  9324. } catch (Exception e) {
  9325. log.warn("social_circle_like表可能已存在");
  9326. }
  9327. // 迁移260: 创建圈子评论表
  9328. try {
  9329. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_comment (" +
  9330. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9331. "circle_id BIGINT NOT NULL, " +
  9332. "post_id BIGINT NOT NULL COMMENT '所属帖子ID', " +
  9333. "parent_id BIGINT DEFAULT NULL COMMENT '回复的评论ID(NULL=一级评论)', " +
  9334. "member_id BIGINT NOT NULL COMMENT '评论者成员ID', " +
  9335. "member_type VARCHAR(20) NOT NULL, " +
  9336. "content TEXT NOT NULL COMMENT '评论内容', " +
  9337. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9338. "INDEX idx_post_time (post_id, created_at)" +
  9339. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子评论记录'");
  9340. log.info("已创建social_circle_comment表");
  9341. } catch (Exception e) {
  9342. log.warn("social_circle_comment表可能已存在");
  9343. }
  9344. // 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能)
  9345. ensureColumn("butler_profiles", "accepting",
  9346. "TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'");
  9347. // 迁移262: family_join_requests 添加 inviter_user_id 列(审批人=邀请人,谁分享的卡谁确认)
  9348. try {
  9349. jdbcTemplate.execute("ALTER TABLE family_join_requests ADD COLUMN inviter_user_id BIGINT NULL COMMENT '邀请人用户ID(分享邀请令牌的人)' AFTER invite_ref");
  9350. jdbcTemplate.execute("ALTER TABLE family_join_requests ADD INDEX idx_inviter (inviter_user_id)");
  9351. log.info("已添加inviter_user_id列到family_join_requests表");
  9352. } catch (Exception e) {
  9353. // 列/索引已存在,忽略错误
  9354. log.warn("family_join_requests.inviter_user_id可能已存在: {}", e.getMessage());
  9355. }
  9356. // 迁移263: users 表添加 portrait_prompt 列(用户画像 prompt 拼装需求)
  9357. try {
  9358. jdbcTemplate.execute("ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt' AFTER mascot");
  9359. log.info("已添加 portrait_prompt 列到 users 表");
  9360. } catch (Exception e) {
  9361. log.warn("users.portrait_prompt可能已存在: {}", e.getMessage());
  9362. }
  9363. // 迁移264: 方案分解任务补全 family_member_id,修正 deadline 落在次日 00:00 的边界问题
  9364. try {
  9365. jdbcTemplate.update(
  9366. "UPDATE tasks SET family_member_id = child_id " +
  9367. "WHERE source_type = 'plan' AND family_member_id IS NULL AND child_id IS NOT NULL");
  9368. jdbcTemplate.update(
  9369. "UPDATE tasks SET deadline = TIMESTAMP(DATE(deadline)) - INTERVAL 1 SECOND " +
  9370. "WHERE source_type = 'plan' AND TIME(deadline) = '00:00:00'");
  9371. log.info("已修复方案分解任务的 family_member_id 与 deadline 边界");
  9372. } catch (Exception e) {
  9373. log.warn("修复方案分解任务字段失败: {}", e.getMessage());
  9374. }
  9375. // 迁移265: 方案分解任务开启审核,打卡后需规划师/管理员审核通过才发放积分
  9376. try {
  9377. jdbcTemplate.update(
  9378. "UPDATE tasks SET need_review = 1 " +
  9379. "WHERE source_type = 'plan' AND (need_review IS NULL OR need_review = 0) AND status IN ('pending', 'pending_review')");
  9380. log.info("已开启方案分解任务的审核模式");
  9381. } catch (Exception e) {
  9382. log.warn("开启方案分解任务审核失败: {}", e.getMessage());
  9383. }
  9384. // 迁移266: tasks.status 扩展 pending_review/locked(打卡提交待审核、前置任务锁定)
  9385. try {
  9386. jdbcTemplate.execute(
  9387. "ALTER TABLE tasks MODIFY COLUMN `status` " +
  9388. "ENUM('pending','assigned','in_progress','completed','rejected','overdue','cancelled','locked','pending_review') " +
  9389. "DEFAULT 'pending' COMMENT '状态: pending=待完成 assigned=待接受 in_progress=进行中 completed=已完成 rejected=已拒绝 overdue=已过期 cancelled=已取消 locked=前置锁定 pending_review=待审核'");
  9390. log.info("已扩展 tasks.status 枚举,支持 pending_review/locked");
  9391. } catch (Exception e) {
  9392. log.warn("扩展 tasks.status 枚举失败(可能已修改): {}", e.getMessage());
  9393. }
  9394. // 迁移268: coupon 表添加 status 列(优惠券停用/启用)
  9395. migrateCouponStatus();
  9396. // 迁移276: five_dimension_self_checks 表添加 question_ids_json 列(自检轮换用)
  9397. ensureColumn("five_dimension_self_checks", "question_ids_json", "VARCHAR(255) COMMENT '本次自检使用的题号JSON: [1,5,9,...]'");
  9398. // 迁移277: 创建 self_check_ignores 表(记录用户忽略自检提醒的动作,作为 15 天周期重置依据)
  9399. try {
  9400. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS self_check_ignores (" +
  9401. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9402. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  9403. "check_id BIGINT COMMENT '忽略的自检记录ID(可空)', " +
  9404. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间', " +
  9405. "INDEX idx_user_id (user_id)" +
  9406. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录'");
  9407. log.info("已创建 self_check_ignores 表");
  9408. } catch (Exception e) {
  9409. log.warn("创建 self_check_ignores 表失败: {}", e.getMessage());
  9410. }
  9411. // 迁移275: users 表添加 family_inviter_id(家庭邀请追踪)
  9412. ensureColumn("users", "family_inviter_id", "BIGINT DEFAULT NULL COMMENT '家庭邀请人ID(首次注册时绑定,用于追踪邀请来源)'");
  9413. }
  9414. /**
  9415. * 迁移268: coupon 表添加 status 列(优惠券停用/启用,幂等)
  9416. * 存量数据 status 为 NULL,查询层按 ACTIVE 处理,不影响现有券。
  9417. */
  9418. private void migrateCouponStatus() {
  9419. try {
  9420. jdbcTemplate.execute("ALTER TABLE coupon ADD COLUMN status VARCHAR(16) DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE=启用/DISABLED=停用'");
  9421. log.info("迁移268: 已为 coupon 表添加 status 列");
  9422. } catch (Exception e) {
  9423. // 列已存在,忽略错误
  9424. }
  9425. }
  9426. /**
  9427. * 角色-菜单授权(幂等,唯一键 uk_role_menu 冲突忽略)
  9428. */
  9429. private void grantMenuToRole(String role, Long menuId) {
  9430. if (role == null || menuId == null) {
  9431. return;
  9432. }
  9433. try {
  9434. jdbcTemplate.update("INSERT IGNORE INTO sys_role_menu (role, menu_id) VALUES (?, ?)", role, menuId);
  9435. } catch (Exception ignored) {
  9436. }
  9437. }
  9438. /**
  9439. * 查询单个菜单 id,不存在返回 null
  9440. */
  9441. private Long queryMenuId(String sql) {
  9442. try {
  9443. List<Long> ids = jdbcTemplate.queryForList(sql, Long.class);
  9444. return ids.isEmpty() ? null : ids.get(0);
  9445. } catch (Exception e) {
  9446. return null;
  9447. }
  9448. }
  9449. /**
  9450. * 迁移244辅助方法: 插入 sys_menu 种子数据
  9451. * 结构与 cfc-web/src/views/Layout.vue 的 menuItems 硬编码一致
  9452. * 数组格式: {parentKey(空=顶级), key, title(目录), label(叶子), path, icon, perm, sort}
  9453. */
  9454. private void seedSysMenus() {
  9455. Map<String, Long> keyToId = new HashMap<>();
  9456. String[][] menus = {
  9457. // 顶级叶子
  9458. {"", "home", "", "首页", "/dashboard", "el-icon-s-home", "dashboard", "0"},
  9459. // 家庭运营(family:*)
  9460. {"", "operation", "家庭运营", "", "", "el-icon-s-custom", "family:*", "1"},
  9461. {"operation", "families", "", "家庭列表", "/families", "el-icon-s-custom", "family:list,family:read,family:bound", "0"},
  9462. {"operation", "children", "", "孩子管理", "/children", "el-icon-user-solid", "family:children,family:read,family:bound", "1"},
  9463. {"operation", "points", "", "积分管理", "/points", "el-icon-s-finance", "family:points", "2"},
  9464. {"operation", "pointsLog", "", "积分记录", "/points-log", "el-icon-document", "family:points,family:bound", "3"},
  9465. {"operation", "wishes", "", "心愿管理", "/wishes", "el-icon-star-off", "family:wishes", "4"},
  9466. {"operation", "rewards", "", "奖励管理", "/rewards", "el-icon-s-goods", "reward:list", "5"},
  9467. // 方案中心(plan:*,插在家庭运营与任务中心之间)
  9468. {"", "planCenter", "方案中心", "", "", "el-icon-notebook-2", "plan:*", "2"},
  9469. {"planCenter", "healthPlanManage", "", "健康方案管理", "/health-plan-manage", "el-icon-first-aid-kit", "plan:health", "0"},
  9470. {"planCenter", "assessmentPlanManage", "", "测评方案管理", "/assessment-plan-manage", "el-icon-edit", "plan:assessment", "1"},
  9471. // 任务中心(task:*)
  9472. {"", "taskCenter", "任务中心", "", "", "el-icon-s-order", "task:*", "3"},
  9473. {"taskCenter", "tasks", "", "任务列表", "/tasks", "el-icon-s-order", "task:list", "0"},
  9474. {"taskCenter", "taskTemplates", "", "任务模板", "/task-templates", "el-icon-document", "task:templates", "1"},
  9475. {"taskCenter", "growthTask", "", "成长任务管理", "/growth-task", "el-icon-s-management", "task:growth", "2"},
  9476. // 审核中心(audit,归集各审核页:服务角色申请/活动审核/退款/提现)
  9477. {"", "reviewCenterGroup", "审核中心", "", "", "el-icon-s-check", "audit", "4"},
  9478. {"reviewCenterGroup", "reviewCenter", "", "审核中心总览", "/review-center", "el-icon-s-check", "audit", "0"},
  9479. {"reviewCenterGroup", "activityReview", "", "活动审核", "/activity-review", "el-icon-document-checked", "audit:activity", "2"},
  9480. {"reviewCenterGroup", "pendingRefund", "", "待退款管理", "/pending-refund", "el-icon-warning", "audit:refund", "3"},
  9481. {"reviewCenterGroup", "earningWithdraw", "", "收益提现审核", "/family-earnings-withdraw", "el-icon-document-checked", "audit:withdraw", "4"},
  9482. // 商城营销(commerce:*)
  9483. {"", "commerce", "商城营销", "", "", "el-icon-s-goods", "commerce:*", "5"},
  9484. {"commerce", "productManage", "", "商品管理", "/product-manage", "el-icon-s-goods", "commerce:products,commerce:self", "0"},
  9485. {"commerce", "inventory", "", "库存管理", "/inventory", "el-icon-document", "inventory:self", "1"},
  9486. {"commerce", "orderManage", "", "订单管理", "/order-manage", "el-icon-s-order", "commerce:orders,order:self", "2"},
  9487. {"commerce", "ecomSupplier", "", "供应商管理", "/supply-system", "el-icon-s-shop", "system:supply", "3"},
  9488. {"commerce", "productProfitRate", "", "产品利润率", "/product-profit-rate", "el-icon-data-line", "finance:profit-rate", "4"},
  9489. {"commerce", "coupon", "", "优惠券管理", "/coupon", "el-icon-ticket", "commerce:coupon", "5"},
  9490. {"commerce", "couponGrantLog", "", "发券记录", "/coupon-grant-log", "el-icon-document", "commerce:coupon", "6"},
  9491. {"commerce", "discountRules", "", "限时折扣管理", "/discount-rules", "el-icon-timer", "commerce:discount", "7"},
  9492. {"commerce", "promotion", "", "推广管理", "/promotion", "el-icon-s-marketing", "commerce:promotion", "7"},
  9493. {"commerce", "familyEarnings", "", "家庭收益", "/family-earnings", "el-icon-s-money", "finance:family-earnings", "8"},
  9494. // 知识中心(articles:*)
  9495. {"", "knowledge", "知识中心", "", "", "el-icon-document", "articles:*", "6"},
  9496. {"knowledge", "articleCategories", "", "知识分类", "/article-categories", "el-icon-folder", "articles:categories", "0"},
  9497. {"knowledge", "articleManage", "", "知识管理", "/article-manage", "el-icon-document", "articles:manage", "1"},
  9498. {"knowledge", "commentReview", "", "评论审核", "/comment-review", "el-icon-chat-dot-round", "articles:manage", "2"},
  9499. {"knowledge", "knowledgeTags", "", "知识标签", "/knowledge-tags", "el-icon-price-tag", "articles:categories", "3"},
  9500. {"knowledge", "knowledgeBase", "", "知识库", "/knowledge-base", "el-icon-reading", "knowledge:base", "4"},
  9501. {"knowledge", "healthKnowledge", "", "健康知识库", "/health-knowledge", "el-icon-first-aid-kit", "knowledge:base", "5"},
  9502. // 活动管理(activity:*,活动审核已移入审核中心)
  9503. {"", "activity", "活动管理", "", "", "el-icon-date", "activity:*", "7"},
  9504. {"activity", "activities", "", "活动列表", "/activities", "el-icon-date", "activity:list,activity:self", "0"},
  9505. {"activity", "activityRegReview", "", "活动报名审核", "/activity-registration-review", "el-icon-document-checked", "activity:registration", "1"},
  9506. // 健康饮食(health:*,能量规则/行为/周期性服务已移入系统配置·健康配置,LangGraph已移入报告解析系统;报告管理/报告审核/应季食材按方案挂载)
  9507. {"", "health", "健康饮食", "", "", "el-icon-first-aid-kit", "health:*", "8"},
  9508. {"health", "reportManagement", "", "报告管理", "/report-management", "el-icon-document", "health:reports", "0"},
  9509. {"health", "proxyReportUpload", "", "代传报告", "/report-proxy-upload", "el-icon-upload", "health:reports", "1"},
  9510. {"health", "healthIndicators", "", "健康指标", "/health-indicators", "el-icon-data-line", "health:indicators", "2"},
  9511. {"health", "healthCheckins", "", "健康打卡", "/health-checkins", "el-icon-s-order", "health:checkins", "3"},
  9512. {"health", "emotionAlert", "", "情绪告警", "/emotion-alert", "el-icon-warning", "health:checkins", "4"},
  9513. {"health", "nutritionMappings", "", "营养素映射", "/nutrition-mappings", "el-icon-connection", "health:mappings", "5"},
  9514. {"health", "foods", "", "食材管理", "/foods", "el-icon-apple", "diet:foods", "6"},
  9515. {"health", "recipes", "", "食谱管理", "/recipes", "el-icon-dish", "diet:recipes", "7"},
  9516. {"health", "seasonalFoods", "", "应季食材", "/seasonal-foods", "el-icon-apple", "diet:foods", "8"},
  9517. {"health", "nutritionProducts", "", "营养产品", "/nutrition-products", "el-icon-first-aid-kit", "health:products", "9"},
  9518. {"health", "energySandbox", "", "五维能量", "/energy-sandbox", "el-icon-data-line", "health:energy", "10"},
  9519. // 报告解析系统(config:report-parser,LangGraph 管理归入)
  9520. {"", "reportParser", "报告解析系统", "", "", "el-icon-cpu", "config:report-parser", "9"},
  9521. {"reportParser", "reportTypes", "", "报告类型管理", "/report-types", "el-icon-collection", "config:report-parser", "0"},
  9522. {"reportParser", "reportParserImport", "", "解析器导入", "/report-parser-import", "el-icon-upload2", "config:report-parser", "1"},
  9523. {"reportParser", "reportUnknownClusters", "", "未知报告审核", "/report-unknown-clusters", "el-icon-question", "config:report-parser", "2"},
  9524. {"reportParser", "reportAutoLearn", "", "报告自学习", "/report-auto-learn", "el-icon-magic-stick", "config:report-parser", "3"},
  9525. {"reportParser", "langgraphAdmin", "", "LangGraph 管理", "/langgraph-admin", "el-icon-cpu", "config:report-parser", "4"},
  9526. // 家庭服务(service:*)
  9527. {"", "familyService", "家庭服务", "", "", "el-icon-s-custom", "service:*", "10"},
  9528. {"familyService", "myFamilies", "", "我的家庭", "/my-families", "el-icon-s-custom", "service:family", "0"},
  9529. {"familyService", "teacherTeam", "", "我的团队", "/teacher-team", "el-icon-s-custom", "service:team", "1"},
  9530. // 业务管理(biz:*)
  9531. {"", "biz", "业务管理", "", "", "el-icon-s-marketing", "biz:*", "11"},
  9532. {"biz", "teacherPackages", "", "任务模板管理", "/teacher-packages", "el-icon-s-goods", "biz:packages", "0"},
  9533. {"biz", "teacherOrders", "", "订单佣金", "/teacher-orders", "el-icon-s-order", "biz:orders", "1"},
  9534. // 测评咨询(测评管理/测评订单/待分配规划师已从系统配置移入)
  9535. {"", "assessment", "测评咨询", "", "", "el-icon-edit", "assessment:*", "12"},
  9536. {"assessment", "assessmentAdmin", "", "测评管理", "/assessment-admin", "el-icon-edit", "assessment:manage", "0"},
  9537. {"assessment", "assessmentOrders", "", "测评订单", "/assessment-orders", "el-icon-s-order", "assessment:orders", "1"},
  9538. {"assessment", "assessmentAssign", "", "待分配规划师", "/assessment-assign", "el-icon-user", "assessment:assign", "2"},
  9539. {"assessment", "teacherAssessment", "", "DAN测评", "/teacher-assessment", "el-icon-edit", "assessment:dan", "3"},
  9540. {"assessment", "teacherConsult", "", "家长咨询", "/teacher-consult", "el-icon-chat-dot-round", "assessment:consult", "4"},
  9541. {"assessment", "growthRecords", "", "成长记录", "/growth-records", "el-icon-document", "growth:records", "5"},
  9542. {"assessment", "growthPlans", "", "成长计划", "/growth-plans", "el-icon-document", "growth:plans", "6"},
  9543. // 系统配置(system:*,权限拆分 system:base / config:member / config:dimension / config:health)
  9544. {"", "system", "系统配置", "", "", "el-icon-s-tools", "system:*", "13"},
  9545. {"system", "baseManage", "基础管理", "", "", "el-icon-s-tools", "system:base", "0"},
  9546. {"baseManage", "sysConfig", "", "系统配置", "/sys-config", "el-icon-s-tools", "system:base", "0"},
  9547. {"baseManage", "users", "", "用户管理", "/users", "el-icon-user", "system:users", "1"},
  9548. {"baseManage", "operationLogs", "", "操作日志", "/operation-logs", "el-icon-s-order", "system:logs", "2"},
  9549. {"baseManage", "membershipCenter", "", "会员中心", "/membership-center", "el-icon-s-custom", "config:member", "3"},
  9550. {"baseManage", "badgeManage", "", "勋章管理", "/badge-manage", "el-icon-medal", "config:member", "4"},
  9551. {"baseManage", "virtualGoodsConfig", "", "虚拟支付道具", "/virtual-goods-config", "el-icon-goods", "config:member", "5"},
  9552. {"baseManage", "gates", "", "关卡配置", "/gates", "el-icon-key", "system:base", "6"},
  9553. {"baseManage", "menuManage", "", "菜单管理", "/menu-manage", "el-icon-menu", "system:menu", "7"},
  9554. {"system", "configCenter", "配置中心", "", "", "el-icon-setting", "config:dimension", "1"},
  9555. {"configCenter", "dimensionConfig", "", "维度配置", "/dimension-config", "el-icon-data-line", "config:dimension", "0"},
  9556. {"system", "supplyHierarchy", "供应商体系", "", "", "el-icon-s-management", "system:supply", "2"},
  9557. {"supplyHierarchy", "supplySystem", "", "供应商体系", "/supply-system", "el-icon-s-management", "system:supply", "0"},
  9558. {"system", "supplierManage", "供应商管理", "", "", "el-icon-s-shop", "supply:manage", "3"},
  9559. {"supplierManage", "supplyManage", "", "供应商管理", "/supply-manage", "el-icon-s-shop", "supply:manage", "0"},
  9560. {"supplierManage", "supplierProducts", "", "商品管理", "/supplier-products", "el-icon-s-goods", "supply:manage", "1"},
  9561. {"system", "healthConfig", "健康配置", "", "", "el-icon-first-aid-kit", "config:health", "4"},
  9562. {"healthConfig", "healthNormConfig", "", "健康常模", "/health-norm-config", "el-icon-data-line", "config:health", "0"},
  9563. {"healthConfig", "healthDataSource", "", "健康数据源", "/health-data-source", "el-icon-data-line", "config:health", "1"},
  9564. {"healthConfig", "healthEnergyConfig", "", "五维能量配置", "/health-energy-config", "el-icon-data-line", "config:health", "2"},
  9565. {"healthConfig", "energyRule", "", "能量规则管理", "/energy-rule", "el-icon-setting", "config:health", "3"},
  9566. {"healthConfig", "energyBehaviorConfig", "", "能量行为配置", "/energy-behavior-config", "el-icon-edit-outline", "config:health", "4"},
  9567. {"healthConfig", "periodicServiceConfig", "", "周期性服务配置", "/periodic-service-config", "el-icon-refresh", "config:health", "5"},
  9568. {"system", "lifeDimension", "人生维度", "", "", "el-icon-s-custom", "config:dimension", "5"},
  9569. {"lifeDimension", "zodiacConfigs", "", "星座配置", "/zodiac-configs", "el-icon-s-management", "config:dimension", "0"},
  9570. {"lifeDimension", "baziConfigs", "", "八字配置", "/bazi-configs", "el-icon-s-management", "config:dimension", "1"},
  9571. {"lifeDimension", "bloodTypeConfigs", "", "血型配置", "/blood-type-configs", "el-icon-s-management", "config:dimension", "2"},
  9572. {"system", "virtualTeam", "虚拟团队", "", "", "el-icon-s-custom", "system:base", "6"},
  9573. {"virtualTeam", "virtualTeams", "", "虚拟团队", "/virtual-teams", "el-icon-s-custom", "system:base", "0"},
  9574. };
  9575. for (String[] m : menus) {
  9576. Long parentId = 0L;
  9577. if (m[0] != null && !m[0].isEmpty()) {
  9578. parentId = keyToId.get(m[0]);
  9579. if (parentId == null) {
  9580. parentId = 0L;
  9581. }
  9582. }
  9583. String title = m[2] != null && !m[2].isEmpty() ? m[2] : null;
  9584. String label = m[3] != null && !m[3].isEmpty() ? m[3] : null;
  9585. String path = m[4] != null && !m[4].isEmpty() ? m[4] : null;
  9586. String icon = m[5] != null && !m[5].isEmpty() ? m[5] : null;
  9587. String perm = m[6] != null && !m[6].isEmpty() ? m[6] : null;
  9588. int sort = Integer.parseInt(m[7]);
  9589. jdbcTemplate.update(
  9590. "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
  9591. parentId, title, label, path, icon, perm, sort
  9592. );
  9593. Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
  9594. keyToId.put(m[1], id);
  9595. }
  9596. // ===== 迁移:初始化方案规则数据 (P1) =====
  9597. try {
  9598. // 使用 jdbcTemplate 的 DataSource
  9599. var ds = jdbcTemplate.getDataSource();
  9600. if (ds instanceof javax.sql.DataSource) {
  9601. try (Connection conn = ((javax.sql.DataSource) ds).getConnection()) {
  9602. PreparedStatement ps = null;
  9603. try {
  9604. // 插入注意力提升方案规则 1:0-60 分 → 模板 ID=38
  9605. ps = conn.prepareStatement("INSERT INTO assessment_plan_rules (id, dimension, score_min, score_max, template_id, priority, is_active, created_at, updated_at) VALUES (NULL, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE updated_at=NOW()");
  9606. ps.setString(1, "attention");
  9607. ps.setInt(2, 0);
  9608. ps.setInt(3, 60);
  9609. ps.setLong(4, 38L); // 使用现有的模板 ID
  9610. ps.setInt(5, 1);
  9611. ps.setBoolean(6, true);
  9612. ps.executeUpdate();
  9613. log.info("已插入注意力方案规则 1(0-60 分)");
  9614. ps.close();
  9615. // 插入注意力提升方案规则 2:61-100 分 → 模板 ID=39
  9616. ps = conn.prepareStatement("INSERT INTO assessment_plan_rules (id, dimension, score_min, score_max, template_id, priority, is_active, created_at, updated_at) VALUES (NULL, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE updated_at=NOW()");
  9617. ps.setString(1, "attention");
  9618. ps.setInt(2, 61);
  9619. ps.setInt(3, 100);
  9620. ps.setLong(4, 39L);
  9621. ps.setInt(5, 2);
  9622. ps.setBoolean(6, true);
  9623. ps.executeUpdate();
  9624. log.info("已插入注意力方案规则 2(61-100 分)");
  9625. ps.close();
  9626. } finally {
  9627. if (ps != null) ps.close();
  9628. }
  9629. }
  9630. }
  9631. } catch (SQLException e) {
  9632. log.warn("初始化方案规则失败:{}", e.getMessage());
  9633. }
  9634. // ===== 迁移:初始化报告类型注册 (R1) =====
  9635. try {
  9636. // 使用 jdbcTemplate 的 DataSource
  9637. var ds = jdbcTemplate.getDataSource();
  9638. if (ds instanceof javax.sql.DataSource) {
  9639. try (Connection conn = ((javax.sql.DataSource) ds).getConnection()) {
  9640. PreparedStatement ps = null;
  9641. try {
  9642. // 注册肠道菌群检测报告类型
  9643. ps = conn.prepareStatement("INSERT INTO report_type_registry (id, type_id, display_name, parser_path, min_confidence, is_active, source, review_status, version, created_at, updated_at) VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE updated_at=NOW()");
  9644. ps.setString(1, "gut_flora");
  9645. ps.setString(2, "肠道菌群检测");
  9646. ps.setString(3, "dan_reports/gut_flora.py");
  9647. ps.setInt(4, 10);
  9648. ps.setBoolean(5, true);
  9649. ps.setString(6, "prebuilt");
  9650. ps.setString(7, "approved");
  9651. ps.setString(8, "1.0");
  9652. ps.executeUpdate();
  9653. log.info("已注册报告类型:gut_flora");
  9654. ps.close();
  9655. } finally {
  9656. if (ps != null) ps.close();
  9657. }
  9658. }
  9659. }
  9660. } catch (SQLException e) {
  9661. log.warn("注册报告类型 gut_flora 失败:{}", e.getMessage());
  9662. }
  9663. // 迁移278-313: 已移至独立方法 runSystemMigrations()(原误置于本方法仅空表触发)
  9664. }
  9665. // 迁移278-313: 系统级 DDL/数据迁移(原误置于 seedSysMenus 内,空表才执行,生产永远跳过)
  9666. // 抽出为独立方法,由 runMigrations() 无条件调用,确保生产库每次启动补齐。
  9667. private void runSystemMigrations() {
  9668. // 迁移278: 创建 family_coupon 表 + family_coupon_grant_log 表
  9669. // (优惠券全链路改为家庭维度:2026-08-31)
  9670. try {
  9671. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon (" +
  9672. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9673. "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " +
  9674. "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
  9675. "status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED', " +
  9676. "received_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9677. "used_at DATETIME, " +
  9678. "order_id BIGINT COMMENT '核销时写入的订单号', " +
  9679. "INDEX idx_family_coupon (family_id, coupon_id), " +
  9680. "INDEX idx_status (status), " +
  9681. "INDEX idx_order_id (order_id)" +
  9682. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表'");
  9683. log.info("已创建 family_coupon 表");
  9684. } catch (Exception e) {
  9685. log.warn("family_coupon 表已存在,跳过创建: {}", e.getMessage());
  9686. }
  9687. try {
  9688. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon_grant_log (" +
  9689. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9690. "family_id BIGINT NOT NULL COMMENT '受赠家庭ID', " +
  9691. "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
  9692. "grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE', " +
  9693. "period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " +
  9694. "quantity INT DEFAULT 1 COMMENT '发放数量', " +
  9695. "source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " +
  9696. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9697. "UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period), " +
  9698. "INDEX idx_coupon (coupon_id), " +
  9699. "INDEX idx_family (family_id)" +
  9700. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表'");
  9701. log.info("已创建 family_coupon_grant_log 表");
  9702. } catch (Exception e) {
  9703. log.warn("family_coupon_grant_log 表已存在,跳过创建: {}", e.getMessage());
  9704. }
  9705. // 迁移279: 创建 cf_rate_tier 表(CF值返佣阶梯配置,替代 promotion_tier_config)
  9706. try {
  9707. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_rate_tier (" +
  9708. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9709. "tier_name VARCHAR(50) NOT NULL COMMENT '档位名称', " +
  9710. "min_team_size INT NOT NULL DEFAULT 0 COMMENT '团队规模下限(含)', " +
  9711. "rate_percent INT NOT NULL DEFAULT 0 COMMENT '返佣比例(%)', " +
  9712. "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,越大越高', " +
  9713. "enabled TINYINT DEFAULT 1 COMMENT '1启用/0停用', " +
  9714. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9715. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9716. "INDEX idx_min_size (min_team_size)" +
  9717. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值返佣阶梯配置'");
  9718. log.info("已创建cf_rate_tier表");
  9719. Integer cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM cf_rate_tier", Integer.class);
  9720. if (cnt == null || cnt == 0) {
  9721. jdbcTemplate.execute("INSERT INTO cf_rate_tier (tier_name, min_team_size, rate_percent, sort_order) VALUES " +
  9722. "('铜牌', 0, 5, 1), ('银牌', 3, 10, 2), ('金牌', 10, 15, 3), ('铂金', 30, 20, 4), ('钻石', 100, 25, 5)");
  9723. log.info("cf_rate_tier 种子数据已初始化");
  9724. }
  9725. } catch (Exception e) {
  9726. log.warn("创建cf_rate_tier表失败: {}", e.getMessage());
  9727. }
  9728. // 迁移280: 创建 cf_transfer_record 表(CF值流转记录)
  9729. try {
  9730. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_transfer_record (" +
  9731. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9732. "from_user_id BIGINT COMMENT '转出用户ID(null=平台)', " +
  9733. "to_user_id BIGINT COMMENT '转入用户ID', " +
  9734. "family_id BIGINT COMMENT '所属家庭ID', " +
  9735. "amount INT NOT NULL COMMENT 'CF值数量', " +
  9736. "type VARCHAR(16) NOT NULL COMMENT 'transfer/allocate/refund', " +
  9737. "ref_type VARCHAR(50) COMMENT '关联业务类型', " +
  9738. "ref_id BIGINT COMMENT '关联业务ID', " +
  9739. "remark VARCHAR(255), " +
  9740. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9741. "INDEX idx_from (from_user_id), " +
  9742. "INDEX idx_to (to_user_id), " +
  9743. "INDEX idx_family (family_id)" +
  9744. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录'");
  9745. log.info("已创建cf_transfer_record表");
  9746. } catch (Exception e) {
  9747. log.warn("创建cf_transfer_record表失败: {}", e.getMessage());
  9748. }
  9749. // 迁移281: promotion_tier 精简列(删三层,加 rate_percent)
  9750. try {
  9751. jdbcTemplate.execute("ALTER TABLE promotion_tier DROP COLUMN team_size_1st, DROP COLUMN team_size_2nd, DROP COLUMN team_size_3rd");
  9752. log.info("promotion_tier 已删除三层团队列");
  9753. } catch (Exception e) {
  9754. log.warn("promotion_tier 三层列删除失败(可能不存在): {}", e.getMessage());
  9755. }
  9756. try {
  9757. jdbcTemplate.execute("ALTER TABLE promotion_tier ADD COLUMN rate_percent INT DEFAULT 0 COMMENT '当前返佣比例(%)'");
  9758. log.info("promotion_tier 已添加 rate_percent");
  9759. } catch (Exception e) {
  9760. log.warn("promotion_tier.rate_percent 已存在: {}", e.getMessage());
  9761. }
  9762. // 迁移282: coupon 表添加 family_id
  9763. try {
  9764. jdbcTemplate.execute("ALTER TABLE coupon ADD COLUMN family_id BIGINT DEFAULT NULL COMMENT '绑定家庭ID(NULL=不绑定)'");
  9765. log.info("coupon 已添加 family_id");
  9766. } catch (Exception e) {
  9767. log.warn("coupon.family_id 已存在: {}", e.getMessage());
  9768. }
  9769. // 迁移283: referral_tree 唯一键改为 (parent_id, child_id) 联合唯一(支持每祖先一行物化)
  9770. try {
  9771. jdbcTemplate.execute("ALTER TABLE referral_tree DROP INDEX uk_child_id");
  9772. log.info("referral_tree 已删除 uk_child_id 唯一键");
  9773. } catch (Exception e) {
  9774. log.warn("referral_tree.uk_child_id 删除失败(可能不存在): {}", e.getMessage());
  9775. }
  9776. try {
  9777. jdbcTemplate.execute("ALTER TABLE referral_tree ADD UNIQUE KEY uk_parent_child (parent_id, child_id)");
  9778. log.info("referral_tree 已添加 uk_parent_child 联合唯一键");
  9779. } catch (Exception e) {
  9780. log.warn("referral_tree.uk_parent_child 已存在: {}", e.getMessage());
  9781. }
  9782. // 迁移284: cf_transfer_record 加 (ref_type, ref_id) 唯一索引(幂等兜底,防止并发 earn 重复发放)
  9783. try {
  9784. jdbcTemplate.execute("ALTER TABLE cf_transfer_record ADD UNIQUE KEY uk_ref_type_id (ref_type, ref_id)");
  9785. log.info("cf_transfer_record 已添加 uk_ref_type_id 唯一索引");
  9786. } catch (Exception e) {
  9787. log.warn("cf_transfer_record.uk_ref_type_id 已存在: {}", e.getMessage());
  9788. }
  9789. // 迁移285: 创建 product_gift_configs 表(商品赠品配置,商品×等级)
  9790. try {
  9791. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_configs (" +
  9792. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9793. "product_id BIGINT NOT NULL COMMENT '关联products表', " +
  9794. "level_code VARCHAR(50) NOT NULL COMMENT '会员等级编码(FAMILY/PREMIUM等)', " +
  9795. "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " +
  9796. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9797. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  9798. "UNIQUE KEY uk_product_level (product_id, level_code), " +
  9799. "INDEX idx_product_id (product_id), " +
  9800. "INDEX idx_level_code (level_code)" +
  9801. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置(商品×等级)'");
  9802. log.info("已创建product_gift_configs表");
  9803. } catch (Exception e) {
  9804. // 表已存在,忽略
  9805. }
  9806. // 迁移286: 创建 product_gift_items 表(商品赠品项)
  9807. try {
  9808. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_items (" +
  9809. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9810. "config_id BIGINT NOT NULL COMMENT '关联product_gift_configs.id', " +
  9811. "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " +
  9812. "gift_product_id BIGINT COMMENT '赠品商品ID(gift_type=product时必填)', " +
  9813. "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " +
  9814. "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " +
  9815. "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时必填,单位分)', " +
  9816. "sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)', " +
  9817. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9818. "INDEX idx_config_id (config_id)" +
  9819. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品项'");
  9820. log.info("已创建product_gift_items表");
  9821. } catch (Exception e) {
  9822. // 表已存在,忽略
  9823. }
  9824. // 迁移287: 创建 product_order_gifts 表(订单赠品记录)
  9825. try {
  9826. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_order_gifts (" +
  9827. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  9828. "order_id BIGINT NOT NULL COMMENT '关联product_orders.id', " +
  9829. "order_no VARCHAR(64) NOT NULL COMMENT '订单号快照', " +
  9830. "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " +
  9831. "gift_product_id BIGINT COMMENT '赠品商品ID', " +
  9832. "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " +
  9833. "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " +
  9834. "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时)', " +
  9835. "quantity INT DEFAULT 1 COMMENT '数量(默认1)', " +
  9836. "fulfilled TINYINT(1) DEFAULT 0 COMMENT '0=已选未发放 1=已发放', " +
  9837. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  9838. "INDEX idx_order_id (order_id), " +
  9839. "INDEX idx_order_no (order_no)" +
  9840. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单赠品记录'");
  9841. log.info("已创建product_order_gifts表");
  9842. } catch (Exception e) {
  9843. // 表已存在,忽略
  9844. }
  9845. // 迁移288: health_report_drafts 加异步采集状态列(报告采集流程异步化)
  9846. // 用途:上传后进入后台异步采集(指纹判定→专用脚本/opencode),前端轮询 parse_status
  9847. ensureColumn("health_report_drafts", "parse_status",
  9848. "VARCHAR(20) DEFAULT NULL COMMENT '异步解析状态: collecting/completed/failed(NULL=未触发异步采集)'");
  9849. ensureColumn("health_report_drafts", "parse_method",
  9850. "VARCHAR(32) DEFAULT NULL COMMENT '解析方式: fingerprint_script/opencode/langgraph'");
  9851. ensureColumn("health_report_drafts", "parse_error",
  9852. "VARCHAR(2000) DEFAULT NULL COMMENT '异步解析失败原因'");
  9853. log.info("已添加 health_report_drafts 异步采集状态列 (parse_status/parse_method/parse_error)");
  9854. // 迁移289: health_reports 补 report_format 列(HealthReport 实体已含字段,但从未迁移至 DB,全字段 SELECT 报 Unknown column)
  9855. ensureColumn("health_reports", "report_format",
  9856. "VARCHAR(32) DEFAULT NULL COMMENT '报告格式: pdf/image'");
  9857. // 迁移290: 补齐实体已含但 DB 从未迁移的真实缺列(全字段 SELECT 防 Unknown column)
  9858. // 逐表核实(对照 schema.sql + 历史 ADD COLUMN/ensureColumn),仅含确认不存在的列,共 6 表 21 列
  9859. // 风险预留:promotion_tier_config 实体与 DB 为两套不同晋级模型(已废弃,由 cf_rate_tier 迁移279 替代),
  9860. // 按用户决定保持现状不迁移,避免与旧种子数据冲突;后续清理旧实体依赖(CommissionService等)时再处理
  9861. ensureColumn("activities", "visibility", "VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private'");
  9862. ensureColumn("activities", "visible_to", "TEXT DEFAULT NULL COMMENT '私密指定人群JSON'");
  9863. ensureColumn("dan_knowledge_base", "file_url", "VARCHAR(500) DEFAULT NULL COMMENT 'fileUrl'");
  9864. ensureColumn("dan_knowledge_base", "file_name", "VARCHAR(255) DEFAULT NULL COMMENT 'fileName'");
  9865. ensureColumn("dan_knowledge_base", "source_url", "VARCHAR(500) DEFAULT NULL COMMENT 'sourceUrl'");
  9866. ensureColumn("dan_knowledge_base", "source_type", "VARCHAR(20) DEFAULT NULL COMMENT 'sourceType'");
  9867. ensureColumn("distribution_relations", "invite_code", "VARCHAR(64) DEFAULT NULL COMMENT 'inviteCode'");
  9868. ensureColumn("family_members", "birth_hour", "VARCHAR(10) DEFAULT NULL COMMENT '出生时辰'");
  9869. ensureColumn("family_members", "birth_weight", "INT DEFAULT NULL COMMENT '出生体重(g)'");
  9870. ensureColumn("family_members", "birth_place", "VARCHAR(255) DEFAULT NULL COMMENT '出生地'");
  9871. ensureColumn("family_members", "is_cesarean", "TINYINT(1) DEFAULT 0 COMMENT '是否剖腹产: 0否 1是'");
  9872. ensureColumn("health_indicators", "symptoms", "TEXT DEFAULT NULL COMMENT '症状描述'");
  9873. ensureColumn("product_orders", "tracking_number", "VARCHAR(100) DEFAULT NULL COMMENT '快递单号'");
  9874. ensureColumn("product_orders", "express_company", "VARCHAR(100) DEFAULT NULL COMMENT '快递公司'");
  9875. ensureColumn("product_orders", "delivery_method", "INT DEFAULT NULL COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人'");
  9876. ensureColumn("product_orders", "pickup_code", "VARCHAR(64) DEFAULT NULL COMMENT '自提码'");
  9877. ensureColumn("product_orders", "confirm_time", "DATETIME DEFAULT NULL COMMENT '确认收货时间'");
  9878. ensureColumn("product_orders", "refund_status", "INT DEFAULT NULL COMMENT '退款状态'");
  9879. ensureColumn("product_orders", "refund_reason", "VARCHAR(255) DEFAULT NULL COMMENT '退款原因'");
  9880. ensureColumn("product_orders", "refund_amount", "INT DEFAULT NULL COMMENT '退款金额(分)'");
  9881. ensureColumn("product_orders", "refund_time", "DATETIME DEFAULT NULL COMMENT '退款时间'");
  9882. log.info("已补齐真实缺列 (迁移290, 6表21列)");
  9883. // 迁移291: food_nutrients 列名对齐(amount_per_100g -> amount_per100g)
  9884. // FoodNutrient 实体字段 amountPer100g 经 MyBatis-Plus camel->snake 映射为 amount_per100g,
  9885. // 但建表 SQL 误用 amount_per_100g,导致实体全字段 SELECT 报 Unknown column 'amount_per100g'。
  9886. // 当前 FoodNutrientMapper 无 Service 调用,属潜在隐患;此处统一列名为 amount_per100g。
  9887. try {
  9888. Integer cnt = jdbcTemplate.queryForObject(
  9889. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " +
  9890. "AND TABLE_NAME = 'food_nutrients' AND COLUMN_NAME = 'amount_per_100g'", Integer.class);
  9891. if (cnt != null && cnt > 0) {
  9892. jdbcTemplate.execute("ALTER TABLE food_nutrients CHANGE COLUMN amount_per_100g amount_per100g DECIMAL(10,4) NOT NULL COMMENT '每100g含量'");
  9893. log.info("已重命名 food_nutrients.amount_per_100g -> amount_per100g");
  9894. }
  9895. } catch (Exception e) {
  9896. // 表不存在或列不存在,忽略
  9897. }
  9898. // 迁移292: health_status 补 member_id 并改为成员级归档(Unknown column 'member_id')
  9899. // 迁移239 建表只有 user_id,schema.sql 与实体已含 member_id(成员级档案),生产缺列导致 selectOne 报错。
  9900. // 原 UNIQUE KEY uk_user(user_id) 限制每账号仅一行,与成员级多行归档冲突,改为复合索引 idx_user_member。
  9901. ensureColumn("health_status", "member_id", "BIGINT DEFAULT NULL COMMENT '家庭成员ID(NULL=账号级,非空=成员级归档)'");
  9902. try {
  9903. jdbcTemplate.execute("ALTER TABLE health_status DROP INDEX uk_user");
  9904. log.info("已移除 health_status.uk_user 唯一键(允许成员级多行归档)");
  9905. } catch (Exception e) {
  9906. // 索引不存在,忽略
  9907. }
  9908. try {
  9909. jdbcTemplate.execute("ALTER TABLE health_status ADD INDEX idx_user_member (user_id, member_id)");
  9910. log.info("已添加 health_status.idx_user_member 复合索引");
  9911. } catch (Exception e) {
  9912. // 索引已存在,忽略
  9913. }
  9914. // 迁移293: family_members 添加 is_admin 列(后台家庭成员仅区分管理员/成员,不再区分家长/孩子)
  9915. ensureColumn("family_members", "is_admin", "TINYINT(1) DEFAULT 0 COMMENT '是否家庭管理员: 1=管理员, 0=普通成员'");
  9916. // 回填:将每个家庭的创建者(families.creator_id 匹配 family_members.user_id)设为管理员
  9917. try {
  9918. jdbcTemplate.execute("UPDATE family_members fm JOIN families f ON fm.family_id = f.id " +
  9919. "SET fm.is_admin = 1 WHERE fm.user_id = f.creator_id AND (fm.is_admin IS NULL OR fm.is_admin = 0)");
  9920. log.info("迁移293: 已回填家庭创建者为管理员");
  9921. } catch (Exception e) {
  9922. log.warn("迁移293: 回填家庭创建者为管理员失败: {}", e.getMessage());
  9923. }
  9924. // 迁移294: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
  9925. ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
  9926. log.info("已添加 articles.sort_order 列(迁移294)");
  9927. // 迁移294b: articles 表添加 audio_url/audio_text 列(文章AI朗读音频)
  9928. ensureColumn("articles", "audio_url", "VARCHAR(500) DEFAULT NULL COMMENT 'AI朗读音频URL'");
  9929. ensureColumn("articles", "audio_text", "TEXT COMMENT '朗读文本(纯文本,缺省从content剥离)'");
  9930. log.info("已添加 articles.audio_url/audio_text 列(迁移294b)");
  9931. // 迁移295: task_plan_instances 补齐实体已含但 DB 缺失的审核/确认/激活列(review/activate 写字段报 Unknown column)
  9932. ensureColumn("task_plan_instances", "source", "VARCHAR(20) DEFAULT NULL COMMENT '来源: manual/assessment/template'");
  9933. ensureColumn("task_plan_instances", "source_result_id", "BIGINT DEFAULT NULL COMMENT '关联测评结果ID(source=assessment时)'");
  9934. ensureColumn("task_plan_instances", "remark", "TEXT COMMENT '方案备注(含指导意见摘要)'");
  9935. ensureColumn("task_plan_instances", "reviewed_by", "BIGINT DEFAULT NULL COMMENT '审核人(规划师ID)'");
  9936. ensureColumn("task_plan_instances", "reviewed_at", "DATETIME DEFAULT NULL COMMENT '审核时间'");
  9937. ensureColumn("task_plan_instances", "review_comment", "TEXT COMMENT '审核意见'");
  9938. ensureColumn("task_plan_instances", "confirmed_by", "BIGINT DEFAULT NULL COMMENT '确认人(家长ID)'");
  9939. ensureColumn("task_plan_instances", "confirmed_at", "DATETIME DEFAULT NULL COMMENT '确认时间'");
  9940. ensureColumn("task_plan_instances", "activated_at", "DATETIME DEFAULT NULL COMMENT '激活时间'");
  9941. log.info("迁移295: 已补齐 task_plan_instances 审核/确认/激活列");
  9942. // 迁移296: 健康方案任务 source_type 'plan' -> 'health_plan'(消除与 task_plan_instances 的 ID 域冲突)
  9943. try {
  9944. // 冲突检测:source_id 同时存在于 health_plans 和 task_plan_instances 的任务(需人工确认,不阻断迁移)
  9945. Integer conflict = jdbcTemplate.queryForObject(
  9946. "SELECT COUNT(*) FROM tasks t WHERE t.source_type = 'plan' " +
  9947. "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = t.source_id) " +
  9948. "AND EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = t.source_id)",
  9949. Integer.class);
  9950. if (conflict != null && conflict > 0) {
  9951. log.warn("迁移296: 检测到{}条冲突任务(source_id同时存在于health_plans和task_plan_instances),需人工确认;无冲突部分仍继续迁移", conflict);
  9952. }
  9953. // 始终执行 UPDATE:仅迁移"存在于 health_plans 且不存在于 task_plan_instances"的任务(冲突部分不迁移,避免误改套餐任务)
  9954. int updated = jdbcTemplate.update(
  9955. "UPDATE tasks SET source_type = 'health_plan' " +
  9956. "WHERE source_type = 'plan' " +
  9957. "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = tasks.source_id) " +
  9958. "AND NOT EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = tasks.source_id)");
  9959. log.info("迁移296: 已修正{}条健康方案任务 source_type -> health_plan", updated);
  9960. } catch (Exception e) {
  9961. log.warn("迁移296执行失败(health_plans 表可能不存在): {}", e.getMessage());
  9962. }
  9963. // 迁移297: 认知训练方案任务补设 source_type='training_plan'(training_plan_tasks 关联溯源)
  9964. try {
  9965. Integer updated = jdbcTemplate.update(
  9966. "UPDATE tasks t SET t.source_type = 'training_plan' " +
  9967. "WHERE (t.source_type IS NULL OR t.source_type = '') " +
  9968. "AND EXISTS (SELECT 1 FROM training_plan_tasks pt WHERE pt.task_id = t.id)");
  9969. log.info("迁移297: 已补设{}条认知训练方案任务 source_type -> training_plan", updated);
  9970. } catch (Exception e) {
  9971. log.warn("迁移297执行失败(training_plan_tasks 表可能不存在): {}", e.getMessage());
  9972. }
  9973. // 迁移298: 存量 role='child' 账号强制迁移为成员模型
  9974. // 1) 有 openid 的 child → role 改 parent(可登录成员,登录后进成员端界面,不能被切换)
  9975. // 2) 无 openid 的 child → 保持不可登录,确保有 family_members 记录(仅可被切换)
  9976. try {
  9977. Integer updated = jdbcTemplate.update(
  9978. "UPDATE users SET role='parent' WHERE role='child' AND openid IS NOT NULL AND openid <> ''");
  9979. log.info("迁移298: 已迁移{}条有 openid 的 child 账号 role → parent", updated);
  9980. } catch (Exception e) {
  9981. log.warn("迁移298第一步失败", e);
  9982. }
  9983. try {
  9984. jdbcTemplate.execute(
  9985. "INSERT INTO family_members (family_id, user_id, nickname, gender, birthday, generation, show_to_family, total_points, created_at, updated_at) " +
  9986. "SELECT u.family_id, u.id, u.nickname, u.gender, u.birthday, -1, 1, 0, NOW(), NOW() " +
  9987. "FROM users u " +
  9988. "WHERE u.role='child' AND (u.openid IS NULL OR u.openid='') " +
  9989. "AND u.family_id IS NOT NULL " +
  9990. "AND NOT EXISTS (SELECT 1 FROM family_members fm WHERE fm.user_id = u.id)");
  9991. log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记录");
  9992. } catch (Exception e) {
  9993. log.warn("迁移298第二步失败", e);
  9994. }
  9995. // 迁移322: 废弃 children 表 —— 数据迁移到 family_members 后删除表(幂等,表不存在时跳过)
  9996. try {
  9997. Integer childrenTableCnt = jdbcTemplate.queryForObject(
  9998. "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'children'",
  9999. Integer.class);
  10000. if (childrenTableCnt == null || childrenTableCnt == 0) {
  10001. log.info("迁移322: children 表不存在,跳过(已废弃)");
  10002. } else {
  10003. // 1. 匹配更新:family_members 已存在该成员记录时补全字段
  10004. jdbcTemplate.execute(
  10005. "UPDATE family_members fm INNER JOIN children c " +
  10006. "ON fm.family_id = c.family_id AND fm.nickname = c.nickname AND fm.user_id = c.user_id " +
  10007. "SET fm.id_card = c.id_card, fm.penalty_enabled = c.penalty_enabled, fm.theme = c.theme, " +
  10008. "fm.system_points = c.system_points, fm.streak_days = c.streak_days, fm.last_task_date = c.last_task_date, " +
  10009. "fm.focus_max_daily = c.focus_max_daily, fm.focus_remaining = c.focus_remaining, " +
  10010. "fm.focus_reset_date = c.focus_reset_date, fm.total_points = c.total_points, " +
  10011. "fm.show_to_family = c.show_to_family, fm.birthday = c.birthday, fm.gender = c.gender, " +
  10012. "fm.phone = c.phone, fm.role_override = 'child'");
  10013. log.info("迁移322: 已更新 family_members 中已存在的 children 匹配记录字段");
  10014. // 2. 不匹配则插入新记录
  10015. jdbcTemplate.execute(
  10016. "INSERT INTO family_members (family_id, user_id, nickname, birthday, gender, phone, " +
  10017. "id_card, penalty_enabled, theme, system_points, streak_days, last_task_date, " +
  10018. "focus_max_daily, focus_remaining, focus_reset_date, show_to_family, total_points, " +
  10019. "role_override, created_at, updated_at) " +
  10020. "SELECT family_id, user_id, nickname, birthday, gender, phone, " +
  10021. "id_card, penalty_enabled, theme, system_points, streak_days, last_task_date, " +
  10022. "focus_max_daily, focus_remaining, focus_reset_date, show_to_family, total_points, " +
  10023. "'child', created_at, updated_at " +
  10024. "FROM children " +
  10025. "WHERE NOT EXISTS (SELECT 1 FROM family_members fm2 WHERE fm2.family_id = children.family_id " +
  10026. "AND fm2.nickname = children.nickname AND fm2.user_id = children.user_id)");
  10027. log.info("迁移322: 已从 children 插入不匹配记录到 family_members");
  10028. // 3. 删除 children 表
  10029. jdbcTemplate.execute("DROP TABLE IF EXISTS children");
  10030. log.info("迁移322: 已删除 children 表(数据已迁移到 family_members)");
  10031. }
  10032. } catch (Exception e) {
  10033. log.warn("迁移322执行失败(children 表可能不存在或迁移范围内字段缺失): {}", e.getMessage());
  10034. }
  10035. ensureColumn("promotion_tier", "rate_percent", "INT DEFAULT 0 COMMENT '当前返佣比例(%)'");
  10036. // 迁移300: family_members 添加 status 列(成员状态: active/recycled,非微信用户被移出进回收箱)
  10037. ensureColumn("family_members", "status", "VARCHAR(16) DEFAULT 'active' COMMENT '成员状态: active=正常, recycled=回收箱'");
  10038. // ===== 生产库对账候选风险记录(已核对生产库,2026-09,按用户决定不迁移/暂不动,仅记录)=====
  10039. // 注:以下为 entity 期望但生产库未对齐.项目,用户已决定保持现状或记为候选,勿重复迁移。
  10040. // exportUserData 全字段 SELECT 会报 Unknown column 'phone')。schema.sql 已将 children 标为
  10041. // 已废除/并入 family_members,用户决定不迁移。
  10042. // 2. user_trigger_rules:生产表仅剩 user_id(PK,auto_increment)/action_config/last_triggered_at,
  10043. // 实体期望 id/trigger_type/trigger_action/status/created_at 共 8 列,表 0 行且不在 schema.sql。
  10044. // UserSegmentationService.getTriggerRules 会查 status,命中时会报 Unknown column 'status'。候选。
  10045. // 3. promotion_tier.rate_percent:生产用旧模型(tier/team_size_1st/2nd/3rd/total_*),实体期望新模型,
  10046. // 4. promotion_tier_config:实体已废弃,改用 cf_rate_tier(迁移279),保持现状。
  10047. // 5. family_members.member_type:实体为 VO 计算值已加 @TableField(exist=false)(不落库),schema.sql
  10048. // 无此列;2026-09-06 生产已手动 ALTER 补列(JwtInterceptor/SurveyServiceImpl 查询报 Unknown column
  10049. // 'member_type' 修复,生产旧代码兼容用),部署含 exist=false 的新代码后该列不再被引用,可留可删。
  10050. // 迁移302: tasks表新增is_daily_progress/target_value字段(统一任务系统;原迁移298用 AFTER source_id,列缺失时会被静默吞掉)
  10051. ensureColumn("tasks", "is_daily_progress", "TINYINT DEFAULT 0 COMMENT '1=每日进度型任务(无deadline,每日重置)'");
  10052. ensureColumn("tasks", "target_value", "INT DEFAULT NULL COMMENT '进度目标值(is_daily_progress=1时有效)'");
  10053. try {
  10054. jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN source_type VARCHAR(32) DEFAULT NULL COMMENT '来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase'");
  10055. log.info("已更新tasks.source_type注释");
  10056. } catch (Exception e) {
  10057. // 修改失败,忽略
  10058. }
  10059. // 迁移296: admin_task_templates表新增onboarding_sequence字段(统一任务系统:新人引导复用任务模板表)
  10060. try {
  10061. jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN onboarding_sequence INT DEFAULT NULL COMMENT '新人引导顺序(source_type=onboarding的模板用)' AFTER teacher_id");
  10062. log.info("已添加onboarding_sequence列到admin_task_templates表");
  10063. } catch (Exception e) {
  10064. // 列已存在,忽略
  10065. }
  10066. // 迁移297: 统一任务系统数据迁移——growth_task DAILY定义迁入admin_task_templates(仅执行一次,幂等)
  10067. try {
  10068. Integer migrated = jdbcTemplate.queryForObject(
  10069. "SELECT COUNT(*) FROM sys_config WHERE config_key = 'TASK_UNIFY_GT_MIGRATED'", Integer.class);
  10070. if (migrated == null || migrated == 0) {
  10071. // DAILY 类型 → admin_task_templates(title 拼入 [task_key] 前缀,供 updateProgress 匹配)
  10072. jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, created_at, updated_at) " +
  10073. "SELECT CONCAT('[', task_key, ']', title), description, reward_points, '成长', 0, enabled, 'recurring', 'daily', created_at, created_at " +
  10074. "FROM growth_task WHERE type = 'DAILY' AND enabled = 1");
  10075. jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('TASK_UNIFY_GT_MIGRATED', '1', '统一任务系统: growth_task DAILY定义已迁入admin_task_templates')");
  10076. log.info("迁移297: growth_task DAILY定义已迁入admin_task_templates");
  10077. }
  10078. } catch (Exception e) {
  10079. log.warn("迁移297: growth_task迁移失败或已执行: {}", e.getMessage());
  10080. }
  10081. // 说明:growth_task_log 历史进度、onboarding_task 存量数据仅保留在旧表(只读备份),不回填 tasks(历史数据无法1:1映射到新模型)
  10082. // 迁移299: 插入新人引导任务模板种子数据(统一任务系统:onboarding 复用 admin_task_templates)
  10083. // title = 原 taskType(REGISTER/COMPLETE_PROFILE/...),onboarding_sequence 定义顺序链
  10084. try {
  10085. Integer onboardCount = jdbcTemplate.queryForObject(
  10086. "SELECT COUNT(*) FROM admin_task_templates WHERE onboarding_sequence IS NOT NULL", Integer.class);
  10087. if (onboardCount == null || onboardCount == 0) {
  10088. jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, onboarding_sequence, created_at, updated_at) VALUES " +
  10089. "('REGISTER', '完成注册', 100, '成长', 0, 1, 'onetime', 'none', 1, NOW(), NOW()), " +
  10090. "('COMPLETE_PROFILE', '完善个人资料', 50, '成长', 0, 1, 'onetime', 'none', 2, NOW(), NOW()), " +
  10091. "('ADD_CHILD', '添加家庭成员', 100, '成长', 0, 1, 'onetime', 'none', 3, NOW(), NOW()), " +
  10092. "('FIRST_CHECKIN', '完成首次健康打卡', 30, '成长', 0, 1, 'onetime', 'none', 4, NOW(), NOW()), " +
  10093. "('FIRST_TASK', '完成首次任务', 50, '成长', 0, 1, 'onetime', 'none', 5, NOW(), NOW()), " +
  10094. "('BIND_REFERRAL', '绑定推荐人', 50, '成长', 0, 1, 'onetime', 'none', 6, NOW(), NOW()), " +
  10095. "('COMPLETE_SELF_CHECK', '完成一次五维自检', 60, '成长', 0, 1, 'onetime', 'none', 7, NOW(), NOW())");
  10096. log.info("迁移299: 已插入7条新人引导任务模板种子数据");
  10097. }
  10098. } catch (Exception e) {
  10099. log.warn("迁移299: 插入新人引导任务模板失败或已存在: {}", e.getMessage());
  10100. }
  10101. // 迁移301: platform_balance_log 加 (user_id, ref_type, ref_id) 唯一索引(earn 幂等 DB 兜底,防止并发重复发放)
  10102. try {
  10103. jdbcTemplate.execute("DELETE l1 FROM platform_balance_log l1 " +
  10104. "INNER JOIN platform_balance_log l2 ON l1.user_id = l2.user_id " +
  10105. "AND l1.ref_type = l2.ref_type AND l1.ref_id = l2.ref_id AND l1.id > l2.id " +
  10106. "WHERE l1.ref_id IS NOT NULL AND l1.ref_type IS NOT NULL");
  10107. log.info("迁移301: platform_balance_log 历史重复流水已清理");
  10108. jdbcTemplate.execute("ALTER TABLE platform_balance_log ADD UNIQUE KEY uk_user_ref (user_id, ref_type, ref_id)");
  10109. log.info("迁移301: platform_balance_log 已添加 uk_user_ref 唯一索引");
  10110. } catch (Exception e) {
  10111. log.warn("迁移301: platform_balance_log 唯一索引处理失败: {}", e.getMessage());
  10112. }
  10113. // 迁移302: 创建 contact_help_logs 表(章鱼图帮助记录)
  10114. try {
  10115. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS contact_help_logs (" +
  10116. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10117. "user_id BIGINT NOT NULL COMMENT '被帮助者(当前用户)', " +
  10118. "contact_id BIGINT NOT NULL COMMENT '帮助者(联系人)', " +
  10119. "help_type VARCHAR(32) COMMENT '帮助类型: MONEY/ITEM/EMOTION/RESOURCE/SKILL/TIME', " +
  10120. "amount DECIMAL(10,2) DEFAULT 0 COMMENT '帮助金额(非金钱类为0)', " +
  10121. "description VARCHAR(255) COMMENT '帮助内容简述', " +
  10122. "happened_at DATETIME COMMENT '帮助发生时间', " +
  10123. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10124. "INDEX idx_chl_user (user_id), " +
  10125. "INDEX idx_chl_contact (contact_id) " +
  10126. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人帮助记录(章鱼图数据源)'");
  10127. log.info("已创建contact_help_logs表");
  10128. } catch (Exception e) {
  10129. log.warn("创建contact_help_logs表失败: {}", e.getMessage());
  10130. }
  10131. // 迁移303: 创建 resource_items 表(珍珠图资源登记:信息/场所/技能类资源)
  10132. try {
  10133. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS resource_items (" +
  10134. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10135. "user_id BIGINT NOT NULL COMMENT '资源拥有者(当前用户)', " +
  10136. "type VARCHAR(16) NOT NULL COMMENT '资源类型: INFO(信息)/PLACE(场所)/SKILL(技能)', " +
  10137. "name VARCHAR(100) NOT NULL COMMENT '资源名称', " +
  10138. "description VARCHAR(255) COMMENT '资源描述(可调用方式/说明)', " +
  10139. "contact_id BIGINT DEFAULT NULL COMMENT '关联联系人ID(可选)', " +
  10140. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10141. "INDEX idx_ri_user (user_id) " +
  10142. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图资源登记表(信息/场所/技能类资源)'");
  10143. log.info("已创建resource_items表");
  10144. } catch (Exception e) {
  10145. log.warn("创建resource_items表失败: {}", e.getMessage());
  10146. }
  10147. // 迁移304: health_gut_flora.bacteria_name 改为可空(LLM解析偶发缺菌种名,NOT NULL 导致采集入库失败)
  10148. try {
  10149. jdbcTemplate.execute("ALTER TABLE health_gut_flora MODIFY COLUMN bacteria_name VARCHAR(100) NULL COMMENT '菌种名称'");
  10150. log.info("迁移304: health_gut_flora.bacteria_name 已改为可空");
  10151. } catch (Exception e) {
  10152. log.warn("迁移304: health_gut_flora.bacteria_name 修改失败: {}", e.getMessage());
  10153. }
  10154. // 迁移305: health_indicators.status 放宽到 VARCHAR(100)(opencode 回退解析产出自由文本 status,
  10155. // 超长写入报 Data too long for column 'status',draft 入库失败)
  10156. try {
  10157. jdbcTemplate.execute("ALTER TABLE health_indicators MODIFY COLUMN status VARCHAR(100) NULL COMMENT '评估状态: 正常/偏低/偏高/过多/注意/低风险/未检出'");
  10158. log.info("迁移305: health_indicators.status 已放宽到 VARCHAR(100)");
  10159. } catch (Exception e) {
  10160. log.warn("迁移305: health_indicators.status 修改失败: {}", e.getMessage());
  10161. }
  10162. // 迁移306: products表添加visible_roles列(商品类型权限过滤,NULL=全部角色可见)
  10163. try {
  10164. jdbcTemplate.execute("ALTER TABLE products ADD COLUMN visible_roles JSON DEFAULT NULL COMMENT '可见角色: [\"parent\",\"child\",\"teacher\",\"admin\"],NULL=全部可见'");
  10165. log.info("迁移306: products.visible_roles 列已添加");
  10166. } catch (Exception e) {
  10167. // ignore
  10168. }
  10169. // 迁移307: resource_items 表添加社会连接分类字段(珍珠图重构:25 类连接类型 + 价值分级)
  10170. try {
  10171. jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN connection_type VARCHAR(50) NOT NULL DEFAULT '' COMMENT '连接类型枚举名(ConnectionType)'");
  10172. } catch (Exception e) {
  10173. log.warn("迁移307: resource_items.connection_type 已存在或添加失败: {}", e.getMessage());
  10174. }
  10175. try {
  10176. jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN priority TINYINT DEFAULT 2 COMMENT '优先级: 1=高 2=中 3=低(手动覆盖)'");
  10177. } catch (Exception e) {
  10178. log.warn("迁移307: resource_items.priority 已存在或添加失败: {}", e.getMessage());
  10179. }
  10180. try {
  10181. jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN value_score INT DEFAULT 0 COMMENT '价值分 0-100(自动计算,可手动覆盖)'");
  10182. } catch (Exception e) {
  10183. log.warn("迁移307: resource_items.value_score 已存在或添加失败: {}", e.getMessage());
  10184. }
  10185. try {
  10186. jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN last_interaction_at DATETIME DEFAULT NULL COMMENT '最近互动时间'");
  10187. } catch (Exception e) {
  10188. log.warn("迁移307: resource_items.last_interaction_at 已存在或添加失败: {}", e.getMessage());
  10189. }
  10190. try {
  10191. jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN interaction_count INT DEFAULT 0 COMMENT '互动总次数'");
  10192. } catch (Exception e) {
  10193. log.warn("迁移307: resource_items.interaction_count 已存在或添加失败: {}", e.getMessage());
  10194. }
  10195. try {
  10196. jdbcTemplate.execute("ALTER TABLE resource_items ADD INDEX idx_ri_connection_type (connection_type)");
  10197. } catch (Exception e) {
  10198. log.warn("迁移307: resource_items.connection_type 索引已存在或添加失败: {}", e.getMessage());
  10199. }
  10200. log.info("迁移307: resource_items 社会连接字段添加完成");
  10201. // 迁移308: 创建珍珠图互动日志表
  10202. try {
  10203. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pearl_interaction_log (" +
  10204. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10205. "user_id BIGINT NOT NULL COMMENT '操作人', " +
  10206. "resource_item_id BIGINT NOT NULL COMMENT '关联资源', " +
  10207. "interaction_type VARCHAR(20) NOT NULL COMMENT 'PHONE/WECHAT/MEETING/OTHER', " +
  10208. "content VARCHAR(500) DEFAULT NULL COMMENT '互动内容简记', " +
  10209. "happened_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间', " +
  10210. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  10211. "INDEX idx_pil_user (user_id), " +
  10212. "INDEX idx_pil_resource (resource_item_id), " +
  10213. "INDEX idx_pil_happened (happened_at) " +
  10214. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动日志'");
  10215. log.info("迁移308: 已创建 pearl_interaction_log 表");
  10216. } catch (Exception e) {
  10217. log.warn("迁移308: 创建 pearl_interaction_log 表失败: {}", e.getMessage());
  10218. }
  10219. // 迁移309: 创建珍珠图互动提醒表
  10220. try {
  10221. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (" +
  10222. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10223. "user_id BIGINT NOT NULL, " +
  10224. "resource_item_id BIGINT NOT NULL, " +
  10225. "connection_type VARCHAR(50) NOT NULL, " +
  10226. "suggest_interval_month INT NOT NULL COMMENT '建议互动周期(月)', " +
  10227. "last_interaction_at DATETIME DEFAULT NULL, " +
  10228. "days_since_last INT NOT NULL COMMENT '距上次互动天数', " +
  10229. "status VARCHAR(20) DEFAULT 'PENDING' COMMENT 'PENDING/DONE/DISMISSED', " +
  10230. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
  10231. "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  10232. "INDEX idx_pir_user_status (user_id, status), " +
  10233. "INDEX idx_pir_resource (resource_item_id) " +
  10234. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒'");
  10235. log.info("迁移309: 已创建 pearl_interaction_reminder 表");
  10236. } catch (Exception e) {
  10237. log.warn("迁移309: 创建 pearl_interaction_reminder 表失败: {}", e.getMessage());
  10238. }
  10239. try {
  10240. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_record (" +
  10241. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10242. "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " +
  10243. "member_order_id BIGINT COMMENT '关联 MemberSubscriptionOrder.id(会员订单)', " +
  10244. "effect_type TINYINT UNSIGNED NOT NULL COMMENT '成效类型 bitmask:1=金额大,2=频次高,4=新成交(支持多选)', " +
  10245. "effect_amount DECIMAL(10,2) DEFAULT 0 COMMENT '成效金额(元)', " +
  10246. "effect_desc VARCHAR(500) COMMENT '成效描述', " +
  10247. "status TINYINT DEFAULT 1 COMMENT '1=有效,0=无效', " +
  10248. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10249. "INDEX idx_oer_user (user_id), " +
  10250. "INDEX idx_oer_order (member_order_id) " +
  10251. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人拓展-成效记录'");
  10252. log.info("已创建octopus_effect_record表");
  10253. } catch (Exception e) {
  10254. log.warn("创建octopus_effect_record表失败: {}", e.getMessage());
  10255. }
  10256. try {
  10257. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_key_person (" +
  10258. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10259. "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " +
  10260. "name VARCHAR(64) NOT NULL COMMENT '关键人姓名', " +
  10261. "organization VARCHAR(128) COMMENT '单位/公司', " +
  10262. "department VARCHAR(64) COMMENT '部门', " +
  10263. "title VARCHAR(64) COMMENT '职务/职位', " +
  10264. "first_meet_scene VARCHAR(128) COMMENT '初识场合', " +
  10265. "know_way VARCHAR(255) COMMENT '如何认识(认识路径)', " +
  10266. "contact_info VARCHAR(255) COMMENT '联系方式(可选)', " +
  10267. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10268. "INDEX idx_okp_user (user_id), " +
  10269. "INDEX idx_okp_org (organization) " +
  10270. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人画像'");
  10271. log.info("已创建octopus_key_person表");
  10272. } catch (Exception e) {
  10273. log.warn("创建octopus_key_person表失败: {}", e.getMessage());
  10274. }
  10275. try {
  10276. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_key_person (" +
  10277. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10278. "effect_id BIGINT NOT NULL COMMENT '关联 octopus_effect_record.id', " +
  10279. "key_person_id BIGINT NOT NULL COMMENT '关联 octopus_key_person.id', " +
  10280. "role TINYINT NOT NULL COMMENT '1=引荐,2=决策,3=其他', " +
  10281. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10282. "INDEX idx_oekp_effect (effect_id), " +
  10283. "INDEX idx_oekp_person (key_person_id), " +
  10284. "UNIQUE KEY uk_oekp (effect_id,key_person_id) " +
  10285. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成效-关键人关联'");
  10286. log.info("已创建octopus_effect_key_person表");
  10287. } catch (Exception e) {
  10288. log.warn("创建octopus_effect_key_person表失败: {}", e.getMessage());
  10289. }
  10290. // 迁移310: health_meal_record 表添加 record_date(补打日期)与 is_backfill(是否补打)字段(饮食记录重设计)
  10291. try {
  10292. jdbcTemplate.execute("ALTER TABLE health_meal_record ADD COLUMN record_date DATE NULL COMMENT '用餐日期(补打时为实际用餐日,默认等于创建日)'");
  10293. log.info("迁移310: health_meal_record.record_date 列已添加");
  10294. } catch (Exception e) {
  10295. log.warn("迁移310: health_meal_record.record_date 已存在或添加失败: {}", e.getMessage());
  10296. }
  10297. try {
  10298. jdbcTemplate.execute("ALTER TABLE health_meal_record ADD COLUMN is_backfill TINYINT DEFAULT 0 COMMENT '是否补打: 1=补打(无能量值), 0=当日打卡'");
  10299. log.info("迁移310: health_meal_record.is_backfill 列已添加");
  10300. } catch (Exception e) {
  10301. log.warn("迁移310: health_meal_record.is_backfill 已存在或添加失败: {}", e.getMessage());
  10302. }
  10303. try {
  10304. jdbcTemplate.execute("ALTER TABLE health_meal_record ADD INDEX idx_meal_record_date (member_id, record_date)");
  10305. log.info("迁移310: health_meal_record (member_id, record_date) 索引已添加");
  10306. } catch (Exception e) {
  10307. log.warn("迁移310: health_meal_record 索引已存在或添加失败: {}", e.getMessage());
  10308. }
  10309. // 迁移310: health_reports / health_report_drafts 补 file_hash 列
  10310. // 实体与 schema.sql 已含该列,但建表迁移从未 ADD;upload-only 按 MD5 去重时 SELECT 报 Unknown column 'file_hash'
  10311. ensureColumn("health_reports", "file_hash",
  10312. "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'");
  10313. ensureColumn("health_report_drafts", "file_hash",
  10314. "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'");
  10315. log.info("迁移310: 已补齐 health_reports/health_report_drafts.file_hash");
  10316. // 迁移311: report_type_registry 补 script_status 列
  10317. // 实体/schema 已含;异步采集指纹识别 SELECT 全字段时报 Unknown column 'script_status'
  10318. ensureColumn("report_type_registry", "script_status",
  10319. "VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed'");
  10320. log.info("迁移311: 已补齐 report_type_registry.script_status");
  10321. // 迁移312: 为 gut_flora 补默认指纹规则(空表会导致指纹引擎永远 unknown)
  10322. seedGutFloraFingerprints();
  10323. // 迁移316-329: 原误置于 seedGutFloraFingerprints 早返回(return)之后,gut_flora 指纹存在时永不执行;
  10324. // 现移至 runSystemMigrations 无条件执行(recommendation_rules / task_orchestration / onboarding_guide_config / 打卡表照片语音字段)
  10325. // 迁移316: 创建 recommendation_rules 表(统一推荐规则配置)
  10326. try {
  10327. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_rules (" +
  10328. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10329. "rule_type TINYINT NOT NULL COMMENT '1-5', " +
  10330. "rule_name VARCHAR(100) COMMENT '运营名称', " +
  10331. "trigger_product_id BIGINT COMMENT '触发商品ID', " +
  10332. "trigger_product_ids VARCHAR(1000) COMMENT '触发商品ID JSON 数组', " +
  10333. "target_product_id BIGINT COMMENT '目标商品ID', " +
  10334. "priority INT DEFAULT 10 COMMENT '优先级,越大越优', " +
  10335. "enabled TINYINT DEFAULT 1 COMMENT '启用 1/0', " +
  10336. "time_window_days INT COMMENT '回溯窗口(天)', " +
  10337. "delay_days INT COMMENT '复购延迟天数', " +
  10338. "scope VARCHAR(20) COMMENT 'family/individual', " +
  10339. "scene_codes VARCHAR(500) COMMENT '生效场景 JSON 数组', " +
  10340. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10341. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
  10342. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐规则配置'");
  10343. log.info("迁移316: 已创建recommendation_rules表");
  10344. } catch (Exception e) {
  10345. // 表已存在,忽略错误
  10346. }
  10347. // 迁移317: recommendation_rules 表索引
  10348. try {
  10349. jdbcTemplate.execute("CREATE INDEX idx_rules_enabled ON recommendation_rules(enabled)");
  10350. log.info("迁移317: 已创建idx_rules_enabled索引");
  10351. } catch (Exception e) {
  10352. // 索引已存在,忽略错误
  10353. }
  10354. try {
  10355. jdbcTemplate.execute("CREATE INDEX idx_rules_scene ON recommendation_rules(scene_codes)");
  10356. log.info("迁移317: 已创建idx_rules_scene索引");
  10357. } catch (Exception e) {
  10358. // 索引已存在,忽略错误
  10359. }
  10360. try {
  10361. jdbcTemplate.execute("CREATE INDEX idx_rules_priority ON recommendation_rules(priority)");
  10362. log.info("迁移317: 已创建idx_rules_priority索引");
  10363. } catch (Exception e) {
  10364. // 索引已存在,忽略错误
  10365. }
  10366. try {
  10367. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_flows` (" +
  10368. "`id` bigint NOT NULL AUTO_INCREMENT," +
  10369. "`name` varchar(100) NOT NULL," +
  10370. "`description` text," +
  10371. "`creator_id` bigint NOT NULL," +
  10372. "`family_id` bigint DEFAULT NULL," +
  10373. "`version` int NOT NULL DEFAULT 1," +
  10374. "`status` varchar(20) NOT NULL DEFAULT 'draft'," +
  10375. "`schedule_cron` varchar(64) DEFAULT NULL," +
  10376. "`config_json` json DEFAULT NULL," +
  10377. "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
  10378. "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  10379. "PRIMARY KEY (`id`)," +
  10380. "KEY `idx_family_id` (`family_id`)," +
  10381. "KEY `idx_status` (`status`)" +
  10382. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流定义'");
  10383. log.info("迁移329: 已创建task_orchestration_flows表");
  10384. } catch (Exception e) {
  10385. log.warn("迁移329: task_orchestration_flows 已存在或创建失败: {}", e.getMessage());
  10386. }
  10387. try {
  10388. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_edges` (" +
  10389. "`id` bigint NOT NULL AUTO_INCREMENT," +
  10390. "`flow_id` bigint NOT NULL," +
  10391. "`from_node_id` varchar(64) NOT NULL," +
  10392. "`to_node_id` varchar(64) NOT NULL," +
  10393. "`edge_type` varchar(20) NOT NULL," +
  10394. "`operator` varchar(10) NOT NULL DEFAULT 'AND'," +
  10395. "`sort_order` int NOT NULL DEFAULT 0," +
  10396. "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
  10397. "PRIMARY KEY (`id`)," +
  10398. "KEY `idx_flow_id` (`flow_id`)," +
  10399. "UNIQUE KEY `uk_flow_edge` (`flow_id`, `from_node_id`, `to_node_id`)" +
  10400. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流边定义'");
  10401. log.info("迁移329: 已创建task_orchestration_edges表");
  10402. } catch (Exception e) {
  10403. log.warn("迁移329: task_orchestration_edges 已存在或创建失败: {}", e.getMessage());
  10404. }
  10405. try {
  10406. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_executions` (" +
  10407. "`id` bigint NOT NULL AUTO_INCREMENT," +
  10408. "`flow_id` bigint NOT NULL," +
  10409. "`flow_version` int NOT NULL DEFAULT 1," +
  10410. "`family_id` bigint NOT NULL," +
  10411. "`family_member_id` bigint NOT NULL," +
  10412. "`trigger_source` varchar(20) NOT NULL DEFAULT 'manual'," +
  10413. "`status` varchar(20) NOT NULL DEFAULT 'running'," +
  10414. "`started_at` datetime NOT NULL," +
  10415. "`finished_at` datetime DEFAULT NULL," +
  10416. "`error_reason` varchar(255) DEFAULT NULL," +
  10417. "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
  10418. "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  10419. "PRIMARY KEY (`id`)," +
  10420. "KEY `idx_execution_flow` (`flow_id`)," +
  10421. "KEY `idx_execution_status` (`status`)" +
  10422. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流执行记录'");
  10423. log.info("迁移329: 已创建task_orchestration_executions表");
  10424. } catch (Exception e) {
  10425. log.warn("迁移329: task_orchestration_executions 已存在或创建失败: {}", e.getMessage());
  10426. }
  10427. try {
  10428. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_node_instances` (" +
  10429. "`id` bigint NOT NULL AUTO_INCREMENT," +
  10430. "`execution_id` bigint NOT NULL," +
  10431. "`node_id` varchar(64) NOT NULL," +
  10432. "`task_id` bigint DEFAULT NULL," +
  10433. "`status` varchar(20) NOT NULL DEFAULT 'pending'," +
  10434. "`generation` int NOT NULL DEFAULT 0," +
  10435. "`condition_met_at` datetime DEFAULT NULL COMMENT '条件终止:外部API通知条件达成时间'," +
  10436. "`loop_termination_reason` varchar(50) DEFAULT NULL," +
  10437. "`started_at` datetime DEFAULT NULL," +
  10438. "`completed_at` datetime DEFAULT NULL," +
  10439. "`error_reason` varchar(255) DEFAULT NULL," +
  10440. "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
  10441. "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  10442. "PRIMARY KEY (`id`)," +
  10443. "UNIQUE KEY `uk_node_instance` (`execution_id`, `node_id`, `generation`)," +
  10444. "KEY `idx_execution_id` (`execution_id`)," +
  10445. "KEY `idx_task_id` (`task_id`)" +
  10446. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流节点实例'");
  10447. log.info("迁移329: 已创建task_orchestration_node_instances表");
  10448. } catch (Exception e) {
  10449. log.warn("迁移329: task_orchestration_node_instances 已存在或创建失败: {}", e.getMessage());
  10450. }
  10451. // 迁移318: sys_config 预置首页引导浮层默认配置(onboarding_guide_config key)
  10452. try {
  10453. String defaultConfig = "{"
  10454. + "\"enabled\":true,"
  10455. + "\"version\":\"1.0\","
  10456. + "\"positions\":[\"home\",\"child\"],"
  10457. + "\"parent\":["
  10458. + "{\"icon\":\"🔍\",\"title\":\"你真的了解孩子的状态吗?\",\"desc\":\"情绪低落没察觉、习惯变化没注意、能力短板没发现——很多问题等爆发了才看见。浠艾福帮你在问题发生前,看清孩子真实的身心状态。\",\"isIntro\":true},"
  10459. + "{\"icon\":\"🌏\",\"title\":\"五维沙盘,看见全家\",\"desc\":\"身·心·智·行·富,一张全景图看清每个维度。孩子身体好不好、情绪怎么样、能力到哪了——不用猜,看得见.\"},"
  10460. + "{\"icon\":\"📋\",\"title\":\"小任务,大改变\",\"desc\":\"布置每日小任务,完成就能攒星星。每天一点点积累,好习惯自然养成.\"},"
  10461. + "{\"icon\":\"🎁\",\"title\":\"心愿墙,看得见努力\",\"desc\":\"孩子攒够星星就能兑换心愿。努力有回报,目标感和坚持力一起培养.\"}"
  10462. + "],"
  10463. + "\"child\":["
  10464. + "{\"icon\":\"🔍\",\"title\":\"你的每一点成长,都被看见\",\"desc\":\"今天完成了几个任务?比昨天进步了多少?浠艾福帮你记录每一点努力,让成长看得见。\",\"isIntro\":true},"
  10465. + "{\"icon\":\"🌟\",\"title\":\"赚星星\",\"desc\":\"完成爸爸妈妈布置的任务,就能获得星星积分,越努力越多!\"},"
  10466. + "{\"icon\":\"🎮\",\"title\":\"玩中学\",\"desc\":\"完成任务后可以玩小游戏、兑换心愿,学习和快乐两不误!\"},"
  10467. + "{\"icon\":\"🏆\",\"title\":\"升级挑战\",\"desc\":\"从星星宝宝到宇宙小英雄,一步步升级,看看你能走多远!\"}"
  10468. + "]"
  10469. + "}";
  10470. jdbcTemplate.update(
  10471. "INSERT IGNORE INTO sys_config (config_key, config_value, description) VALUES ('onboarding_guide_config', ?, '首页引导浮层配置')",
  10472. defaultConfig
  10473. );
  10474. log.info("迁移318: 已预置onboarding_guide_config默认配置");
  10475. } catch (Exception e) {
  10476. log.warn("预置onboarding_guide_config可能已存在: {}", e.getMessage());
  10477. }
  10478. // 迁移328: 饮食/运动打卡表补充照片与语音字段(前端已上传 media URL,补上持久化列)
  10479. try {
  10480. ensureColumn("health_meal_record", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'");
  10481. ensureColumn("health_meal_record", "voice_url", "VARCHAR(500) COMMENT '语音URL'");
  10482. ensureColumn("health_meal_record", "voice_text", "TEXT COMMENT '语音转写文字'");
  10483. ensureColumn("exercise_record", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'");
  10484. ensureColumn("exercise_record", "voice_url", "VARCHAR(500) COMMENT '语音URL'");
  10485. ensureColumn("exercise_record", "voice_text", "TEXT COMMENT '语音转写文字'");
  10486. log.info("迁移328: 已为health_meal_record/exercise_record补充照片语音字段");
  10487. } catch (Exception e) {
  10488. log.warn("迁移328: 补充照片语音字段失败: {}", e.getMessage());
  10489. }
  10490. // 迁移333-336: 引导弹窗配置表(guide_popup 系列,原远程版本误置于 seedGutFloraFingerprints 早返回之后,
  10491. // 现并入 runSystemMigrations 无条件执行;编号 333-336 为本地迁移329(task_orchestration)以后的续号)
  10492. // 迁移333: 创建 guide_popup 表(弹窗引导页配置化管理)
  10493. try {
  10494. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup (" +
  10495. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
  10496. "slug VARCHAR(64) NOT NULL UNIQUE COMMENT '唯一标识', " +
  10497. "title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '弹窗标题', " +
  10498. "description TEXT COMMENT '主描述文案', " +
  10499. "icon VARCHAR(256) NOT NULL DEFAULT '' COMMENT '图标:emoji或图片URL', " +
  10500. "icon_type TINYINT NOT NULL DEFAULT 0 COMMENT '图标类型:0=emoji 1=图片', " +
  10501. "theme_color VARCHAR(20) NOT NULL DEFAULT '#F97316' COMMENT '主题色', " +
  10502. "btn_text VARCHAR(64) NOT NULL DEFAULT '知道了' COMMENT '按钮文案', " +
  10503. "btn_action VARCHAR(256) NOT NULL DEFAULT '' COMMENT '点击跳转路径,空=关闭', " +
  10504. "status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用 1=启用', " +
  10505. "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序优先级', " +
  10506. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  10507. "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
  10508. "INDEX idx_status_sort (status, sort_order)" +
  10509. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗配置表'");
  10510. log.info("迁移333: 已创建 guide_popup 表");
  10511. } catch (Exception e) {
  10512. log.warn("guide_popup 表可能已存在: {}", e.getMessage());
  10513. }
  10514. // 迁移334: 创建 guide_popup_step 表(引导弹窗步骤)
  10515. try {
  10516. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_step (" +
  10517. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
  10518. "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " +
  10519. "step_order INT NOT NULL DEFAULT 0 COMMENT '步骤顺序', " +
  10520. "title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '步骤标题', " +
  10521. "`desc` TEXT COMMENT '步骤描述', " +
  10522. "icon VARCHAR(256) NOT NULL DEFAULT '' COMMENT '步骤图标', " +
  10523. "icon_type TINYINT NOT NULL DEFAULT 0 COMMENT '图标类型:0=emoji 1=图片', " +
  10524. "is_intro TINYINT NOT NULL DEFAULT 0 COMMENT '是否为开场身份定义页', " +
  10525. "btn_text VARCHAR(64) NOT NULL DEFAULT '下一步' COMMENT '本步骤按钮文案', " +
  10526. "btn_action VARCHAR(256) NOT NULL DEFAULT '' COMMENT '最后一步跳转链接', " +
  10527. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  10528. "INDEX idx_popup_order (popup_id, step_order)" +
  10529. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗步骤表'");
  10530. log.info("迁移334: 已创建 guide_popup_step 表");
  10531. } catch (Exception e) {
  10532. log.warn("guide_popup_step 表可能已存在: {}", e.getMessage());
  10533. }
  10534. // 迁移335: 创建 guide_popup_trigger 表(引导弹窗触发条件)
  10535. try {
  10536. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_trigger (" +
  10537. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
  10538. "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " +
  10539. "trigger_type VARCHAR(32) NOT NULL COMMENT '触发类型:page_enter/task_complete/assessment_done/first_login/daily_active/role_change', " +
  10540. "target_page VARCHAR(128) DEFAULT '' COMMENT '目标页面路径', " +
  10541. "target_action VARCHAR(128) DEFAULT '' COMMENT '触发行为标识', " +
  10542. "min_role VARCHAR(32) DEFAULT 'parent' COMMENT '最低角色要求', " +
  10543. "extra_config JSON DEFAULT NULL COMMENT '扩展条件', " +
  10544. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
  10545. "INDEX idx_popup_trigger (popup_id)" +
  10546. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗触发条件表'");
  10547. log.info("迁移335: 已创建 guide_popup_trigger 表");
  10548. } catch (Exception e) {
  10549. log.warn("guide_popup_trigger 表可能已存在: {}", e.getMessage());
  10550. }
  10551. // 迁移336: 创建 guide_popup_log 表(引导弹窗用户消费记录)
  10552. try {
  10553. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_log (" +
  10554. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
  10555. "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " +
  10556. "user_id BIGINT NOT NULL COMMENT '用户ID users.id', " +
  10557. "member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID family_members.id', " +
  10558. "role VARCHAR(32) NOT NULL COMMENT '弹出时所在角色', " +
  10559. "consumed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间', " +
  10560. "button_clicked TINYINT NOT NULL DEFAULT 0 COMMENT '是否点击按钮', " +
  10561. "INDEX idx_user_popup (user_id, popup_id), " +
  10562. "INDEX idx_consume_time (consumed_at)" +
  10563. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗用户消费记录表'");
  10564. log.info("迁移336: 已创建 guide_popup_log 表");
  10565. } catch (Exception e) {
  10566. log.warn("guide_popup_log 表可能已存在: {}", e.getMessage());
  10567. }
  10568. // 迁移323: 注册素质测评A2报告类型(dan家族)+ 默认指纹规则
  10569. // 统一上传后指纹识别为 dan_a2 → ReportCollectService 按 report_type_registry.family=dan 分流走 DAN 采集管道
  10570. try {
  10571. Integer danA2Count = jdbcTemplate.queryForObject(
  10572. "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, "dan_a2");
  10573. if (danA2Count == null || danA2Count == 0) {
  10574. jdbcTemplate.update(
  10575. "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " +
  10576. "min_confidence, is_active, source, review_status, version, created_at, updated_at) " +
  10577. "VALUES ('dan_a2', 'dan', '素质测评A2', '核心素养评估报告:大五人格/认知能力/社会关系等', " +
  10578. "NULL, 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())");
  10579. log.info("迁移323: 已注册报告类型 dan_a2(dan家族)");
  10580. } else {
  10581. log.info("迁移323: 报告类型 dan_a2 已存在,跳过");
  10582. }
  10583. } catch (Exception e) {
  10584. log.warn("迁移323: 注册 dan_a2 类型失败: {}", e.getMessage());
  10585. }
  10586. seedDanA2Fingerprints();
  10587. seedDiscountRulesMenu();
  10588. createUserShortcutConfigTable();
  10589. createUserAppUsageTable();
  10590. seedGuidePopupData();
  10591. addGiftAmountColumn();
  10592. createGiftMembershipProgressTable();
  10593. addFlowTaskColumnsToTasksTable();
  10594. addPolicyTermsColumn();
  10595. createHomeBannersTable();
  10596. ensureHealthReportDraftsMemberIdColumn();
  10597. ensureHealthPlansTotalDaysColumn();
  10598. seedTaskReminderTemplate();
  10599. backfillHealthPlanTaskPrerequisite();
  10600. backfillTemplateInstanceFamilyMemberId();
  10601. createReportTransferRequestsTable();
  10602. addProductMinDiscountLevel();
  10603. restoreBasicMembershipLevel();
  10604. backfillMembershipLevelOrder();
  10605. fixMembershipLevelOrder();
  10606. removeReportUploadQuota();
  10607. applyBasicMembership20261023Spec();
  10608. createProductDimensionMappingTable();
  10609. createProductRecommendationLogTable();
  10610. createRepurchaseReminderTables();
  10611. createRecommendationProductAssociationsTable();
  10612. addAbTestColumnsToRecommendationLog();
  10613. createTaskReminderConfigTable();
  10614. addMissingColumnsToRecommendationLog();
  10615. createDietFamilySelectedIngredientsTable();
  10616. createSsoTicketTable();
  10617. // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
  10618. try {
  10619. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_feedback (" +
  10620. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10621. "user_id BIGINT NOT NULL COMMENT '提交人 users.id', " +
  10622. "member_id BIGINT DEFAULT NULL COMMENT '关联家庭成员 family_members.id', " +
  10623. "report_id BIGINT DEFAULT NULL COMMENT '健康报告ID', " +
  10624. "draft_id BIGINT DEFAULT NULL COMMENT '草稿ID', " +
  10625. "feedback_type VARCHAR(20) DEFAULT 'question' COMMENT 'question=疑问 / suggestion=建议', " +
  10626. "content VARCHAR(500) NOT NULL COMMENT '反馈内容', " +
  10627. "ai_answer TEXT COMMENT 'AI 解答', " +
  10628. "ai_status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/answered/failed', " +
  10629. "conversation_id VARCHAR(64) DEFAULT NULL COMMENT 'AI 会话ID(付费多轮)', " +
  10630. "created_at DATETIME NOT NULL COMMENT '创建时间', " +
  10631. "INDEX idx_rf_user (user_id, created_at), " +
  10632. "INDEX idx_rf_report (report_id) " +
  10633. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告反馈与AI解答'");
  10634. log.info("迁移327: 已创建 report_feedback 表");
  10635. } catch (Exception e) {
  10636. // 表已存在,忽略错误
  10637. }
  10638. // 迁移313: 修复 meal_configs 历史双编码 participant_member_ids(共餐配置前端曾 JSON.stringify 二次编码,存成了 "\"[1,2]\"",
  10639. // 导致再次进入页面时无法识别选中成员)。仅处理以双引号包裹的值,不触碰正常 "[]..." 格式
  10640. try {
  10641. jdbcTemplate.execute("UPDATE meal_configs SET participant_member_ids = JSON_UNQUOTE(participant_member_ids) " +
  10642. "WHERE participant_member_ids LIKE '\"%\"' ");
  10643. log.info("迁移313: 已修复 meal_configs 双编码 participant_member_ids");
  10644. } catch (Exception e) {
  10645. log.warn("迁移313: 修复 meal_configs participant_member_ids 失败: {}", e.getMessage());
  10646. }
  10647. // 迁移314: meal_configs 增加 config_date 字段(当天临时共餐配置)+ 修改 UNIQUE KEY
  10648. // 支持「今天」模式:config_date 有值=当天临时配置(优先级最高),config_date=NULL+config_date_type=工作日/周末默认配置
  10649. try {
  10650. jdbcTemplate.execute("ALTER TABLE meal_configs ADD COLUMN config_date DATE DEFAULT NULL COMMENT '当天临时配置日期(NULL=默认工作日/周末配置)'");
  10651. log.info("迁移314: meal_configs 已添加 config_date 列");
  10652. } catch (Exception e) {
  10653. log.warn("迁移314: meal_configs 添加 config_date 列失败(可能已存在): {}", e.getMessage());
  10654. }
  10655. try {
  10656. jdbcTemplate.execute("ALTER TABLE meal_configs ADD COLUMN config_date_eff DATE GENERATED ALWAYS AS (COALESCE(config_date, '9999-12-31')) STORED COMMENT '生效日期(NULL映射为9999-12-31,用于唯一约束)'");
  10657. log.info("迁移314: meal_configs 已添加生成列 config_date_eff");
  10658. } catch (Exception e) {
  10659. log.warn("迁移314: meal_configs 添加 config_date_eff 生成列失败(可能已存在): {}", e.getMessage());
  10660. }
  10661. try {
  10662. jdbcTemplate.execute("ALTER TABLE meal_configs DROP INDEX uk_config");
  10663. log.info("迁移314: meal_configs 已删除旧 UNIQUE KEY uk_config");
  10664. } catch (Exception e) {
  10665. log.warn("迁移314: 删除旧 UNIQUE KEY 失败(可能不存在): {}", e.getMessage());
  10666. }
  10667. try {
  10668. jdbcTemplate.execute("ALTER TABLE meal_configs ADD UNIQUE KEY uk_config (family_id, config_date_eff, meal_type)");
  10669. log.info("迁移314: meal_configs 已添加新 UNIQUE KEY uk_config(支持当天临时配置)");
  10670. } catch (Exception e) {
  10671. log.warn("迁移314: 添加新 UNIQUE KEY 失败: {}", e.getMessage());
  10672. }
  10673. try {
  10674. jdbcTemplate.execute("ALTER TABLE meal_configs ADD INDEX idx_config_date (config_date)");
  10675. log.info("迁移314: meal_configs 已添加索引 idx_config_date");
  10676. } catch (Exception e) {
  10677. log.warn("迁移314: meal_configs 添加索引 idx_config_date 失败(可能已存在): {}", e.getMessage());
  10678. }
  10679. try {
  10680. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_stage (" +
  10681. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10682. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  10683. "member_id BIGINT NOT NULL COMMENT '目标归属成员ID(family_members.id)', " +
  10684. "title VARCHAR(200) COMMENT '阶段主题(可选)', " +
  10685. "start_date DATE NOT NULL COMMENT '阶段开始日期', " +
  10686. "end_date DATE NOT NULL COMMENT '阶段结束日期', " +
  10687. "duration_weeks TINYINT NOT NULL COMMENT '周期周数:2/4/8/12', " +
  10688. "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " +
  10689. "created_by BIGINT NOT NULL COMMENT '创建人userId(代管时为代管人)', " +
  10690. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10691. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  10692. "INDEX idx_ds_family_member (family_id, member_id), " +
  10693. "INDEX idx_ds_status (status) " +
  10694. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段'");
  10695. log.info("迁移318: 已创建 doa_stage 表");
  10696. } catch (Exception e) {
  10697. log.warn("迁移318: 创建 doa_stage 表失败: {}", e.getMessage());
  10698. }
  10699. try {
  10700. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_goal (" +
  10701. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10702. "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " +
  10703. "domain VARCHAR(20) NOT NULL COMMENT '领域:health/wealth/family/social/help', " +
  10704. "content VARCHAR(500) NOT NULL COMMENT '量化目标内容', " +
  10705. "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/achieved/abandoned', " +
  10706. "sort_order TINYINT DEFAULT 0 COMMENT '排序', " +
  10707. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10708. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  10709. "INDEX idx_dg_stage (stage_id) " +
  10710. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段目标'");
  10711. log.info("迁移318: 已创建 doa_goal 表");
  10712. } catch (Exception e) {
  10713. log.warn("迁移318: 创建 doa_goal 表失败: {}", e.getMessage());
  10714. }
  10715. try {
  10716. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_week (" +
  10717. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10718. "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " +
  10719. "week_no TINYINT NOT NULL COMMENT '第几周(1起)', " +
  10720. "week_start DATE NOT NULL COMMENT '本周开始(周一)', " +
  10721. "week_end DATE NOT NULL COMMENT '本周结束(周日)', " +
  10722. "plan TEXT COMMENT '周行动宣告计划(A表)', " +
  10723. "achieved TEXT COMMENT '复盘:本周达成了什么', " +
  10724. "experience TEXT COMMENT '复盘:有什么体验', " +
  10725. "worked TEXT COMMENT '复盘:行得通的', " +
  10726. "improve TEXT COMMENT '复盘:要提升/行不通的', " +
  10727. "next_challenge TEXT COMMENT '复盘:下周挑战自己什么', " +
  10728. "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/planned/reviewed', " +
  10729. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  10730. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  10731. "UNIQUE KEY uk_dw_stage_week (stage_id, week_no), " +
  10732. "INDEX idx_dw_stage (stage_id) " +
  10733. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录'");
  10734. log.info("迁移318: 已创建 doa_week 表");
  10735. } catch (Exception e) {
  10736. log.warn("迁移318: 创建 doa_week 表失败: {}", e.getMessage());
  10737. }
  10738. // 迁移318: 基础管理分组挂载「系统提示词」菜单(cfc-web菜单树同步)
  10739. migrateSystemPromptMenu();
  10740. // 迁移319: system_prompts 插入 health_plan 提示词种子(仅当不存在时)
  10741. try {
  10742. Integer count = jdbcTemplate.queryForObject(
  10743. "SELECT COUNT(*) FROM system_prompts WHERE prompt_key = 'health_plan'", Integer.class);
  10744. if (count == null || count == 0) {
  10745. jdbcTemplate.update(
  10746. "INSERT INTO system_prompts (prompt_key, title, content, default_content, version, status, remark) VALUES (?, ?, ?, ?, 1, 1, ?)",
  10747. "health_plan", "健康方案生成",
  10748. "你是一个专业的家庭健康方案规划师。根据用户的健康数据和目标,生成结构化的改善方案。\n\n" +
  10749. "## 输出格式(必须严格遵守 JSON)\n\n" +
  10750. "```\n" +
  10751. "{\n" +
  10752. " \"overview\": \"总体概述(100字以内,说明方案目标和核心策略)\",\n" +
  10753. " \"sections\": [\n" +
  10754. " {\n" +
  10755. " \"key\": \"nutrition\",\n" +
  10756. " \"title\": \"营养补充建议\",\n" +
  10757. " \"content\": \"Markdown 格式的详细内容,包含具体产品推荐、用量、服用时间\",\n" +
  10758. " \"items\": [{\"name\": \"产品名\", \"dosage\": \"用量\", \"timing\": \"服用时间\", \"reason\": \"推荐理由\"}]\n" +
  10759. " },\n" +
  10760. " {\n" +
  10761. " \"key\": \"diet\",\n" +
  10762. " \"title\": \"饮食建议\",\n" +
  10763. " \"content\": \"Markdown 格式的餐饮建议,包含早餐/午餐/晚餐建议\",\n" +
  10764. " \"items\": [{\"meal\": \"餐型\", \"food\": \"食物建议\", \"notes\": \"注意事项\"}]\n" +
  10765. " },\n" +
  10766. " {\n" +
  10767. " \"key\": \"exercise\",\n" +
  10768. " \"title\": \"运动计划\",\n" +
  10769. " \"content\": \"Markdown 格式的运动建议,包含频率、时长、类型\",\n" +
  10770. " \"items\": [{\"type\": \"运动类型\", \"duration\": \"时长\", \"frequency\": \"频率\", \"notes\": \"注意事项\"}]\n" +
  10771. " }\n" +
  10772. " ]\n" +
  10773. "}\n" +
  10774. "```\n\n" +
  10775. "## 原则\n" +
  10776. "1. 基于实际数据给出建议,不编造\n" +
  10777. "2. 引用知识库时标注来源\n" +
  10778. "3. 建议要具体可执行,避免空泛\n" +
  10779. "4. 营养补充部分要具体到品牌/产品类型和用量\n" +
  10780. "5. 严重健康问题建议咨询医生",
  10781. "你是一个专业的家庭健康方案规划师。根据用户的健康数据和目标,生成结构化的改善方案。",
  10782. "健康方案生成 prompt(营养+饮食+运动三段结构化输出)");
  10783. log.info("迁移319: 已插入 health_plan 提示词种子数据");
  10784. } else {
  10785. log.info("迁移319: health_plan 提示词已存在,跳过");
  10786. }
  10787. } catch (Exception e) {
  10788. log.warn("迁移319: 插入 health_plan 提示词种子数据失败: {}", e.getMessage());
  10789. }
  10790. try {
  10791. jdbcTemplate.execute("ALTER TABLE exercise_record ADD COLUMN step_count INT COMMENT '微信运动步数'");
  10792. log.info("迁移320: exercise_record.step_count 列已添加");
  10793. } catch (Exception e) {
  10794. log.warn("迁移320: exercise_record.step_count 列已存在或添加失败: {}", e.getMessage());
  10795. }
  10796. // 迁移321: 商品/分类可见性由「角色」改为「按会员级别」控制
  10797. // - membership_levels.level_order: 级别数字(数字越大级别越高): FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员)
  10798. // - products.min_visible_level: 最低可见会员级别编码(NULL=所有用户含未登录可见)
  10799. // - shop_categories.min_visible_level: 分类最低可见会员级别编码(NULL=所有用户含未登录可见)
  10800. ensureColumn("membership_levels", "level_order",
  10801. "INT DEFAULT 1 COMMENT '级别数字(越大级别越高): FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员)'");
  10802. try {
  10803. jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
  10804. "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 " +
  10805. "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 WHEN 'PROVIDER' THEN 1 ELSE level_order END");
  10806. log.info("迁移321: 已回填 membership_levels.level_order");
  10807. } catch (Exception e) {
  10808. log.warn("迁移321: 回填 membership_levels.level_order 失败: {}", e.getMessage());
  10809. }
  10810. ensureColumn("products", "min_visible_level",
  10811. "VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME, NULL=全部可见)'");
  10812. ensureColumn("shop_categories", "min_visible_level",
  10813. "VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME, NULL=全部可见)'");
  10814. log.info("迁移321: 已完成商品/分类按会员级别可见性改造");
  10815. // 迁移299: 创建 article_quiz_records 表(文章阅读答题记录)
  10816. try {
  10817. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_quiz_records (" +
  10818. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  10819. "article_id BIGINT DEFAULT NULL, " +
  10820. "user_id BIGINT DEFAULT NULL, " +
  10821. "member_id BIGINT DEFAULT NULL, " +
  10822. "questions TEXT, " +
  10823. "answers TEXT, " +
  10824. "score INT DEFAULT 0, " +
  10825. "total_questions INT DEFAULT 0, " +
  10826. "cf_earned INT DEFAULT 0, " +
  10827. "energy_earned INT DEFAULT 0, " +
  10828. "created_at DATETIME, " +
  10829. "updated_at DATETIME, " +
  10830. "INDEX idx_member (member_id), " +
  10831. "INDEX idx_article (article_id)" +
  10832. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表'");
  10833. log.info("已创建article_quiz_records表");
  10834. } catch (Exception e) {
  10835. log.warn("创建article_quiz_records表可能已存在: {}", e.getMessage());
  10836. }
  10837. // 迁移300: profile_snapshot 增加 quiz_summary 列(答题认知雷达汇总)
  10838. ensureColumn("profile_snapshot", "quiz_summary", "JSON DEFAULT NULL COMMENT '答题认知雷达汇总'");
  10839. // 迁移323: dan_assessment_results 添加 language_score 列(游戏化认知测评:语言表达维度)
  10840. ensureColumn("dan_assessment_results", "language_score", "INT COMMENT '语言表达得分'");
  10841. log.info("迁移323: 已确保 language_score 列存在于 dan_assessment_results");
  10842. // 迁移324-325: octopus_effect_record 添加 happened_at 列 + article_reading_records 回填与补列
  10843. try {
  10844. jdbcTemplate.execute("ALTER TABLE octopus_effect_record ADD COLUMN happened_at DATETIME COMMENT '成效发生时间'");
  10845. log.info("迁移324: octopus_effect_record.happened_at 列已添加");
  10846. } catch (Exception e) {
  10847. log.warn("迁移324: octopus_effect_record.happened_at 列已存在或添加失败: {}", e.getMessage());
  10848. }
  10849. try {
  10850. int updated = jdbcTemplate.update(
  10851. "UPDATE article_reading_records r " +
  10852. "LEFT JOIN tasks t ON r.task_id = t.id AND t.source_type = 'article' " +
  10853. "SET r.article_id = t.source_id " +
  10854. "WHERE r.article_id IS NULL AND t.id IS NOT NULL AND t.source_id IS NOT NULL"
  10855. );
  10856. log.info("迁移325: 已回填 article_reading_records.article_id {} 条记录", updated);
  10857. } catch (Exception e) {
  10858. log.warn("迁移325: 回填 article_reading_records.article_id 失败: {}", e.getMessage());
  10859. }
  10860. ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'");
  10861. // 迁移342: octopus_key_person 添加 first_meet_time / referrer_id / system_user_id 列(感恩记录新增字段)
  10862. ensureColumn("octopus_key_person", "first_meet_time", "DATETIME COMMENT '相识具体时间'");
  10863. log.info("迁移342: octopus_key_person.first_meet_time 列已添加");
  10864. ensureColumn("octopus_key_person", "referrer_id", "BIGINT DEFAULT NULL COMMENT '介绍人关键人ID(指向同用户另一条关键人)'");
  10865. log.info("迁移342: octopus_key_person.referrer_id 列已添加");
  10866. ensureColumn("octopus_key_person", "system_user_id", "BIGINT DEFAULT NULL COMMENT '匹配到的系统用户ID(如有)'");
  10867. log.info("迁移342: octopus_key_person.system_user_id 列已添加");
  10868. try {
  10869. Integer reportCount = jdbcTemplate.queryForObject(
  10870. "SELECT COUNT(*) FROM admin_task_templates WHERE title = 'REPORT_UPLOAD' AND onboarding_sequence IS NOT NULL", Integer.class);
  10871. if (reportCount == null || reportCount == 0) {
  10872. jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, onboarding_sequence, created_at, updated_at) VALUES " +
  10873. "('REPORT_UPLOAD', '完成首次报告上传', 300, '成长', 0, 1, 'onetime', 'none', 8, NOW(), NOW())");
  10874. log.info("迁移343: 已插入完成首次报告上传新人引导任务模板");
  10875. }
  10876. } catch (Exception e) {
  10877. log.warn("迁移343: 插入完成首次报告上传任务模板失败或已存在: {}", e.getMessage());
  10878. }
  10879. }
  10880. /**
  10881. * 迁移318: 系统配置·基础管理 挂载 系统提示词 菜单(幂等)
  10882. * 仅当 sys_menu 表已有数据(即角色授权已存在)时补授权,避免破坏兼容逻辑
  10883. */
  10884. private void migrateSystemPromptMenu() {
  10885. try {
  10886. Long baseManageId = queryMenuId("SELECT id FROM sys_menu WHERE title='基础管理'");
  10887. if (baseManageId == null) {
  10888. log.warn("迁移318: 未找到基础管理分组,跳过");
  10889. return;
  10890. }
  10891. Long promptId = insertSystemPromptMenu(baseManageId);
  10892. if (promptId != null) {
  10893. Integer grantCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_role_menu", Integer.class);
  10894. if (grantCount != null && grantCount > 0) {
  10895. grantMenuToRole("admin", promptId);
  10896. log.info("迁移318: 已授权 admin 角色");
  10897. }
  10898. log.info("迁移318: 系统提示词菜单挂载完成 id={}", promptId);
  10899. }
  10900. } catch (Exception e) {
  10901. log.warn("迁移318失败: {}", e.getMessage());
  10902. }
  10903. }
  10904. /**
  10905. * 按 path 幂等插入系统提示词叶子菜单,返回菜单 id(已存在返回原 id)
  10906. */
  10907. private Long insertSystemPromptMenu(Long parentId) {
  10908. Long existed = queryMenuId("SELECT id FROM sys_menu WHERE path='/system-prompt'");
  10909. if (existed != null) {
  10910. return existed;
  10911. }
  10912. try {
  10913. jdbcTemplate.update(
  10914. "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) VALUES (?, NULL, '系统提示词', '/system-prompt', 'el-icon-chat-line-square', 'system:base', 8, 1)",
  10915. parentId);
  10916. Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
  10917. log.info("迁移318: 已挂载系统提示词菜单");
  10918. return id;
  10919. } catch (Exception e) {
  10920. log.warn("迁移318: 挂载菜单失败: {}", e.getMessage());
  10921. return null;
  10922. }
  10923. }
  10924. private void seedGutFloraFingerprints() {
  10925. try {
  10926. Integer count = jdbcTemplate.queryForObject(
  10927. "SELECT COUNT(*) FROM report_fingerprint WHERE type_id = ?", Integer.class, "gut_flora");
  10928. if (count != null && count > 0) {
  10929. log.info("迁移312: gut_flora 指纹规则已存在,跳过");
  10930. return;
  10931. }
  10932. String[][] rows = {
  10933. {"肠道菌群检测", "20", "0", "1"},
  10934. {"核心菌属", "15", "0", "2"},
  10935. {"高通量测序", "15", "0", "3"},
  10936. {"菌群健康", "10", "0", "4"},
  10937. {"益生菌", "8", "0", "5"},
  10938. {"Shannon", "8", "0", "6"},
  10939. {"肠道菌群", "10", "0", "7"},
  10940. };
  10941. for (String[] row : rows) {
  10942. jdbcTemplate.update(
  10943. "INSERT INTO report_fingerprint (type_id, pattern, weight, is_exclusion, sort_order, created_at) "
  10944. + "VALUES ('gut_flora', ?, ?, ?, ?, NOW())",
  10945. row[0], Integer.valueOf(row[1]), Integer.valueOf(row[2]), Integer.valueOf(row[3]));
  10946. }
  10947. log.info("迁移312: 已写入 gut_flora 默认指纹规则 {} 条", rows.length);
  10948. } catch (Exception e) {
  10949. log.warn("迁移312: 写入 gut_flora 指纹规则失败: {}", e.getMessage());
  10950. }
  10951. }
  10952. /**
  10953. * 迁移323: 为 dan_a2(素质测评A2)补默认指纹规则。
  10954. * 空表会导致指纹引擎永远 unknown;A2 报告特征词取自 DanReportParseService.parseA2Report 的章节识别词。
  10955. * dan_a2 注册表 min_confidence=30,两高权重词同时命中即判定成功,避免单词误判。
  10956. */
  10957. private void seedDanA2Fingerprints() {
  10958. try {
  10959. Integer count = jdbcTemplate.queryForObject(
  10960. "SELECT COUNT(*) FROM report_fingerprint WHERE type_id = ?", Integer.class, "dan_a2");
  10961. if (count != null && count > 0) {
  10962. log.info("迁移323: dan_a2 指纹规则已存在,跳过");
  10963. return;
  10964. }
  10965. String[][] rows = {
  10966. {"大五人格", "20", "0", "1"},
  10967. {"人格特质", "15", "0", "2"},
  10968. {"核心素养", "15", "0", "3"},
  10969. {"认知能力", "10", "0", "4"},
  10970. {"感知觉", "10", "0", "5"},
  10971. {"评估报告", "8", "0", "6"},
  10972. {"综合评估", "8", "0", "7"},
  10973. };
  10974. for (String[] row : rows) {
  10975. jdbcTemplate.update(
  10976. "INSERT INTO report_fingerprint (type_id, pattern, weight, is_exclusion, sort_order, created_at) "
  10977. + "VALUES ('dan_a2', ?, ?, ?, ?, NOW())",
  10978. row[0], Integer.valueOf(row[1]), Integer.valueOf(row[2]), Integer.valueOf(row[3]));
  10979. }
  10980. log.info("迁移323: 已写入 dan_a2 默认指纹规则 {} 条", rows.length);
  10981. } catch (Exception e) {
  10982. log.warn("迁移323: 写入 dan_a2 指纹规则失败: {}", e.getMessage());
  10983. }
  10984. }
  10985. // 迁移326: 商城营销菜单新增「限时折扣管理」(幂等)
  10986. private void seedDiscountRulesMenu() {
  10987. try {
  10988. Integer count = jdbcTemplate.queryForObject(
  10989. "SELECT COUNT(*) FROM sys_menu WHERE path = '/discount-rules'", Integer.class);
  10990. if (count != null && count > 0) {
  10991. log.info("迁移326: 限时折扣管理菜单已存在,跳过");
  10992. return;
  10993. }
  10994. jdbcTemplate.update(
  10995. "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible, created_at) "
  10996. + "VALUES (15, NULL, '限时折扣管理', '/discount-rules', 'el-icon-timer', 'commerce:discount', 7, 1, NOW())");
  10997. log.info("迁移326: 已添加限时折扣管理菜单");
  10998. } catch (Exception e) {
  10999. log.warn("迁移326: 添加限时折扣管理菜单失败: {}", e.getMessage());
  11000. }
  11001. }
  11002. // 迁移327: 创建 user_shortcut_config 表(首页快捷方式自定义配置,每应用一条记录,locked=置顶)
  11003. private void createUserShortcutConfigTable() {
  11004. try {
  11005. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_shortcut_config (" +
  11006. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11007. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  11008. "app_key VARCHAR(64) NOT NULL COMMENT '应用key', " +
  11009. "locked TINYINT DEFAULT 0 COMMENT '是否锁定置顶(1=锁定,0=普通)', " +
  11010. "sort_order INT DEFAULT 0 COMMENT '手动排序序号', " +
  11011. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  11012. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  11013. "UNIQUE KEY uk_user_app (user_id, app_key)" +
  11014. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页快捷方式自定义配置'");
  11015. log.info("迁移327: 已创建 user_shortcut_config 表");
  11016. } catch (Exception e) {
  11017. log.warn("迁移327: 创建 user_shortcut_config 表失败: {}", e.getMessage());
  11018. }
  11019. }
  11020. // 迁移327: 创建 user_app_usage 表(应用使用频度记录)
  11021. private void createUserAppUsageTable() {
  11022. try {
  11023. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_app_usage (" +
  11024. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11025. "user_id BIGINT NOT NULL COMMENT '用户ID', " +
  11026. "app_key VARCHAR(64) NOT NULL COMMENT '应用key', " +
  11027. "enter_count INT DEFAULT 0 COMMENT '累计进入次数', " +
  11028. "last_enter_at DATETIME COMMENT '最近进入时间', " +
  11029. "UNIQUE KEY uk_user_app (user_id, app_key)" +
  11030. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用使用频度记录'");
  11031. log.info("迁移327: 已创建 user_app_usage 表");
  11032. } catch (Exception e) {
  11033. log.warn("迁移327: 创建 user_app_usage 表失败: {}", e.getMessage());
  11034. }
  11035. }
  11036. // 迁移333: 预置引导弹窗种子数据(parent_first_login,首次登录家长端)
  11037. private void seedGuidePopupData() {
  11038. try {
  11039. Long count = jdbcTemplate.queryForObject(
  11040. "SELECT COUNT(*) FROM guide_popup WHERE slug = 'parent_first_login'", Long.class);
  11041. if (count != null && count > 0L) {
  11042. log.info("迁移333: parent_first_login 种子弹窗已存在,跳过");
  11043. return;
  11044. }
  11045. jdbcTemplate.update(
  11046. "INSERT INTO guide_popup (slug, title, description, icon, icon_type, theme_color, btn_text, btn_action, status, sort_order, created_at, updated_at) "
  11047. + "VALUES ('parent_first_login', '欢迎使用浠艾福', '身·智·富·行·心,全家健康一站式管理', '🌏', 0, '#F97316', '开始体验', '', 1, 0, NOW(), NOW())");
  11048. Long popupId = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
  11049. // 步骤
  11050. jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 0, '你真的了解家人的状态吗?', '情绪低落没察觉、习惯变化没注意、能力短板没发现——什么问题等爆发了才看见。浠艾福帮你在问题发生前,看清每位家人的真实状态。', '🔍', 0, 1, '下一步', '')", popupId);
  11051. jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 1, '五维能量,看见全家', '身·心·智·行·富,一张全景图看清每位家人。谁的身体需要关注、谁的情绪需要疏导、谁的状态正在下滑——不用猜,看得见。', '🌏', 0, 0, '下一步', '')", popupId);
  11052. jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 2, '小行动,大改变', '全家一起完成每日小任务,各自攒星星。每天一点点积累,好习惯自然养成。', '📋', 0, 0, '下一步', '')", popupId);
  11053. jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 3, '心愿墙,看得见努力', '每位家人用自己的星星兑换心愿。努力有回报,全家人都能看到彼此的坚持。', '🎁', 0, 0, '开始体验', '')", popupId);
  11054. // 触发条件:首次登录,parent 角色
  11055. jdbcTemplate.update("INSERT INTO guide_popup_trigger (popup_id, trigger_type, target_page, target_action, min_role, extra_config, created_at) VALUES (?, 'first_login', '', '', 'parent', NULL, NOW())", popupId);
  11056. log.info("迁移333: 已预置 parent_first_login 种子弹窗 (popup_id={})", popupId);
  11057. } catch (Exception e) {
  11058. log.warn("预置引导弹窗种子数据失败: {}", e.getMessage());
  11059. }
  11060. }
  11061. // 迁移337: product_orders 表添加 gift_amount 列(送会员商品原价金额,分)
  11062. private void addGiftAmountColumn() {
  11063. ensureColumn("product_orders", "gift_amount", "INT DEFAULT 0 COMMENT '送会员商品原价金额(分)'");
  11064. }
  11065. // 迁移338: 创建 gift_membership_progress 表(家庭维度送会员累计进度)
  11066. private void createGiftMembershipProgressTable() {
  11067. try {
  11068. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS gift_membership_progress (" +
  11069. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11070. "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
  11071. "accumulated_amount INT DEFAULT 0 COMMENT '累计送会员商品原价金额(分)', " +
  11072. "granted INT DEFAULT 0 COMMENT '是否已赠送 1=已赠送 0=未赠送', " +
  11073. "granted_level VARCHAR(50) COMMENT '已赠送会员等级', " +
  11074. "granted_order_no VARCHAR(64) COMMENT '触发赠送的订单号', " +
  11075. "granted_at DATETIME COMMENT '赠送时间', " +
  11076. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  11077. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  11078. "UNIQUE KEY uk_family (family_id)" +
  11079. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='送会员累计进度'");
  11080. log.info("迁移338: 已创建 gift_membership_progress 表");
  11081. } catch (Exception e) {
  11082. log.warn("迁移338: 创建 gift_membership_progress 表失败: {}", e.getMessage());
  11083. }
  11084. }
  11085. // 迁移339: tasks 表加 is_flow_task / successor_task_ids 列(TASK-SYS-248 流程任务前置/后置逻辑)
  11086. private void addFlowTaskColumnsToTasksTable() {
  11087. ensureColumn("tasks", "is_flow_task", "TINYINT DEFAULT 0 COMMENT '是否为流程任务:1=是(有前置/后置任务),0=否'");
  11088. ensureColumn("tasks", "successor_task_ids", "TEXT NULL COMMENT '后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑)'");
  11089. }
  11090. // 迁移339: insurance_policies 表加 policy_terms 列(保单原始条款,AI 解析后原样保存)
  11091. private void addPolicyTermsColumn() {
  11092. ensureColumn("insurance_policies", "policy_terms", "TEXT COMMENT '保单原始条款(AI 解析后原样保存)'");
  11093. }
  11094. // 迁移340: 创建 home_banners 表(首页广告栏配置)
  11095. private void createHomeBannersTable() {
  11096. try {
  11097. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS home_banners (" +
  11098. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11099. "title VARCHAR(100) NOT NULL COMMENT '展示标题'," +
  11100. "banner_type VARCHAR(20) NOT NULL COMMENT '类型: activity/product/link'," +
  11101. "ref_id BIGINT DEFAULT NULL COMMENT '关联活动/商品ID(banner_type=activity/product时有效)'," +
  11102. "cover_image VARCHAR(500) DEFAULT NULL COMMENT '封面图URL(可覆盖默认封面)'," +
  11103. "link_url VARCHAR(500) DEFAULT NULL COMMENT '跳转链接(banner_type=link时有效)'," +
  11104. "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,值越小越靠前'," +
  11105. "enabled TINYINT NOT NULL DEFAULT 1 COMMENT '启用状态:1=启用 0=禁用'," +
  11106. "start_time DATETIME DEFAULT NULL COMMENT '投放开始时间(NULL=不限)'," +
  11107. "end_time DATETIME DEFAULT NULL COMMENT '投放结束时间(NULL=不限)'," +
  11108. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11109. "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'" +
  11110. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页广告栏配置表'");
  11111. log.info("迁移340: 已创建 home_banners 表");
  11112. } catch (Exception e) {
  11113. log.warn("迁移340: 创建 home_banners 表失败: {}", e.getMessage());
  11114. }
  11115. }
  11116. /** 迁移343: health_report_drafts 补 member_id 列(commit 21f8f6740 遗漏,schema.sql 已含但无迁移脚本) */
  11117. /** 迁移345: health_plans 补 total_days 列(方案周期天数,驱动服务任务周期边界与截止日期) */
  11118. private void ensureHealthPlansTotalDaysColumn() {
  11119. ensureColumn("health_plans", "total_days",
  11120. "INT DEFAULT NULL COMMENT '方案周期天数,驱动服务任务周期边界与截止日期'");
  11121. log.info("迁移345: health_plans.total_days 已确保存在");
  11122. }
  11123. /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */
  11124. /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */
  11125. private void seedTaskReminderTemplate() {
  11126. try {
  11127. Integer cnt = jdbcTemplate.queryForObject(
  11128. "SELECT COUNT(*) FROM notification_templates WHERE type = ?",
  11129. Integer.class, "task_reminder");
  11130. if (cnt != null && cnt == 0) {
  11131. jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
  11132. "task_reminder",
  11133. "任务待完成提醒",
  11134. "任务「{title}」{status},完成后记得打卡,可获得积分与 CF 值奖励。",
  11135. "in_app,sms");
  11136. log.info("迁移346: task_reminder 通知模板已插入");
  11137. }
  11138. } catch (Exception e) {
  11139. log.warn("迁移346: 插入 task_reminder 通知模板失败: " + e.getMessage());
  11140. }
  11141. try {
  11142. Integer c2 = jdbcTemplate.queryForObject(
  11143. "SELECT COUNT(*) FROM notification_templates WHERE type = ?",
  11144. Integer.class, "task_completion_praise");
  11145. if (c2 != null && c2 == 0) {
  11146. jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
  11147. "task_completion_praise",
  11148. "任务完成,太棒了!",
  11149. "太棒了!任务「{title}」已完成,继续保持,加油~",
  11150. "in_app,wechat");
  11151. log.info("迁移346+: task_completion_praise 通知模板已插入");
  11152. }
  11153. } catch (Exception e) {
  11154. log.warn("迁移346+: 插入 task_completion_praise 通知模板失败: " + e.getMessage());
  11155. }
  11156. }
  11157. /**
  11158. * 迁移347: 回填健康方案任务的依赖链。
  11159. * 历史上使用任务只写了 parent_task_id,从未写 prerequisite_task_id,导致"采购完成才激活服务任务"的闸门失效。
  11160. * 这里补写依赖并把前置未完成的使用任务置为 locked。
  11161. * 幂等:每次启动只处理 prerequisite IS NULL / status=pending 的行,可重复执行。
  11162. */
  11163. /**
  11164. * 迁移348: 回填周期实例的 family_member_id。
  11165. * RepeatTaskGenerator 过去未透传该字段,导致周期实例在"今日任务"列表查不到、
  11166. * 且 completeTask 抛 NPE。存量实例按其模板回填,模板缺失时按 child_id 兜底。
  11167. */
  11168. private void backfillTemplateInstanceFamilyMemberId() {
  11169. try {
  11170. int fromTpl = jdbcTemplate.update(
  11171. "UPDATE tasks i JOIN tasks tpl ON tpl.id = i.template_id " +
  11172. "SET i.family_member_id = tpl.family_member_id " +
  11173. "WHERE i.family_member_id IS NULL AND tpl.family_member_id IS NOT NULL");
  11174. int fromChild = jdbcTemplate.update(
  11175. "UPDATE tasks SET family_member_id = child_id " +
  11176. "WHERE family_member_id IS NULL AND child_id IS NOT NULL AND is_template = 0");
  11177. if (fromTpl > 0 || fromChild > 0) {
  11178. log.info("迁移348: 周期实例 family_member_id 回填 模板来源={}条 child_id兜底={}条",
  11179. fromTpl, fromChild);
  11180. }
  11181. } catch (Exception e) {
  11182. log.warn("迁移348: 周期实例 family_member_id 回填失败: " + e.getMessage());
  11183. }
  11184. }
  11185. private void backfillHealthPlanTaskPrerequisite() {
  11186. try {
  11187. int linked = jdbcTemplate.update(
  11188. "UPDATE tasks SET prerequisite_task_id = parent_task_id " +
  11189. "WHERE source_type = 'health_plan' AND category = '使用任务' " +
  11190. "AND parent_task_id IS NOT NULL AND prerequisite_task_id IS NULL");
  11191. // 注意:单表 UPDATE 的 WHERE 子查询不能引用目标表本身(MySQL 1093),必须用 LEFT JOIN 自连接
  11192. int locked = jdbcTemplate.update(
  11193. "UPDATE tasks t LEFT JOIN tasks p " +
  11194. "ON p.id = t.prerequisite_task_id AND p.status = 'completed' " +
  11195. "SET t.status = 'locked' " +
  11196. "WHERE t.source_type = 'health_plan' AND t.category = '使用任务' " +
  11197. "AND t.prerequisite_task_id IS NOT NULL AND t.status = 'pending' AND p.id IS NULL");
  11198. if (linked > 0 || locked > 0) {
  11199. log.info("迁移347: 健康方案任务依赖回填 prerequisite={}条, 置locked={}条", linked, locked);
  11200. }
  11201. } catch (Exception e) {
  11202. log.warn("迁移347: 健康方案任务依赖回填失败: " + e.getMessage());
  11203. }
  11204. }
  11205. private void ensureHealthReportDraftsMemberIdColumn() {
  11206. ensureColumn("health_report_drafts", "member_id",
  11207. "BIGINT DEFAULT NULL COMMENT '报告归属成员ID(family_members.id),A2/DAN自动采集必需'");
  11208. log.info("迁移343: health_report_drafts.member_id 已确保存在");
  11209. }
  11210. // 迁移344: 创建 supply_customer_relation 表(供应商团队客户归属记录)
  11211. private void createSupplyCustomerRelationTable() {
  11212. try {
  11213. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_customer_relation (" +
  11214. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11215. "system_id BIGINT NOT NULL COMMENT '供应商体系ID(supply_system.id)'," +
  11216. "parent_user_id BIGINT NOT NULL COMMENT '上级供应商用户ID(supply_hierarchy.parent_id)'," +
  11217. "family_id BIGINT NOT NULL COMMENT '客户家庭ID(family_members.family_id)'," +
  11218. "child_user_id BIGINT DEFAULT NULL COMMENT '下单用户ID(users.id),可为空'," +
  11219. "relation_type VARCHAR(32) NOT NULL DEFAULT 'family' COMMENT '归属类型: family=家庭归属 order=下单自动 referral=推荐码'," +
  11220. "source VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源: manual=人工绑定 order=下单自动 referral=推荐码'," +
  11221. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11222. "INDEX idx_system_family (system_id, family_id)," +
  11223. "INDEX idx_parent_user (parent_user_id)," +
  11224. "INDEX idx_family_id (family_id)," +
  11225. "UNIQUE KEY uk_system_family (system_id, family_id)" +
  11226. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商团队客户归属关系表'");
  11227. log.info("迁移344: 已创建 supply_customer_relation 表");
  11228. } catch (Exception e) {
  11229. log.warn("迁移344: 创建 supply_customer_relation 表失败: {}", e.getMessage());
  11230. }
  11231. }
  11232. // 迁移345: 创建 product_dimension_mapping 表(商品-维度关联)
  11233. private void createProductDimensionMappingTable() {
  11234. try {
  11235. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_mapping (" +
  11236. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11237. "product_id BIGINT NOT NULL COMMENT '商品ID(products.id)'," +
  11238. "dimension_code VARCHAR(32) NOT NULL COMMENT '维度编码: body/wisdom/mind/action/wealth'," +
  11239. "match_score INT DEFAULT 100 COMMENT '匹配度 0-100'," +
  11240. "match_reason VARCHAR(200) COMMENT '匹配原因说明'," +
  11241. "tags VARCHAR(500) COMMENT '推荐标签JSON'," +
  11242. "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," +
  11243. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11244. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  11245. "INDEX idx_product_id (product_id)," +
  11246. "INDEX idx_dimension (dimension_code)" +
  11247. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表'");
  11248. log.info("迁移345: 已创建 product_dimension_mapping 表");
  11249. } catch (Exception e) {
  11250. log.warn("迁移345: 创建 product_dimension_mapping 表失败: {}", e.getMessage());
  11251. }
  11252. }
  11253. // 迁移346: 创建 product_recommendation_log 表(推荐曝光日志)
  11254. private void createProductRecommendationLogTable() {
  11255. try {
  11256. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_recommendation_log (" +
  11257. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11258. "user_id BIGINT DEFAULT NULL COMMENT '用户ID(users.id)'," +
  11259. "family_id BIGINT DEFAULT NULL COMMENT '家庭ID(family_members.family_id)'," +
  11260. "product_id BIGINT NOT NULL COMMENT '商品ID'," +
  11261. "scene VARCHAR(32) NOT NULL COMMENT '场景: dimension_page/ai_chat/report_upload/repurchase'," +
  11262. "reason VARCHAR(200) COMMENT '推荐理由'," +
  11263. "match_score INT DEFAULT NULL COMMENT '匹配分'," +
  11264. "was_clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'," +
  11265. "was_purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'," +
  11266. "clicked_at DATETIME DEFAULT NULL COMMENT '点击时间'," +
  11267. "purchased_at DATETIME DEFAULT NULL COMMENT '购买时间'," +
  11268. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11269. "INDEX idx_user_scene (user_id, scene)," +
  11270. "INDEX idx_product_purchased (product_id, was_purchased)" +
  11271. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐曝光日志表'");
  11272. log.info("迁移346: 已创建 product_recommendation_log 表");
  11273. } catch (Exception e) {
  11274. log.warn("迁移346: 创建 product_recommendation_log 表失败: {}", e.getMessage());
  11275. }
  11276. }
  11277. // 迁移347: 创建 repurchase_reminder_record 和 repurchase_reminder_config 表
  11278. private void createRepurchaseReminderTables() {
  11279. try {
  11280. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_record (" +
  11281. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11282. "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," +
  11283. "product_id BIGINT NOT NULL COMMENT '商品ID'," +
  11284. "order_id BIGINT DEFAULT NULL COMMENT '关联订单ID'," +
  11285. "reminder_days INT DEFAULT 30 COMMENT '提醒天数'," +
  11286. "sent_at DATETIME DEFAULT NULL COMMENT '发送时间'," +
  11287. "clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'," +
  11288. "purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'," +
  11289. "INDEX idx_user_pending (user_id, purchased)" +
  11290. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒记录表'");
  11291. log.info("迁移347a: 已创建 repurchase_reminder_record 表");
  11292. } catch (Exception e) {
  11293. log.warn("迁移347a: 创建 repurchase_reminder_record 表失败: {}", e.getMessage());
  11294. }
  11295. try {
  11296. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_config (" +
  11297. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11298. "product_category VARCHAR(100) DEFAULT NULL COMMENT '商品类目'," +
  11299. "product_id BIGINT DEFAULT NULL COMMENT '特定商品ID(优先于category)'," +
  11300. "reminder_days INT DEFAULT 30 COMMENT '提醒天数'," +
  11301. "reminder_template VARCHAR(500) DEFAULT NULL COMMENT '提醒话术模板'," +
  11302. "max_reminders INT DEFAULT 3 COMMENT '最大提醒次数'," +
  11303. "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," +
  11304. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'" +
  11305. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒配置表'");
  11306. log.info("迁移347b: 已创建 repurchase_reminder_config 表");
  11307. } catch (Exception e) {
  11308. log.warn("迁移347b: 创建 repurchase_reminder_config 表失败: {}", e.getMessage());
  11309. }
  11310. }
  11311. // 迁移348: 创建 recommendation_product_associations 表(商品推荐关联关系)
  11312. private void createRecommendationProductAssociationsTable() {
  11313. try {
  11314. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_product_associations (" +
  11315. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11316. "source_product_id BIGINT NOT NULL COMMENT '源商品ID(已购/关注商品)'," +
  11317. "target_product_id BIGINT NOT NULL COMMENT '目标商品ID(被推荐商品)'," +
  11318. "association_type VARCHAR(32) NOT NULL COMMENT '类型: co_purchase/repurchase/profile_gap/manual'," +
  11319. "strength DECIMAL(5,2) DEFAULT 0.50 COMMENT '关联强度 0.00-1.00'," +
  11320. "manual_override TINYINT DEFAULT 0 COMMENT '是否运营手动覆盖 1=是 0=否'," +
  11321. "reason VARCHAR(500) DEFAULT NULL COMMENT '关联原因说明'," +
  11322. "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '关联维度(body/mind/wisdom/action/wealth)'," +
  11323. "repurchase_days INT DEFAULT NULL COMMENT '复购周期天数(repurchase场景)'," +
  11324. "co_purchase_window_days INT DEFAULT 30 COMMENT '共购时间窗口天数(co_purchase场景)'," +
  11325. "co_purchase_count INT DEFAULT 0 COMMENT '共购次数(co_purchase场景自动累计)'," +
  11326. "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," +
  11327. "created_by BIGINT DEFAULT NULL COMMENT '创建人用户ID(manual场景记录运营)'," +
  11328. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11329. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  11330. "INDEX idx_source_target (source_product_id, target_product_id)," +
  11331. "INDEX idx_type_enabled (association_type, enabled)," +
  11332. "INDEX idx_dimension (dimension_code)" +
  11333. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐关联关系表'");
  11334. log.info("迁移348: 已创建 recommendation_product_associations 表");
  11335. } catch (Exception e) {
  11336. log.warn("迁移348: 创建 recommendation_product_associations 表失败: {}", e.getMessage());
  11337. }
  11338. }
  11339. // 迁移349: product_recommendation_log 表添加 AB测试分组与推荐来源类型列
  11340. private void addAbTestColumnsToRecommendationLog() {
  11341. ensureColumn("product_recommendation_log", "ab_test_group",
  11342. "VARCHAR(32) DEFAULT NULL COMMENT 'AB测试分组(A/B/C/控制组)'");
  11343. ensureColumn("product_recommendation_log", "recommendation_type",
  11344. "VARCHAR(32) DEFAULT NULL COMMENT '推荐来源类型:dimension/co_purchase/repurchase/profile_gap/manual'");
  11345. log.info("迁移349: product_recommendation_log 已添加 ab_test_group / recommendation_type 列");
  11346. }
  11347. // 迁移349: 创建 report_transfer_requests 表(健康报告跨家庭转移申请)
  11348. // 同一份报告(file_hash 相同)被其他家庭上传时,申请方家庭发起转移申请,
  11349. // 原归属家庭同意后 health_reports.family_id 转移至申请方,原家庭不再可见。
  11350. private void createReportTransferRequestsTable() {
  11351. try {
  11352. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_transfer_requests (" +
  11353. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11354. "report_id BIGINT NOT NULL COMMENT '报告ID(health_reports.id)'," +
  11355. "from_family_id BIGINT NOT NULL COMMENT '原归属家庭ID(需同意方)'," +
  11356. "to_family_id BIGINT NOT NULL COMMENT '申请转入家庭ID(申请方)'," +
  11357. "requester_user_id BIGINT NOT NULL COMMENT '申请人用户ID(users.id)'," +
  11358. "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending=待同意 approved=已同意 denied=已拒绝'," +
  11359. "handled_by BIGINT DEFAULT NULL COMMENT '处理人用户ID'," +
  11360. "handled_at DATETIME DEFAULT NULL COMMENT '处理时间'," +
  11361. "remark VARCHAR(200) DEFAULT NULL COMMENT '备注(拒绝原因)'," +
  11362. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11363. "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  11364. "INDEX idx_report_id (report_id)," +
  11365. "INDEX idx_from_family_status (from_family_id, status)," +
  11366. "INDEX idx_to_family_status (to_family_id, status)" +
  11367. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告跨家庭转移申请'");
  11368. log.info("迁移349: 已创建 report_transfer_requests 表");
  11369. } catch (Exception e) {
  11370. log.warn("迁移349: 创建 report_transfer_requests 表失败: {}", e.getMessage());
  11371. }
  11372. }
  11373. // 迁移350: products 表添加 min_discount_level 列(商品粒度控制会员价门槛,NULL=所有会员均可享会员价)
  11374. private void addProductMinDiscountLevel() {
  11375. try {
  11376. jdbcTemplate.execute("ALTER TABLE products ADD COLUMN min_discount_level VARCHAR(50) DEFAULT NULL " +
  11377. "COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)' AFTER min_visible_level");
  11378. log.info("迁移350: 已添加 products.min_discount_level 列");
  11379. } catch (Exception e) {
  11380. // 列已存在,忽略
  11381. log.debug("迁移350: products.min_discount_level 列已存在,跳过");
  11382. }
  11383. }
  11384. // 迁移351: membership_levels.level_order 顺延,为 BASIC 让出独立档位
  11385. // 会员级别包含关系(数字越大级别越高): FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5
  11386. // PROVIDER 为服务商独立角色(不走会员权益),与 FREE 同级=1;最终值由迁移381 fixMembershipLevelOrder 兜底
  11387. private void backfillMembershipLevelOrder() {
  11388. try {
  11389. int updated = jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
  11390. "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 1 " +
  11391. "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 ELSE level_order END");
  11392. log.info("迁移351: 已回填 membership_levels.level_order({} 行):FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员)", updated);
  11393. } catch (Exception e) {
  11394. log.warn("迁移351: 回填 membership_levels.level_order 失败: {}", e.getMessage());
  11395. }
  11396. }
  11397. // 迁移381: 修正 membership_levels.level_order(迁移351/360 执行顺序导致的 FAMILY 被重置为 2 的问题;
  11398. // 同时 PROVIDER 为服务商独立角色,不走会员权益,降至与 FREE 同级,避免其参与 getMemberLevel / resolveEffectiveLevel 的等级比较)
  11399. // level_order 语义:数字越大级别越高; FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员)
  11400. private void fixMembershipLevelOrder() {
  11401. try {
  11402. int updated = jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
  11403. "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 " +
  11404. "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 WHEN 'PROVIDER' THEN 1 ELSE level_order END " +
  11405. "WHERE level_code IN ('FREE','BASIC','FAMILY','PREMIUM','LIFETIME','PROVIDER')");
  11406. log.info("迁移381: 已修正 membership_levels.level_order({} 行):" +
  11407. "FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5 PROVIDER=1(非会员)", updated);
  11408. } catch (Exception e) {
  11409. log.warn("迁移381: 修正 membership_levels.level_order 失败: {}", e.getMessage());
  11410. }
  11411. }
  11412. /**
  11413. * 迁移360: 恢复 9.9 元/月 BASIC 体验会员等级与权益(幂等)。
  11414. * 反向还原提交 f86dd164b 删除的内容;level_order 由 fixMembershipLevelOrder(迁移381)统一回填。
  11415. */
  11416. private void restoreBasicMembershipLevel() {
  11417. try {
  11418. jdbcTemplate.execute(
  11419. "INSERT IGNORE INTO membership_levels " +
  11420. "(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) " +
  11421. "VALUES ('BASIC', '体验会员', " +
  11422. "'9.9元/月体验版,解锁核心功能,限次使用AI与报告解读', " +
  11423. "990, " +
  11424. "'[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]', " +
  11425. "3, 3)");
  11426. log.info("迁移360: 已恢复 BASIC 体验会员等级");
  11427. } catch (Exception e) {
  11428. log.warn("迁移360: 恢复 BASIC 会员等级失败: {}", e.getMessage());
  11429. }
  11430. // 兼容历史数据:早期 BASIC features 缺少 referral_commission,补齐
  11431. try {
  11432. jdbcTemplate.update("UPDATE membership_levels SET features = ? WHERE level_code = 'BASIC' AND features NOT LIKE '%referral_commission%'",
  11433. "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]");
  11434. } catch (Exception e) {
  11435. log.warn("迁移360: 补齐 BASIC features 失败: {}", e.getMessage());
  11436. }
  11437. try {
  11438. jdbcTemplate.execute(
  11439. "INSERT IGNORE INTO membership_benefits " +
  11440. "(level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
  11441. "('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1), " +
  11442. "('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4), " +
  11443. "('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3), " +
  11444. "('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测试', '📅', '#E0E7FF', 8)");
  11445. log.info("迁移360: 已恢复 BASIC 会员权益");
  11446. } catch (Exception e) {
  11447. log.warn("迁移360: 恢复 BASIC 会员权益失败: {}", e.getMessage());
  11448. }
  11449. }
  11450. private void removeReportUploadQuota() {
  11451. try {
  11452. int deletedBenefits = jdbcTemplate.update(
  11453. "DELETE FROM membership_benefits WHERE level_code = 'BASIC' AND benefit_code = 'REPORT_UPLOAD_LITE'");
  11454. log.info("迁移352: 已清理遗留权益记录({} 行)", deletedBenefits);
  11455. } catch (Exception e) {
  11456. log.warn("迁移352: 移除 REPORT_UPLOAD_LITE 权益失败: {}", e.getMessage());
  11457. }
  11458. try {
  11459. int deletedUsage = jdbcTemplate.update(
  11460. "DELETE FROM basic_quota_usage WHERE quota_type = 'REPORT_UPLOAD'");
  11461. log.info("迁移352: 已清理 REPORT_UPLOAD 配额记录({} 行)", deletedUsage);
  11462. } catch (Exception e) {
  11463. log.warn("迁移352: 清理 REPORT_UPLOAD 配额记录失败: {}", e.getMessage());
  11464. }
  11465. }
  11466. /**
  11467. * 迁移405: 9.9 元体验会员(BASIC)权益口径修正(2026-10-23 用户定稿,单一事实源)。
  11468. * <ul>
  11469. * <li>AI 对话:每天 3 次(原"AI健康顾问"月配额口径,展示名同步改为"AI 对话");</li>
  11470. * <li>饮食拍照识别:每天 3 次;</li>
  11471. * <li>报告解读:保留每月 1 次;</li>
  11472. * <li>测评预约:不是特殊功能 → 移除 assessment_quota_lite 与 ASSESSMENT_QUOTA 配额(预约凭已购测评额度);</li>
  11473. * <li>报告上传:全员开放 → 移除 health_report_upload_lite;</li>
  11474. * <li>每日任务:不限 → max_tasks_per_day=0;</li>
  11475. * <li>指定商品会员价:discount_purchase 生效(products.member_price + min_discount_level 机制)。</li>
  11476. * </ul>
  11477. * 配额周期粒度(日/月)由 BasicQuotaService.QUOTA_PERIODS 按类型判定,本迁移同步 features/权益/描述。
  11478. */
  11479. private void applyBasicMembership20261023Spec() {
  11480. try {
  11481. jdbcTemplate.update(
  11482. "UPDATE membership_levels SET level_desc = ?, features = ?, max_tasks_per_day = 0 " +
  11483. "WHERE level_code = 'BASIC'",
  11484. "9.9元/月体验版:AI对话每天3次、饮食识别每天3次、报告解读每月1次,享指定商品会员价",
  11485. "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]");
  11486. log.info("迁移405: 已更新 BASIC 等级口径(features/max_tasks_per_day/level_desc)");
  11487. } catch (Exception e) {
  11488. log.warn("迁移405: 更新 BASIC 等级失败: {}", e.getMessage());
  11489. }
  11490. try {
  11491. jdbcTemplate.update(
  11492. "UPDATE membership_benefits SET benefit_name = ?, benefit_desc = ? " +
  11493. "WHERE level_code = 'BASIC' AND benefit_code = 'AI_CHAT_LITE'",
  11494. "AI对话(限3次/天)", "每天3次AI对话问答");
  11495. jdbcTemplate.update(
  11496. "DELETE FROM membership_benefits WHERE level_code = 'BASIC' AND benefit_code = 'ASSESSMENT_QUOTA_LITE'");
  11497. jdbcTemplate.execute(
  11498. "INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
  11499. "('BASIC', 'FOOD_RECOGNIZE_LITE', '饮食拍照识别(限3次/天)', '每天3次食材图片识别', '📷', '#FEF9C3', 2), " +
  11500. "('BASIC', 'MEMBER_PRICE', '指定商品会员价', '购买指定商品享受会员价', '🏷️', '#FEF3C7', 5)");
  11501. log.info("迁移405: 已更新 BASIC 权益列表(AI对话更名/去测评预约/增识别与会员价)");
  11502. } catch (Exception e) {
  11503. log.warn("迁移405: 更新 BASIC 权益失败: {}", e.getMessage());
  11504. }
  11505. // 配额记录切新制度:ASSESSMENT_QUOTA 整体移除;AI_CHAT/FOOD_RECOGNIZE 月记录改按日懒重建(删除旧记录避免月初日/月周期重合碰撞)
  11506. try {
  11507. int deleted = jdbcTemplate.update(
  11508. "DELETE FROM basic_quota_usage WHERE quota_type IN ('ASSESSMENT_QUOTA','AI_CHAT','FOOD_RECOGNIZE')");
  11509. log.info("迁移405: 已清理旧口径配额记录({} 行)", deleted);
  11510. } catch (Exception e) {
  11511. log.warn("迁移405: 清理配额记录失败: {}", e.getMessage());
  11512. }
  11513. }
  11514. // 迁移353: 任务提醒配置表(用户级,一行/用户;提醒渠道/时机/偏移分钟可配置)
  11515. private void createTaskReminderConfigTable() {
  11516. try {
  11517. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS task_reminder_config (" +
  11518. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11519. "user_id BIGINT NOT NULL COMMENT '用户ID(users.id),家长账号'," +
  11520. "channels VARCHAR(100) DEFAULT 'wechat' COMMENT '提醒渠道(逗号分隔): in_app/wechat/sms/phone/email'," +
  11521. "timing_mode VARCHAR(20) DEFAULT 'after_timeout' COMMENT '提醒时机: before_deadline=截止前提醒 after_timeout=超时后提醒'," +
  11522. "offset_minutes INT DEFAULT 60 COMMENT '偏移分钟: before_deadline=截止前N分钟; after_timeout=超时后N分钟'," +
  11523. "enabled TINYINT DEFAULT 1 COMMENT '是否启用 1=启用 0=停用'," +
  11524. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11525. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  11526. "UNIQUE KEY uk_user (user_id)" +
  11527. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表'");
  11528. log.info("迁移353: 已创建 task_reminder_config 表");
  11529. try { ensureColumn("task_reminder_config", "completion_praise_enabled", "TINYINT DEFAULT 0 COMMENT '任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用'"); ensureColumn("task_reminder_config", "completion_praise_channels", "VARCHAR(100) DEFAULT 'wechat,in_app' COMMENT '完成表扬渠道(逗号分隔)'"); } catch (Exception ex) { log.warn("迁移353补充列失败: {}", ex.getMessage()); }
  11530. } catch (Exception e) {
  11531. log.warn("迁移353: 创建 task_reminder_config 表失败: {}", e.getMessage());
  11532. }
  11533. }
  11534. // 迁移359: 跨小程序单点登录一次性票据表(cfc ↔ train 双向免登)
  11535. // 详见 schema.sql。此处自愈建表,避免 schema.sql 建表语句因故静默失败导致登录报错。
  11536. private void createSsoTicketTable() {
  11537. try {
  11538. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS sso_ticket (" +
  11539. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11540. "ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)'," +
  11541. "source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train'," +
  11542. "target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train'," +
  11543. "source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)'," +
  11544. "cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据'," +
  11545. "used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用'," +
  11546. "expire_at DATETIME NOT NULL COMMENT '过期时间'," +
  11547. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11548. "UNIQUE KEY uk_ticket (ticket)," +
  11549. "INDEX idx_expire (expire_at)" +
  11550. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据'");
  11551. log.info("迁移359: 已创建 sso_ticket 表");
  11552. } catch (Exception e) {
  11553. log.warn("迁移359: 创建 sso_ticket 表失败: {}", e.getMessage());
  11554. }
  11555. }
  11556. // 迁移355: product_recommendation_log 补齐缺失列
  11557. // 旧库该表由更早版本的建表语句创建(CREATE TABLE IF NOT EXISTS 不会补列),
  11558. // 缺 family_id/was_clicked/was_purchased/clicked_at/purchased_at/updated_at,
  11559. // 导致 stats/ab-summary、stats/by-type 查询报 Unknown column 返回 500
  11560. private void addMissingColumnsToRecommendationLog() {
  11561. ensureColumn("product_recommendation_log", "family_id",
  11562. "BIGINT DEFAULT NULL COMMENT '家庭ID(family_members.family_id)'");
  11563. ensureColumn("product_recommendation_log", "was_clicked",
  11564. "TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'");
  11565. ensureColumn("product_recommendation_log", "was_purchased",
  11566. "TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'");
  11567. ensureColumn("product_recommendation_log", "clicked_at",
  11568. "DATETIME DEFAULT NULL COMMENT '点击时间'");
  11569. ensureColumn("product_recommendation_log", "purchased_at",
  11570. "DATETIME DEFAULT NULL COMMENT '购买时间'");
  11571. ensureColumn("product_recommendation_log", "updated_at",
  11572. "DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'");
  11573. log.info("迁移355: product_recommendation_log 已补齐缺失列");
  11574. }
  11575. // 迁移354: 报告采集指标项定义(report_type_registry.expected_indicators)+ 草稿采集校验结果列(health_report_drafts.collect_validation)
  11576. // 每个报告类型定义「应采集指标项」清单(JSON 数组),ReportCollectService 采集完成后按此校验完整性,结果写入草稿 collect_validation。
  11577. // DAN 指标项按 测评数据_优化提取.csv 各报告类型列(填充率>=30%)总结;菌群指标项按 docs/参考资料 已解析报告的指标/区块总结。
  11578. private void registerCollectExpectedIndicators() {
  11579. ensureColumn("report_type_registry", "expected_indicators",
  11580. "TEXT NULL COMMENT '该报告类型应采集的指标项定义(JSON数组),用于数据采集完整性校验'");
  11581. ensureColumn("health_report_drafts", "collect_validation",
  11582. "TEXT NULL COMMENT '采集指标校验结果(JSON: typeId/expected/matched/pass/missing)'");
  11583. // 确保注册表存在各 DAN 类型 + 北京菌群类型的行(parser_path 空 = 保持走 LangGraph 检测,不影响既有路由)
  11584. ensureReportTypeRow("dan_a1", "dan", "素质测评A1", "核心认知发展评估报告:感知觉/注意力/记忆力/推理能力/空间能力/加工速度");
  11585. ensureReportTypeRow("dan_b2", "dan", "素质测评B2", "儿童行为评估报告:自我概念/行为问题");
  11586. ensureReportTypeRow("dan_b3", "dan", "素质测评B3", "学习能力评估报告:执行功能/学习动机/学习策略/自我效能感");
  11587. ensureReportTypeRow("dan_b4", "dan", "素质测评B4", "核心素养评估报告:认知能力/成长型思维/自我概念");
  11588. ensureReportTypeRow("dan_b5", "dan", "素质测评B5", "青春期发展评估报告:情绪调节/人际关系/睡眠/运动/网络依赖/学业压力");
  11589. ensureReportTypeRow("dan_b6", "dan", "素质测评B6", "职业发展评估报告:霍兰德兴趣/多元智能/职业价值观");
  11590. ensureReportTypeRow("dan_c1", "dan", "素质测评C1", "校园版核心素养评估报告:认知能力/大五人格/自我概念/学习动机");
  11591. ensureReportTypeRow("gut_flora_beijing", "gut_flora", "北京菌群检测", "北京肠道菌群检测报告(FMT移植前/后评估)");
  11592. // 种子各类型「应采集指标项」定义(每次启动覆盖,保证与代码一致)
  11593. seedExpectedIndicator("dan_a1", jsonArray(
  11594. "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score",
  11595. "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score",
  11596. "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score",
  11597. "总分", "百分位"));
  11598. seedExpectedIndicator("dan_a2", jsonArray(
  11599. "BMI", "感知觉_pct", "记忆力_pct", "注意力_pct",
  11600. "外倾性", "宜人性", "开放性", "责任心", "神经质",
  11601. "情绪总分", "焦虑-安详", "抑郁-愉快", "无力感-掌控感", "自卑-自尊",
  11602. "与同伴亲近", "与同伴信任", "与同伴沟通",
  11603. "与母亲亲近", "与母亲信任", "与母亲沟通",
  11604. "与父亲亲近", "与父亲信任", "与父亲沟通",
  11605. "身高", "体重", "睡眠_小时", "运动_小时", "饮食_小时",
  11606. "总分", "百分位"));
  11607. seedExpectedIndicator("dan_b2", jsonArray(
  11608. "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校",
  11609. "自我概念_行为表现", "自我概念_躯体外貌",
  11610. "行为_品行问题", "行为_学习问题", "行为_情绪问题", "行为_生活习惯", "行为_社交问题"));
  11611. seedExpectedIndicator("dan_b3", jsonArray(
  11612. "工作记忆_pct", "认知灵活性_pct", "抑制控制_pct",
  11613. "表面动机", "深层动机", "学习自我调节", "表面方法与策略", "深层方法与策略",
  11614. "自我效能感", "总分"));
  11615. seedExpectedIndicator("dan_b4", jsonArray(
  11616. "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score",
  11617. "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score",
  11618. "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score",
  11619. "成长型思维",
  11620. "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校",
  11621. "自我概念_行为表现", "自我概念_躯体外貌",
  11622. "自主性", "胜任感", "归属感",
  11623. "总分", "百分位"));
  11624. seedExpectedIndicator("dan_b5", jsonArray(
  11625. "表达抑制", "认知重评",
  11626. "人际_父亲关系", "人际_父亲关系_过度期待", "人际_父亲关系_价值冲突",
  11627. "人际_父亲关系_控制压迫", "人际_父亲关系_情感疏离", "人际_父亲关系_双重标准",
  11628. "人际_母亲关系", "人际_母亲关系_过度期待", "人际_母亲关系_价值冲突",
  11629. "人际_母亲关系_控制压迫", "人际_母亲关系_情感疏离", "人际_母亲关系_双重标准",
  11630. "人际_师生关系", "人际_师生关系_敌对型", "人际_师生关系_高控制型",
  11631. "人际_师生关系_关系疏离型", "人际_师生关系_回避型",
  11632. "人际_同伴关系", "人际_同伴关系_被排斥型", "人际_同伴关系_边缘型",
  11633. "人际_同伴关系_攻击型", "人际_同伴关系_回避型", "人际_同伴关系_特殊因素型",
  11634. "社交_表达影响", "社交_冲突解决", "社交_情感表达", "社交_情感支持", "社交_主动交往",
  11635. "入睡时间_min", "睡眠_催眠药物", "睡眠_日间功能", "睡眠_时长", "睡眠_效率",
  11636. "睡眠_障碍", "睡眠_主观质量",
  11637. "网络依赖_戒断反应", "网络依赖_强迫使用", "网络依赖_时间管理", "网络依赖_总分",
  11638. "学业压力_家庭期望", "学业压力_师生关系", "学业压力_同伴竞争", "学业压力_学业负担", "学业压力_自我期望",
  11639. "运动_步行_频率", "运动_步行_时长", "运动_高强度_频率", "运动_高强度_时长",
  11640. "运动_久坐_频率", "运动_久坐_时长", "运动_中等强度_频率", "运动_中等强度_时长",
  11641. "总分", "百分位"));
  11642. seedExpectedIndicator("dan_b6", jsonArray(
  11643. "能力_内省", "能力_人际关系", "能力_音乐", "能力_自然",
  11644. "兴趣_常规型", "兴趣_社会型", "兴趣_事业型", "兴趣_现实型", "兴趣_研究型", "兴趣_艺术型",
  11645. "职业价值观"));
  11646. seedExpectedIndicator("dan_c1", jsonArray(
  11647. "BMI",
  11648. "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score",
  11649. "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score",
  11650. "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score",
  11651. "外倾性", "宜人性", "开放性", "责任心", "神经质", "情绪总分",
  11652. "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校",
  11653. "自我概念_行为表现", "自我概念_躯体外貌",
  11654. "表面动机", "深层动机", "学习自我调节", "表面方法与策略", "深层方法与策略", "自我效能感",
  11655. "自主性", "胜任感", "归属感",
  11656. "与同伴亲近", "与同伴信任", "与同伴沟通",
  11657. "与母亲亲近", "与母亲信任", "与母亲沟通",
  11658. "与父亲亲近", "与父亲信任", "与父亲沟通",
  11659. "身高", "体重", "睡眠时长", "运动时间",
  11660. "总分", "百分位"));
  11661. seedExpectedIndicator("gut_flora", jsonArray(
  11662. "肠道预测年龄", "肠型", "肠道菌群平衡", "菌群多样性", "有益菌", "有害菌", "核心菌属",
  11663. "健康总分", "菌群健康", "慢病控制", "营养均衡",
  11664. "疾病风险评估", "主要营养评估", "氨基酸评估", "维生素评估", "微量元素评估",
  11665. "主要消化道致病菌", "抗生素风险评估", "肠道屏障及代谢物", "短链脂肪酸",
  11666. "神经递质及激素", "菌群检出详细列表", "个体化食物推荐表"));
  11667. seedExpectedIndicator("gut_flora_beijing", jsonArray(
  11668. "肠型", "肠道微生物健康指数", "肠道菌群平衡评估", "肠道黏膜屏障", "肠道菌群多样性",
  11669. "肠道菌群表型评估", "肠道抗炎能力", "肠道免疫力", "肠道膳食纤维需求", "短链脂肪酸合成能力",
  11670. "核心菌属综合评分", "有益菌综合评分", "有害菌综合评分",
  11671. "疾病风险评估", "营养物质及营养素代谢评估", "抗生素风险评估",
  11672. "毒性物质清除能力评估", "胃肠道感染病原体评估", "趣味肠菌评估",
  11673. "菌群检出详细列表", "个体化食物推荐表"));
  11674. }
  11675. /** 注册报告类型行(INSERT IF 不存在,幂等;parser_path=null 保持走 LangGraph 检测) */
  11676. private void ensureReportTypeRow(String typeId, String family, String displayName, String description) {
  11677. try {
  11678. Integer count = jdbcTemplate.queryForObject(
  11679. "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, typeId);
  11680. if (count == null || count == 0) {
  11681. jdbcTemplate.update(
  11682. "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " +
  11683. "min_confidence, is_active, source, review_status, version, created_at, updated_at) " +
  11684. "VALUES (?, ?, ?, ?, NULL, 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())",
  11685. typeId, family, displayName, description);
  11686. log.info("迁移354: 已注册报告类型 {}", typeId);
  11687. }
  11688. } catch (Exception e) {
  11689. log.warn("迁移354: 注册报告类型 {} 失败: {}", typeId, e.getMessage());
  11690. }
  11691. }
  11692. /** 写入类型「应采集指标项」定义(仅当为空时写入,保留管理员编辑) */
  11693. void seedExpectedIndicator(String typeId, String expectedJson) {
  11694. try {
  11695. int updated = jdbcTemplate.update(
  11696. "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() " +
  11697. "WHERE type_id = ? AND (expected_indicators IS NULL OR expected_indicators = '')",
  11698. expectedJson, typeId);
  11699. if (updated == 0) {
  11700. log.info("迁移354: 类型 {} 已有指标项定义,跳过种子", typeId);
  11701. } else {
  11702. log.info("迁移354: 已为类型 {} 写入指标项定义", typeId);
  11703. }
  11704. } catch (Exception e) {
  11705. log.warn("迁移354: 为类型 {} 写入指标项定义失败: {}", typeId, e.getMessage());
  11706. }
  11707. }
  11708. /** 简易 JSON 字符串数组序列化(指标项均为中文/ASCII,无转义风险) */
  11709. private String jsonArray(String... items) {
  11710. StringBuilder sb = new StringBuilder("[");
  11711. for (int i = 0; i < items.length; i++) {
  11712. if (i > 0) {
  11713. sb.append(',');
  11714. }
  11715. sb.append('"').append(items[i]).append('"');
  11716. }
  11717. return sb.append(']').toString();
  11718. }
  11719. // 迁移359: 家庭共餐选中食材表(持久化共餐场景食材选择)
  11720. private void createDietFamilySelectedIngredientsTable() {
  11721. try {
  11722. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_family_selected_ingredients (" +
  11723. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  11724. "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
  11725. "food_id BIGINT NOT NULL COMMENT '食材ID'," +
  11726. "food_name VARCHAR(100) COMMENT '食材名称'," +
  11727. "score INT COMMENT '推荐分'," +
  11728. "reason VARCHAR(255) COMMENT '推荐理由'," +
  11729. "category VARCHAR(50) COMMENT '分类'," +
  11730. "calories_per_100g INT COMMENT '每100克卡路里'," +
  11731. "cooking_level TINYINT COMMENT '烹饪难度(1=快手,2=中等,3=耗时)'," +
  11732. "nutrition_score INT COMMENT '营养密度分(0-100)'," +
  11733. "balance_reason VARCHAR(255) COMMENT '均衡化推荐理由'," +
  11734. "sort_order INT DEFAULT 0 COMMENT '排序'," +
  11735. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  11736. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  11737. "UNIQUE KEY uk_family_food (family_id, food_id)," +
  11738. "INDEX idx_family (family_id)" +
  11739. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭共餐选中食材'");
  11740. log.info("迁移359: 已创建 diet_family_selected_ingredients 表");
  11741. } catch (Exception e) {
  11742. log.warn("迁移359: 创建 diet_family_selected_ingredients 表失败: {}", e.getMessage());
  11743. }
  11744. }
  11745. // 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求)
  11746. private void migrateIndicatorDefinitionsColumns() {
  11747. ensureColumn("indicator_definitions", "source_kind",
  11748. "VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'");
  11749. ensureColumn("indicator_definitions", "report_type_scope",
  11750. "VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'");
  11751. ensureColumn("indicator_definitions", "value_type",
  11752. "VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'");
  11753. ensureColumn("indicator_definitions", "good_direction",
  11754. "VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'");
  11755. ensureColumn("indicator_definitions", "dimension_code",
  11756. "VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'");
  11757. log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列");
  11758. }
  11759. // 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status)
  11760. private void migrateIndicatorValuesColumns() {
  11761. ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'");
  11762. ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'");
  11763. ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'");
  11764. ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'");
  11765. ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'");
  11766. ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'");
  11767. log.info("迁移357: indicator_values 已补齐统一观测时序 6 列");
  11768. }
  11769. // 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引
  11770. private void addIndicatorValuesKeys() {
  11771. try {
  11772. // 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复
  11773. jdbcTemplate.execute(
  11774. "DELETE v FROM indicator_values v " +
  11775. "JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " +
  11776. " FROM indicator_values " +
  11777. " GROUP BY source_type, source_id, definition_id " +
  11778. " HAVING COUNT(*) > 1) d " +
  11779. " ON v.source_type = d.source_type AND v.source_id = d.source_id " +
  11780. " AND v.definition_id = d.definition_id AND v.id <> d.keep_id");
  11781. log.info("迁移358: 已清理 indicator_values 重复行(如有)");
  11782. } catch (Exception e) {
  11783. log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage());
  11784. }
  11785. try {
  11786. jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)");
  11787. log.info("迁移358: 已添加 uk_source_definition 唯一键");
  11788. } catch (Exception e) {
  11789. log.info("迁移358: uk_source_definition 已存在,跳过");
  11790. }
  11791. try {
  11792. jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)");
  11793. log.info("迁移358: 已添加 idx_subject_def_date 索引");
  11794. } catch (Exception e) {
  11795. log.info("迁移358: idx_subject_def_date 已存在,跳过");
  11796. }
  11797. }
  11798. // 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则)
  11799. private void createIndicatorMappingRulesTable() {
  11800. try {
  11801. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" +
  11802. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  11803. "source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," +
  11804. "raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," +
  11805. "alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," +
  11806. "definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," +
  11807. "match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," +
  11808. "priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," +
  11809. "status TINYINT DEFAULT 1," +
  11810. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  11811. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  11812. "UNIQUE KEY uk_source_raw (source_type, raw_name)," +
  11813. "INDEX idx_def (definition_id)" +
  11814. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'");
  11815. log.info("迁移359: 已创建 indicator_mapping_rules 表");
  11816. } catch (Exception e) {
  11817. log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage());
  11818. }
  11819. }
  11820. // 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准)
  11821. private void createIndicatorCorrelationsTable() {
  11822. try {
  11823. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" +
  11824. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  11825. "definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," +
  11826. "definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," +
  11827. "relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," +
  11828. "direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," +
  11829. "manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," +
  11830. "computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," +
  11831. "method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," +
  11832. "sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," +
  11833. "confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," +
  11834. "effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," +
  11835. "manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," +
  11836. "cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," +
  11837. "status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," +
  11838. "computed_at DATETIME NULL COMMENT '最后校准时间'," +
  11839. "created_by BIGINT NULL COMMENT '创建者'," +
  11840. "description VARCHAR(500) NULL COMMENT '给用户看的解释'," +
  11841. "source_note VARCHAR(255) NULL COMMENT '依据/出处'," +
  11842. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  11843. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  11844. "UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," +
  11845. "INDEX idx_def_a (definition_id_a)," +
  11846. "INDEX idx_def_b (definition_id_b)" +
  11847. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'");
  11848. log.info("迁移360: 已创建 indicator_correlations 表");
  11849. } catch (Exception e) {
  11850. log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage());
  11851. }
  11852. }
  11853. // 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐)
  11854. private void ensureIndicatorCollectionDate() {
  11855. ensureColumn("health_indicators", "collection_date",
  11856. "DATETIME COMMENT '指标采集日期(报告检测日期)'");
  11857. log.info("迁移361: health_indicators.collection_date 已确认存在");
  11858. }
  11859. // 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values)
  11860. private void seedDimensionDefinitions() {
  11861. Object[][] dims = {
  11862. {"dim.body", "身", "身体", "higher_is_better", "higher_is_better"},
  11863. {"dim.mind", "心", "心理", "higher_is_better", "higher_is_better"},
  11864. {"dim.wisdom", "智", "智慧", "higher_is_better", "higher_is_better"},
  11865. {"dim.action", "行", "行动", "higher_is_better", "higher_is_better"},
  11866. {"dim.wealth", "富", "财富", "higher_is_better", "higher_is_better"},
  11867. };
  11868. for (Object[] d : dims) {
  11869. try {
  11870. Integer exists = jdbcTemplate.queryForObject(
  11871. "SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]);
  11872. if (exists == null || exists == 0) {
  11873. jdbcTemplate.update(
  11874. "INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " +
  11875. "source_kind, value_type, good_direction, dimension_code, sort_order, status) " +
  11876. "VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)",
  11877. d[1], d[0], d[2], d[4], d[3]);
  11878. log.info("迁移362: 已预置维度定义 {}", d[0]);
  11879. }
  11880. } catch (Exception e) {
  11881. log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage());
  11882. }
  11883. }
  11884. }
  11885. // 迁移355: 创建减脂专题体重记录表
  11886. private void createFatWeightRecordTable() {
  11887. try {
  11888. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_weight_record (" +
  11889. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11890. "member_id BIGINT NOT NULL, " +
  11891. "weight DECIMAL(5,2) NOT NULL, " +
  11892. "body_fat_percent DECIMAL(4,2) DEFAULT NULL, " +
  11893. "waist_circumference DECIMAL(4,2) DEFAULT NULL, " +
  11894. "measure_date DATE NOT NULL, " +
  11895. "remark VARCHAR(255) DEFAULT NULL, " +
  11896. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  11897. "INDEX idx_fat_weight_member (member_id, measure_date)) " +
  11898. "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-体重体脂记录'");
  11899. log.info("迁移355: 已创建fat_weight_record表");
  11900. } catch (Exception e) {
  11901. log.warn("迁移355: 创建fat_weight_record表失败: {}", e.getMessage());
  11902. }
  11903. }
  11904. // 迁移356: 创建减脂专题饮食记录表
  11905. private void createFatDietRecordTable() {
  11906. try {
  11907. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_diet_record (" +
  11908. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11909. "member_id BIGINT NOT NULL, " +
  11910. "meal_type VARCHAR(20) NOT NULL, " +
  11911. "food_name VARCHAR(255) NOT NULL, " +
  11912. "food_weight DECIMAL(6,2) DEFAULT NULL, " +
  11913. "calories INT DEFAULT 0, " +
  11914. "protein DECIMAL(6,2) DEFAULT 0, " +
  11915. "fat DECIMAL(6,2) DEFAULT 0, " +
  11916. "carbs DECIMAL(6,2) DEFAULT 0, " +
  11917. "eat_date DATE NOT NULL, " +
  11918. "remark VARCHAR(255) DEFAULT NULL, " +
  11919. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  11920. "INDEX idx_fat_diet_member (member_id, eat_date)) " +
  11921. "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-饮食记录'");
  11922. log.info("迁移356: 已创建fat_diet_record表");
  11923. } catch (Exception e) {
  11924. log.warn("迁移356: 创建fat_diet_record表失败: {}", e.getMessage());
  11925. }
  11926. }
  11927. // 迁移357: 创建减脂专题运动记录表
  11928. private void createFatExerciseRecordTable() {
  11929. try {
  11930. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_exercise_record (" +
  11931. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11932. "member_id BIGINT NOT NULL, " +
  11933. "exercise_type VARCHAR(50) NOT NULL, " +
  11934. "duration_min INT DEFAULT 0, " +
  11935. "distance_km DECIMAL(5,2) DEFAULT NULL, " +
  11936. "calories_burned INT DEFAULT 0, " +
  11937. "exercise_date DATE NOT NULL, " +
  11938. "remark VARCHAR(255) DEFAULT NULL, " +
  11939. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  11940. "INDEX idx_fat_exercise_member (member_id, exercise_date)) " +
  11941. "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-运动记录'");
  11942. log.info("迁移357: 已创建fat_exercise_record表");
  11943. } catch (Exception e) {
  11944. log.warn("迁移357: 创建fat_exercise_record表失败: {}", e.getMessage());
  11945. }
  11946. }
  11947. // 迁移358: 创建减脂专题目标表
  11948. private void createFatGoalTable() {
  11949. try {
  11950. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_goal (" +
  11951. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11952. "member_id BIGINT NOT NULL, " +
  11953. "target_weight DECIMAL(5,2) NOT NULL, " +
  11954. "start_weight DECIMAL(5,2) NOT NULL, " +
  11955. "target_body_fat DECIMAL(4,2) DEFAULT NULL, " +
  11956. "target_date DATE DEFAULT NULL, " +
  11957. "status VARCHAR(20) DEFAULT 'active', " +
  11958. "start_date DATE NOT NULL, " +
  11959. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
  11960. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  11961. "INDEX idx_fat_goal_member (member_id)) " +
  11962. "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标'");
  11963. log.info("迁移358: 已创建fat_goal表");
  11964. } catch (Exception e) {
  11965. log.warn("迁移358: 创建fat_goal表失败: {}", e.getMessage());
  11966. }
  11967. }
  11968. // 迁移384: products 表添加 vendor_visible 列(仅供应商体系内可见开关)
  11969. private void addProductVendorVisibleColumn() {
  11970. ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享详情页可访问)'");
  11971. }
  11972. // 迁移367: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记)
  11973. private void addProductHealthConstraintColumns() {
  11974. ensureColumn("products", "contraindicated_diseases", "VARCHAR(255) DEFAULT NULL COMMENT '禁忌基础疾病,逗号分隔(如\"慢性肾病,肝病\")'");
  11975. ensureColumn("products", "allergen_tags", "VARCHAR(255) DEFAULT NULL COMMENT '过敏原标签,逗号分隔(如\"花生,小麦\")'");
  11976. ensureColumn("products", "drug_interaction_flags", "TINYINT DEFAULT 0 COMMENT '是否命中药物-食物相互作用(1=是,需对照drug_interactions表)'");
  11977. }
  11978. // 迁移368: 创建 drug_interactions 表(药物-食物禁忌对照表)
  11979. private void createDrugInteractionsTable() {
  11980. try {
  11981. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS drug_interactions (" +
  11982. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  11983. "drug_class VARCHAR(50) NOT NULL COMMENT '药物类别(如CCB/他汀/抗凝药/抗生素/铁剂/甲状腺药/利尿剂/MAOI/头孢/降糖药)', " +
  11984. "drug_specifics VARCHAR(100) NOT NULL COMMENT '具体药名(如硝苯地平/华法林)', " +
  11985. "interacting_subst VARCHAR(100) NOT NULL COMMENT '禁忌/需间隔的食材或成分(如西柚/维生素K)', " +
  11986. "risk_level TINYINT NOT NULL COMMENT '1=拦截 2=警告 3=需间隔', " +
  11987. "action_type VARCHAR(20) NOT NULL COMMENT 'block=硬拦截 warn=软警告 space=间隔', " +
  11988. "interval_minutes INT DEFAULT NULL COMMENT '需间隔时长(分钟)', " +
  11989. "note VARCHAR(255) COMMENT '风险说明', " +
  11990. "UNIQUE KEY uk_drug_food (drug_class, drug_specifics, interacting_subst), " +
  11991. "INDEX idx_interacting (interacting_subst)" +
  11992. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药物-食物禁忌对照表'");
  11993. log.info("迁移368: 已创建drug_interactions表");
  11994. } catch (Exception e) {
  11995. log.warn("迁移368: 创建drug_interactions表失败: {}", e.getMessage());
  11996. }
  11997. }
  11998. // 迁移369: 创建 disease_intake_limits 表(疾病-饮食摄入阈值)
  11999. private void createDiseaseIntakeLimitsTable() {
  12000. try {
  12001. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS disease_intake_limits (" +
  12002. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  12003. "disease_type VARCHAR(50) NOT NULL COMMENT '疾病类型(慢性肾病/糖尿病/高血压/痛风/肝病/甲状腺疾病)', " +
  12004. "nutrient VARCHAR(20) NOT NULL COMMENT '校验营养素(protein/potassium/phosphorus/sodium/carb/glycemic/fructose/purine/alcohol/iodine)', " +
  12005. "unit VARCHAR(20) NOT NULL COMMENT '单位', " +
  12006. "threshold_max DECIMAL(8,2) DEFAULT NULL COMMENT '每日上限', " +
  12007. "threshold_min DECIMAL(8,2) DEFAULT NULL COMMENT '每日下限', " +
  12008. "block_foods VARCHAR(500) DEFAULT NULL COMMENT '硬拦截食材列表,逗号分隔(如杨桃)', " +
  12009. "stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)', " +
  12010. "INDEX idx_disease_nutrient (disease_type, nutrient)" +
  12011. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表'");
  12012. log.info("迁移369: 已创建disease_intake_limits表");
  12013. } catch (Exception e) {
  12014. log.warn("迁移369: 创建disease_intake_limits表失败: {}", e.getMessage());
  12015. }
  12016. }
  12017. // 迁移370: 初始化 drug_interactions 种子数据(高危组合,幂等)
  12018. private void seedDrugInteractionsData() {
  12019. String[][] seed = {
  12020. {"CCB", "硝苯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"},
  12021. {"CCB", "氨氯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"},
  12022. {"他汀类", "辛伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"},
  12023. {"他汀类", "阿托伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"},
  12024. {"抗凝药", "华法林", "维生素K", "2", "warn", "0", "维K拮抗药效,摄入量保持稳定,调整饮食结构前先与医生沟通"},
  12025. {"抗凝药", "华法林", "大蒜", "2", "warn", "0", "大蒜可能增强抗凝作用,增加出血风险"},
  12026. {"抗生素", "四环素", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
  12027. {"抗生素", "喹诺酮类", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
  12028. {"抗生素", "喹诺酮类", "钙剂", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
  12029. {"铁剂", "硫酸亚铁", "茶", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"},
  12030. {"铁剂", "硫酸亚铁", "咖啡", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"},
  12031. {"铁剂", "硫酸亚铁", "钙剂", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"},
  12032. {"铁剂", "硫酸亚铁", "牛奶", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"},
  12033. {"甲状腺药", "左甲状腺素", "豆浆", "3", "space", "240", "影响吸收,需间隔4小时"},
  12034. {"甲状腺药", "左甲状腺素", "高纤维食物", "3", "space", "240", "影响吸收,需间隔4小时"},
  12035. {"甲状腺药", "左甲状腺素", "钙剂", "3", "space", "240", "影响吸收,需间隔4小时"},
  12036. {"保钾利尿剂", "螺内酯", "高钾食物", "2", "warn", "0", "高钾血症风险,避免香蕉、橙汁、低钠盐大量集中摄入"},
  12037. {"MAOI", "苯乙肼", "发酵食品", "2", "warn", "0", "酪胺反应可致血压骤升,避免奶酪/豆豉/腌制品/陈年食品"},
  12038. {"头孢类", "头孢类", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"},
  12039. {"甲硝唑", "甲硝唑", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"},
  12040. {"降糖药", "磺脲类", "酒精", "1", "block", "0", "低血糖风险,避免空腹饮酒"}
  12041. };
  12042. for (String[] row : seed) {
  12043. try {
  12044. Integer cnt = jdbcTemplate.queryForObject(
  12045. "SELECT COUNT(*) FROM drug_interactions WHERE drug_class=? AND drug_specifics=? AND interacting_subst=?",
  12046. Integer.class, row[0], row[1], row[2]);
  12047. if (cnt != null && cnt > 0) continue;
  12048. jdbcTemplate.update(
  12049. "INSERT INTO drug_interactions (drug_class, drug_specifics, interacting_subst, risk_level, action_type, interval_minutes, note) VALUES (?,?,?,?,?,?,?)",
  12050. row[0], row[1], row[2], Integer.valueOf(row[3]), row[4], Integer.valueOf(row[5]), row[6]);
  12051. } catch (Exception e) {
  12052. log.warn("迁移370: 插入drug_interactions种子失败: {}", e.getMessage());
  12053. }
  12054. }
  12055. log.info("迁移370: drug_interactions种子数据初始化完成");
  12056. }
  12057. // 迁移371: 初始化 disease_intake_limits 种子数据(幂等)
  12058. private void seedDiseaseIntakeLimitsData() {
  12059. String[][] seed = {
  12060. {"慢性肾病", "protein", "g/kg/day", "0.8", "0.6", "杨桃", "1"},
  12061. {"慢性肾病", "potassium", "mmol/L/day", "5.0", "3.5", "杨桃,香蕉,橙子,土豆,菠菜,菌菇", "1"},
  12062. {"慢性肾病", "phosphorus", "mg/day", "800", "600", "杨桃,奶制品,坚果,加工食品", "1"},
  12063. {"糖尿病", "carb", "g/meal", "75", "30", "", "0"},
  12064. {"糖尿病", "fructose", "g/day", "50", "0", "含糖饮料,果汁,蜂蜜,甜点", "0"},
  12065. {"高血压", "sodium", "g/day", "5", "2", "腌菜,酱菜,酱油,加工肉制品,挂面,咸味零食", "0"},
  12066. {"痛风", "purine", "mg/day", "300", "150", "动物内脏,部分海鲜,浓肉汤", "0"},
  12067. {"痛风", "fructose", "g/day", "50", "0", "含糖饮料,蜂蜜", "0"},
  12068. {"肝病", "protein", "g/kg/day", "1.0", "0.5", "", "1"},
  12069. {"甲状腺疾病", "iodine", "ug/day", "300", "150", "海带,紫菜", "0"}
  12070. };
  12071. for (String[] row : seed) {
  12072. try {
  12073. Integer cnt = jdbcTemplate.queryForObject(
  12074. "SELECT COUNT(*) FROM disease_intake_limits WHERE disease_type=? AND nutrient=?",
  12075. Integer.class, row[0], row[1]);
  12076. if (cnt != null && cnt > 0) continue;
  12077. jdbcTemplate.update(
  12078. "INSERT INTO disease_intake_limits (disease_type, nutrient, unit, threshold_max, threshold_min, block_foods, stage_dependent) VALUES (?,?,?,?,?,?,?)",
  12079. row[0], row[1], row[2], row[3], row[4], row[5], Integer.valueOf(row[6]));
  12080. } catch (Exception e) {
  12081. log.warn("迁移371: 插入disease_intake_limits种子失败: {}", e.getMessage());
  12082. }
  12083. }
  12084. log.info("迁移371: disease_intake_limits种子数据初始化完成");
  12085. }
  12086. // 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
  12087. private void createProcurementTaskTable() {
  12088. try {
  12089. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS procurement_tasks (" +
  12090. "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
  12091. "task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识', " +
  12092. "user_id BIGINT NOT NULL COMMENT '所属用户ID', " +
  12093. "family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID', " +
  12094. "combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low', " +
  12095. "original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)', " +
  12096. "selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)', " +
  12097. "custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)', " +
  12098. "custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)', " +
  12099. "status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED', " +
  12100. "create_time DATETIME NOT NULL COMMENT '创建时间', " +
  12101. "complete_time DATETIME DEFAULT NULL COMMENT '完成时间', " +
  12102. "message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒', " +
  12103. "execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID', " +
  12104. "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
  12105. "UNIQUE KEY uk_task_id (task_id), " +
  12106. "INDEX idx_user_id (user_id), " +
  12107. "INDEX idx_family_member_id (family_member_id), " +
  12108. "INDEX idx_status (status), " +
  12109. "INDEX idx_create_time (create_time)" +
  12110. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)'");
  12111. log.info("已创建procurement_tasks表");
  12112. } catch (Exception e) {
  12113. log.warn("创建procurement_tasks表失败: {}", e.getMessage());
  12114. }
  12115. }
  12116. // 迁移364: health_report_drafts 添加解析进度/重试字段(INFORMATION_SCHEMA 检查后再 ALTER,幂等)
  12117. private void addDraftParseProgressColumns() {
  12118. if (!columnExists("health_report_drafts", "parse_attempt")) {
  12119. jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_attempt INT DEFAULT 0 COMMENT '已知类型解析重试次数'");
  12120. log.info("已添加 parse_attempt 列到 health_report_drafts 表");
  12121. }
  12122. if (!columnExists("health_report_drafts", "next_retry_at")) {
  12123. jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN next_retry_at DATETIME NULL COMMENT '下次自动重试时间(指数退避,上限5分钟)'");
  12124. log.info("已添加 next_retry_at 列到 health_report_drafts 表");
  12125. }
  12126. if (!columnExists("health_report_drafts", "parse_started_at")) {
  12127. jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_started_at DATETIME NULL COMMENT '最近一次解析开始时间'");
  12128. log.info("已添加 parse_started_at 列到 health_report_drafts 表");
  12129. }
  12130. if (!columnExists("health_report_drafts", "parse_finished_at")) {
  12131. jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_finished_at DATETIME NULL COMMENT '最近一次解析结束时间(成功/失败)'");
  12132. log.info("已添加 parse_finished_at 列到 health_report_drafts 表");
  12133. }
  12134. }
  12135. // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表)
  12136. /**
  12137. * 迁移378: 创建 member_indicator_current 表
  12138. * 成员维度「当前指标 + 相对上次变化」:
  12139. * - 报告确认入库后,取该成员最新一份报告 vs 次新报告的同名指标,计算 delta
  12140. * - 方案生成(LangGraph)与前端指标卡片可直接读取,无需每次跨表 diff
  12141. * 幂等:CREATE TABLE IF NOT EXISTS
  12142. */
  12143. private void addMemberIndicatorCurrentTable() {
  12144. try {
  12145. jdbcTemplate.execute(
  12146. "CREATE TABLE IF NOT EXISTS `member_indicator_current` (" +
  12147. "`id` bigint NOT NULL AUTO_INCREMENT," +
  12148. "`member_id` bigint NOT NULL COMMENT '家庭成员ID(family_members.id)'," +
  12149. "`definition_id` bigint NOT NULL COMMENT '指标定义ID(indicator_definitions.id)'," +
  12150. "`domain` varchar(50) NOT NULL DEFAULT 'physical' COMMENT '指标域(body/mind/physical/wisdom)'," +
  12151. "`category` varchar(50) DEFAULT NULL COMMENT '指标分类'," +
  12152. "`name` varchar(100) NOT NULL COMMENT '指标名称'," +
  12153. "`value` varchar(200) DEFAULT NULL COMMENT '最新值'," +
  12154. "`numeric_value` decimal(12,4) DEFAULT NULL COMMENT '最新数值(可计算)'," +
  12155. "`previous_value` varchar(200) DEFAULT NULL COMMENT '上一份报告的同类指标值'," +
  12156. "`previous_numeric` decimal(12,4) DEFAULT NULL COMMENT '上一份报告数值'," +
  12157. "`delta` decimal(12,4) DEFAULT NULL COMMENT 'numeric_value - previous_numeric'," +
  12158. "`trend` varchar(16) DEFAULT NULL COMMENT '变化方向: up/down/flat/new/na'," +
  12159. "`latest_report_id` bigint DEFAULT NULL COMMENT '最新来源报告ID'," +
  12160. "`previous_report_id` bigint DEFAULT NULL COMMENT '上次来源报告ID'," +
  12161. "`report_date` date DEFAULT NULL COMMENT '最新报告日期'," +
  12162. "`recorded_at` datetime DEFAULT CURRENT_TIMESTAMP," +
  12163. "`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  12164. "PRIMARY KEY (`id`)," +
  12165. "UNIQUE KEY `uk_member_definition` (`member_id`, `definition_id`)," +
  12166. "KEY `idx_member_domain` (`member_id`, `domain`)," +
  12167. "KEY `idx_report` (`latest_report_id`)" +
  12168. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员指标当前值+相对上次变化'");
  12169. log.info("迁移378: member_indicator_current 表就绪");
  12170. } catch (Exception e) {
  12171. log.warn("迁移378: 创建 member_indicator_current 表失败或已存在: {}", e.getMessage());
  12172. }
  12173. }
  12174. private void addUserContentWeightsTable() {
  12175. try {
  12176. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_content_weights (" +
  12177. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  12178. "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," +
  12179. "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
  12180. "content_type VARCHAR(16) NOT NULL COMMENT '内容类型: product/activity/article'," +
  12181. "item_id BIGINT NOT NULL COMMENT '内容ID(商品ID/活动ID/文章ID)'," +
  12182. "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth'," +
  12183. "weight DECIMAL(5,2) NOT NULL DEFAULT 1.00 COMMENT '权重值 0.00-10.00,越大越优先'," +
  12184. "reason VARCHAR(200) DEFAULT NULL COMMENT '权重来源: user_set/auto_click/auto_purchase/manual/auto_profile'," +
  12185. "created_by BIGINT DEFAULT NULL COMMENT '创建人ID(manual场景记录运营人员)'," +
  12186. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12187. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12188. "INDEX idx_user_content (user_id, content_type, item_id)," +
  12189. "INDEX idx_family_dimension (family_id, dimension_code)," +
  12190. "UNIQUE KEY uk_user_item (user_id, content_type, item_id)" +
  12191. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表'");
  12192. log.info("迁移374: 已创建/确认 user_content_weights 表");
  12193. } catch (Exception e) {
  12194. log.warn("迁移374: 创建 user_content_weights 表失败: {}", e.getMessage());
  12195. }
  12196. }
  12197. // 迁移375: 修复 article_quiz_records 表结构(旧表由迁移299创建时已存在导致缺列)
  12198. private void fixArticleQuizRecordsTable() {
  12199. try {
  12200. if (!columnExists("article_quiz_records", "member_id")) {
  12201. jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(作答人)'");
  12202. log.info("迁移375: 已添加 member_id 列到 article_quiz_records");
  12203. }
  12204. if (!columnExists("article_quiz_records", "total_questions")) {
  12205. jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN total_questions INT DEFAULT 0 COMMENT '题目总数'");
  12206. log.info("迁移375: 已添加 total_questions 列到 article_quiz_records");
  12207. }
  12208. if (!columnExists("article_quiz_records", "cf_earned")) {
  12209. jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN cf_earned INT DEFAULT 0 COMMENT '发放的CF值'");
  12210. log.info("迁移375: 已添加 cf_earned 列到 article_quiz_records");
  12211. }
  12212. if (!columnExists("article_quiz_records", "energy_earned")) {
  12213. jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN energy_earned INT DEFAULT 0 COMMENT '发放的能量值'");
  12214. log.info("迁移375: 已添加 energy_earned 列到 article_quiz_records");
  12215. }
  12216. // questions/answers 旧表为 VARCHAR(255),实体存 JSON 快照会超长,与 schema.sql 对齐为 TEXT
  12217. if (!columnTypeStartsWith("article_quiz_records", "questions", "text")) {
  12218. jdbcTemplate.execute("ALTER TABLE article_quiz_records MODIFY COLUMN questions TEXT COMMENT '题目快照JSON(含正确答案与维度)'");
  12219. log.info("迁移375: questions 列已改为 TEXT");
  12220. }
  12221. if (!columnTypeStartsWith("article_quiz_records", "answers", "text")) {
  12222. jdbcTemplate.execute("ALTER TABLE article_quiz_records MODIFY COLUMN answers TEXT COMMENT '用户作答JSON(含每题所选选项),作答前为NULL'");
  12223. log.info("迁移375: answers 列已改为 TEXT");
  12224. }
  12225. // 补充索引(幂等;重复创建会报 Duplicate key name,捕获忽略)
  12226. try {
  12227. jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD INDEX idx_member (member_id)");
  12228. } catch (Exception ignored) {
  12229. }
  12230. log.info("迁移375: article_quiz_records 表结构修复完成");
  12231. } catch (Exception e) {
  12232. log.warn("迁移375: 修复 article_quiz_records 表结构失败: {}", e.getMessage());
  12233. }
  12234. }
  12235. private boolean columnTypeStartsWith(String table, String column, String typePrefix) {
  12236. try {
  12237. String t = jdbcTemplate.queryForObject(
  12238. "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
  12239. String.class, table, column);
  12240. return t != null && t.toLowerCase().startsWith(typePrefix);
  12241. } catch (Exception e) {
  12242. return false;
  12243. }
  12244. }
  12245. // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签)
  12246. private void addNutritionProductEfficacyTags() {
  12247. if (!columnExists("nutrition_product", "efficacy_tags")) {
  12248. jdbcTemplate.execute("ALTER TABLE nutrition_product ADD COLUMN efficacy_tags TEXT COMMENT '成分功效标签JSON数组(如 [\"补钙\",\"补锌\",\"益生菌\",\"维生素D\"])'");
  12249. log.info("已添加 efficacy_tags 列到 nutrition_product 表");
  12250. }
  12251. }
  12252. // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
  12253. private void addProductTagsColumn() {
  12254. ensureColumn("products", "tags", "VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐'");
  12255. log.info("迁移373: products.tags 已确认存在");
  12256. }
  12257. // 迁移379: 创建 report_member_permissions 表(报告成员级查看权限)
  12258. private void addReportMemberPermissionsTable() {
  12259. try {
  12260. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_member_permissions (" +
  12261. "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID'," +
  12262. "family_id BIGINT NOT NULL COMMENT '家庭ID(隔离作用域)'," +
  12263. "authorizer_user_id BIGINT NOT NULL COMMENT '授权人(家庭管理员)的用户ID'," +
  12264. "target_member_id BIGINT NOT NULL COMMENT '被授权查看的成员ID(family_members.id)'," +
  12265. "grantee_user_id BIGINT NOT NULL COMMENT '被授权用户的ID(能看报告的人)'," +
  12266. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间'," +
  12267. "PRIMARY KEY (id)," +
  12268. "UNIQUE KEY uk_perm (family_id, target_member_id, grantee_user_id)," +
  12269. "KEY idx_target_member (target_member_id)," +
  12270. "KEY idx_grantee_user (grantee_user_id)," +
  12271. "KEY idx_family_id (family_id)" +
  12272. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='报告成员级查看权限(管理员授权某用户查看某成员的全部报告)'");
  12273. log.info("迁移379: report_member_permissions 表就绪");
  12274. } catch (Exception e) {
  12275. log.warn("迁移379: 创建 report_member_permissions 表失败或已存在: {}", e.getMessage());
  12276. }
  12277. }
  12278. // 迁移380: 修正 FAMILY 会员虚拟道具映射为 member1980(幂等;仅纠正历史错误值 member1314,不覆盖后台后续配置)
  12279. private void fixFamilyMembershipVirtualGoodsMapping() {
  12280. try {
  12281. int updated = jdbcTemplate.update(
  12282. "UPDATE virtual_goods_config SET product_id = 'member1980' " +
  12283. "WHERE goods_type = 'MEMBERSHIP' AND biz_key = 'FAMILY' AND product_id = 'member1314'");
  12284. if (updated > 0) {
  12285. log.info("迁移380: FAMILY 会员虚拟道具映射已修正为 member1980,受影响 {} 行", updated);
  12286. }
  12287. } catch (Exception e) {
  12288. log.warn("迁移380: 修正 FAMILY 会员道具映射失败: {}", e.getMessage());
  12289. }
  12290. }
  12291. // 迁移381: 创建活动海报模板表 + 初始化默认模板(Web 管理端定义,小程序端海报模板选择)
  12292. private void createActivityPosterTemplatesTable() {
  12293. try {
  12294. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_poster_templates (" +
  12295. "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
  12296. "name VARCHAR(50) NOT NULL COMMENT '模板名称(管理端展示,如 暖橙渐变)'," +
  12297. "template_key VARCHAR(30) NOT NULL COMMENT '模板类型: gradient/minimal/card 小程序绘制引擎'," +
  12298. "main_color VARCHAR(20) DEFAULT '#F97316' COMMENT '主色(渐变起点/卡片顶栏)'," +
  12299. "dark_color VARCHAR(20) DEFAULT '#EA580C' COMMENT '深色(渐变终点/深色模板背景)'," +
  12300. "light_color VARCHAR(20) DEFAULT '#FFF7ED' COMMENT '浅色(内容卡背景)'," +
  12301. "bg_colors VARCHAR(500) DEFAULT '[\"#F97316\"]' COMMENT '可选背景色板 JSON 数组'," +
  12302. "qr_size INT DEFAULT 100 COMMENT '二维码尺寸(rpx)'," +
  12303. "sort_order INT DEFAULT 0 COMMENT '排序(越小越前)'," +
  12304. "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
  12305. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12306. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12307. "UNIQUE KEY uk_name (name)" +
  12308. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动海报模板(Web管理端定义)'");
  12309. log.info("迁移381: 已创建/确认 activity_poster_templates 表");
  12310. } catch (Exception e) {
  12311. log.warn("迁移381: 创建 activity_poster_templates 表失败: {}", e.getMessage());
  12312. }
  12313. // 初始化默认模板(幂等:按名称 INSERT IGNORE,重复执行不会产生重复行)
  12314. try {
  12315. jdbcTemplate.execute("INSERT IGNORE INTO activity_poster_templates (name, template_key, main_color, dark_color, light_color, bg_colors, qr_size, sort_order, status) VALUES " +
  12316. "('暖橙渐变', 'gradient', '#F97316', '#EA580C', '#FFF7ED', '[\"#F97316\",\"#EA580C\",\"#F59E0B\",\"#10B981\",\"#FF6B9D\",\"#6366F1\",\"#1E293B\",\"#FFFFFF\"]', 100, 0, 1), " +
  12317. "('深色极简', 'minimal', '#F97316', '#1E293B', '#F3F4F6', '[\"#1E293B\",\"#0F172A\",\"#111827\"]', 100, 1, 1), " +
  12318. "('白色卡片', 'card', '#F97316', '#EA580C', '#FFF7ED', '[\"#FFFFFF\",\"#FFF7ED\",\"#F8FAFC\"]', 100, 2, 1)");
  12319. log.info("迁移381: 默认海报模板种子数据已确认");
  12320. } catch (Exception e) {
  12321. log.warn("迁移381: 初始化默认海报模板失败: {}", e.getMessage());
  12322. }
  12323. }
  12324. // 迁移385: 活动报名添加驳回原因列(审核中心详情页驳回时写入)
  12325. private void addActivityRegistrationRejectReasonColumn() {
  12326. try {
  12327. if (!columnExists("activity_registrations", "reject_reason")) {
  12328. jdbcTemplate.execute("ALTER TABLE activity_registrations ADD COLUMN reject_reason VARCHAR(500) NULL COMMENT '驳回原因'");
  12329. log.info("迁移385: 已添加 activity_registrations.reject_reason 列");
  12330. }
  12331. } catch (Exception e) {
  12332. log.warn("迁移385: 添加活动报名驳回原因列失败: {}", e.getMessage());
  12333. }
  12334. }
  12335. // 迁移386: 历史 teacher_status='pending' 占位假数据清理,并把默认值改为 NULL
  12336. private void cleanPlannerPlaceholderData() {
  12337. try {
  12338. int cleaned = jdbcTemplate.update(
  12339. "UPDATE users SET teacher_status = NULL WHERE teacher_status = 'pending' AND (role IS NULL OR role <> 'teacher')");
  12340. jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN teacher_status VARCHAR(20) DEFAULT NULL COMMENT '成长规划师审核状态'");
  12341. log.info("迁移386: 已清理规划师占位数据 {} 行,teacher_status 默认值改为 NULL", cleaned);
  12342. } catch (Exception e) {
  12343. log.warn("迁移386: 清理规划师占位数据失败: {}", e.getMessage());
  12344. }
  12345. }
  12346. // 迁移387: 下线独立“服务角色申请审核”菜单,服务角色申请按角色分流合并进审核中心
  12347. private void hideServiceRoleApplicationMenu() {
  12348. try {
  12349. int updated = jdbcTemplate.update(
  12350. "UPDATE sys_menu SET visible = 0 WHERE path = '/service-role-applications'");
  12351. int removed = jdbcTemplate.update(
  12352. "DELETE FROM sys_role_menu WHERE menu_id IN (SELECT id FROM sys_menu WHERE path = '/service-role-applications')");
  12353. log.info("迁移387: 服务角色申请菜单已下线(菜单 {} 行,授权 {} 行)", updated, removed);
  12354. } catch (Exception e) {
  12355. log.warn("迁移387: 下线服务角色申请菜单失败: {}", e.getMessage());
  12356. }
  12357. }
  12358. // 迁移394: 创建 planner_cert_categories 表(学习种类/认证类别,管理端可配置)+ 首批种子数据
  12359. private void createPlannerCertCategoriesTable() {
  12360. try {
  12361. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_cert_categories (" +
  12362. "id INT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12363. "code VARCHAR(32) NOT NULL COMMENT '类别编码: planner/nutritionist/assessor...'," +
  12364. "name VARCHAR(64) NOT NULL COMMENT '类别名称'," +
  12365. "description VARCHAR(255) DEFAULT '' COMMENT '类别说明'," +
  12366. "required_hours DECIMAL(6,2) NOT NULL DEFAULT 0 COMMENT '该类别要求总课时(线上+线下)'," +
  12367. "is_base TINYINT(1) DEFAULT 0 COMMENT '1=基础类别(获得即 Guide.status=active)'," +
  12368. "order_index INT DEFAULT 0 COMMENT '排序(越小越前)'," +
  12369. "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
  12370. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12371. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12372. "PRIMARY KEY (id)," +
  12373. "UNIQUE KEY uk_code (code)" +
  12374. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师学习种类/认证类别'");
  12375. log.info("迁移394: 已创建/确认 planner_cert_categories 表");
  12376. } catch (Exception e) {
  12377. log.warn("迁移394: 创建 planner_cert_categories 表失败: {}", e.getMessage());
  12378. }
  12379. // 首批种子类别(幂等)
  12380. try {
  12381. jdbcTemplate.execute("INSERT IGNORE INTO planner_cert_categories (code, name, description, required_hours, is_base, order_index, status) VALUES " +
  12382. "('planner', '成长规划师', '规划师基础身份认证,默认必学必考;获得后即可接基础服务', 10, 1, 0, 1), " +
  12383. "('nutritionist', '营养师', '营养方案/饮食建议类服务前置认证', 8, 0, 1, 1), " +
  12384. "('assessor', '测评师', '测评录入/解读类服务前置认证', 8, 0, 2, 1)");
  12385. log.info("迁移394: 首批学习种类种子数据已确认");
  12386. } catch (Exception e) {
  12387. log.warn("迁移394: 初始化首批学习种类种子数据失败: {}", e.getMessage());
  12388. }
  12389. }
  12390. // 迁移395: 创建 planner_courses 表(线上课程目录,映射小鹅通课程/专栏)
  12391. private void createPlannerCoursesTable() {
  12392. try {
  12393. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_courses (" +
  12394. "id INT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12395. "category_id INT NOT NULL COMMENT '所属学习种类(planner_cert_categories.id)'," +
  12396. "xiaoe_resource_id VARCHAR(64) DEFAULT '' COMMENT '小鹅通课程/商品ID'," +
  12397. "title VARCHAR(120) NOT NULL COMMENT '课程标题'," +
  12398. "required_hours DECIMAL(6,2) NOT NULL DEFAULT 0 COMMENT '该课程要求课时'," +
  12399. "order_index INT DEFAULT 0 COMMENT '排序'," +
  12400. "is_required TINYINT(1) DEFAULT 1 COMMENT '是否必修'," +
  12401. "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
  12402. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12403. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12404. "PRIMARY KEY (id)," +
  12405. "KEY idx_category (category_id)," +
  12406. "KEY idx_status (status)" +
  12407. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师线上课程目录(小鹅通)'");
  12408. log.info("迁移395: 已创建/确认 planner_courses 表");
  12409. } catch (Exception e) {
  12410. log.warn("迁移395: 创建 planner_courses 表失败: {}", e.getMessage());
  12411. }
  12412. }
  12413. // 迁移396: 创建 planner_course_progress 表(每用户每线上课程学习进度,小鹅通回传/手动同步写入)
  12414. private void createPlannerCourseProgressTable() {
  12415. try {
  12416. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_course_progress (" +
  12417. "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12418. "user_id BIGINT NOT NULL COMMENT '规划师用户ID'," +
  12419. "course_id INT NOT NULL COMMENT 'planner_courses.id'," +
  12420. "category_id INT NOT NULL COMMENT '所属学习种类'," +
  12421. "xiaoe_resource_id VARCHAR(64) DEFAULT '' COMMENT '小鹅通资源ID'," +
  12422. "progress_percent INT DEFAULT 0 COMMENT '学习进度百分比'," +
  12423. "completed_hours DECIMAL(6,2) DEFAULT 0 COMMENT '已完成课时'," +
  12424. "completed TINYINT(1) DEFAULT 0 COMMENT '是否完成'," +
  12425. "last_synced_at DATETIME DEFAULT NULL COMMENT '最近同步时间'," +
  12426. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12427. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12428. "PRIMARY KEY (id)," +
  12429. "UNIQUE KEY uk_user_course (user_id, course_id)," +
  12430. "KEY idx_user (user_id)" +
  12431. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师线上课程学习进度'");
  12432. log.info("迁移396: 已创建/确认 planner_course_progress 表");
  12433. } catch (Exception e) {
  12434. log.warn("迁移396: 创建 planner_course_progress 表失败: {}", e.getMessage());
  12435. }
  12436. }
  12437. // 迁移397: 创建 planner_offline_course_map 表(线下培训活动↔认证类别桥接,复用 activities 模块)
  12438. private void createPlannerOfflineCourseMapTable() {
  12439. try {
  12440. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_offline_course_map (" +
  12441. "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12442. "activity_id BIGINT NOT NULL COMMENT '关联 activities.id(线下培训活动)'," +
  12443. "category_id INT NOT NULL COMMENT '关联 planner_cert_categories.id'," +
  12444. "duration_hours DECIMAL(6,2) DEFAULT 0 COMMENT '该活动签到后计入的课时'," +
  12445. "is_required TINYINT(1) DEFAULT 0 COMMENT '是否必修环节'," +
  12446. "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
  12447. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12448. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12449. "PRIMARY KEY (id)," +
  12450. "UNIQUE KEY uk_activity (activity_id)," +
  12451. "KEY idx_category (category_id)" +
  12452. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='线下培训活动↔认证类别映射(桥接)'");
  12453. log.info("迁移397: 已创建/确认 planner_offline_course_map 表");
  12454. } catch (Exception e) {
  12455. log.warn("迁移397: 创建 planner_offline_course_map 表失败: {}", e.getMessage());
  12456. }
  12457. }
  12458. // 迁移398: 创建 activity_checkins 表(活动签到记录,供培训课时桥接;修复"签到审核形同虚设"旧患)
  12459. private void createActivityCheckinsTable() {
  12460. try {
  12461. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_checkins (" +
  12462. "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12463. "activity_id BIGINT NOT NULL COMMENT '活动ID(activities.id)'," +
  12464. "user_id BIGINT NOT NULL COMMENT '签到人用户ID(规划师=userId)'," +
  12465. "registration_id BIGINT DEFAULT NULL COMMENT '关联报名记录(activity_registrations.id)'," +
  12466. "check_in_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '签到时间'," +
  12467. "check_in_type VARCHAR(16) DEFAULT 'qr' COMMENT 'qr扫码/admin手动/gps'," +
  12468. "check_in_by BIGINT DEFAULT NULL COMMENT 'admin手动补录人ID'," +
  12469. "review_status VARCHAR(16) DEFAULT 'approved' COMMENT 'approved/pending/rejected(对应原requireCheckinReview)'," +
  12470. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12471. "PRIMARY KEY (id)," +
  12472. "UNIQUE KEY uk_activity_user (activity_id, user_id)," +
  12473. "KEY idx_user (user_id)," +
  12474. "KEY idx_activity (activity_id)" +
  12475. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动签到记录(通用,供培训课时桥接)'");
  12476. log.info("迁移398: 已创建/确认 activity_checkins 表");
  12477. } catch (Exception e) {
  12478. log.warn("迁移398: 创建 activity_checkins 表失败: {}", e.getMessage());
  12479. }
  12480. }
  12481. // 迁移399: 创建 planner_certifications 表(规划师每类别培训认证主表,认证闸门依据)
  12482. private void createPlannerCertificationsTable() {
  12483. try {
  12484. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_certifications (" +
  12485. "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12486. "user_id BIGINT NOT NULL COMMENT '规划师用户ID'," +
  12487. "guide_id BIGINT DEFAULT NULL COMMENT '关联 guides.id'," +
  12488. "category_id INT NOT NULL COMMENT '所属学习种类(planner_cert_categories.id)'," +
  12489. "category_code VARCHAR(32) DEFAULT '' COMMENT '冗余类别编码便于查询'," +
  12490. "required_hours DECIMAL(6,2) DEFAULT 0 COMMENT '该类别总要求课时'," +
  12491. "completed_hours DECIMAL(6,2) DEFAULT 0 COMMENT '已完成课时(线上+线下汇总)'," +
  12492. "learning_status VARCHAR(20) DEFAULT 'pending_learning' COMMENT 'pending_learning/learning_completed'," +
  12493. "exam_status VARCHAR(20) DEFAULT 'none' COMMENT 'none/exam_failed/exam_passed'," +
  12494. "exam_score INT DEFAULT NULL COMMENT '考核分数'," +
  12495. "exam_attempts INT DEFAULT 0 COMMENT '考核尝试次数'," +
  12496. "cert_no VARCHAR(64) DEFAULT '' COMMENT '证书编号'," +
  12497. "certified_at DATETIME DEFAULT NULL COMMENT '认证通过时间'," +
  12498. "expire_at DATETIME DEFAULT NULL COMMENT '有效期(NULL=不过期)'," +
  12499. "status VARCHAR(20) DEFAULT 'pending_cert' COMMENT 'pending_cert/learning_completed/certified/suspended'," +
  12500. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12501. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12502. "PRIMARY KEY (id)," +
  12503. "UNIQUE KEY uk_user_category (user_id, category_id)," +
  12504. "KEY idx_status (status)," +
  12505. "KEY idx_user (user_id)" +
  12506. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师每类别培训认证主表'");
  12507. log.info("迁移399: 已创建/确认 planner_certifications 表");
  12508. } catch (Exception e) {
  12509. log.warn("迁移399: 创建 planner_certifications 表失败: {}", e.getMessage());
  12510. }
  12511. }
  12512. // 迁移400: 创建考核三表(试卷/题库/作答记录,考核本系统自建)
  12513. private void createPlannerExamTables() {
  12514. try {
  12515. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_exam_papers (" +
  12516. "id INT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12517. "category_id INT NOT NULL COMMENT '所属学习种类'," +
  12518. "title VARCHAR(120) NOT NULL COMMENT '试卷标题'," +
  12519. "pass_score INT NOT NULL DEFAULT 60 COMMENT '及格分'," +
  12520. "duration_minutes INT DEFAULT 30 COMMENT '限时(分钟)'," +
  12521. "question_count INT DEFAULT 0 COMMENT '题目数'," +
  12522. "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
  12523. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
  12524. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
  12525. "PRIMARY KEY (id)," +
  12526. "KEY idx_category (category_id)" +
  12527. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师考核试卷'");
  12528. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_exam_questions (" +
  12529. "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12530. "paper_id INT NOT NULL COMMENT '试卷ID(planner_exam_papers.id)'," +
  12531. "question TEXT NOT NULL COMMENT '题干'," +
  12532. "options TEXT COMMENT '选项JSON数组'," +
  12533. "answer VARCHAR(10) COMMENT '正确选项'," +
  12534. "score INT DEFAULT 1 COMMENT '分值'," +
  12535. "order_index INT DEFAULT 0 COMMENT '排序'," +
  12536. "PRIMARY KEY (id)," +
  12537. "KEY idx_paper (paper_id)" +
  12538. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考核题库'");
  12539. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS planner_exam_attempts (" +
  12540. "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键'," +
  12541. "user_id BIGINT NOT NULL COMMENT '规划师用户ID'," +
  12542. "category_id INT NOT NULL COMMENT '所属学习种类'," +
  12543. "paper_id INT NOT NULL COMMENT '试卷ID'," +
  12544. "score INT DEFAULT 0 COMMENT '得分'," +
  12545. "passed TINYINT(1) DEFAULT 0 COMMENT '是否通过'," +
  12546. "answers TEXT COMMENT 'JSON作答明细'," +
  12547. "started_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间'," +
  12548. "submitted_at DATETIME DEFAULT NULL COMMENT '提交时间'," +
  12549. "PRIMARY KEY (id)," +
  12550. "KEY idx_user (user_id)" +
  12551. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师考核作答记录'");
  12552. log.info("迁移400: 已创建/确认考核三表(试卷/题库/作答记录)");
  12553. } catch (Exception e) {
  12554. log.warn("迁移400: 创建考核三表失败: {}", e.getMessage());
  12555. }
  12556. }
  12557. // 迁移401: 存量规划师补登认证(D6:active guide 按已持有基础类别 planner certified 补登,其余类别 pending_learning)
  12558. private void backfillExistingPlannerCertifications() {
  12559. try {
  12560. int updated = jdbcTemplate.update(
  12561. "INSERT IGNORE INTO planner_certifications (user_id, guide_id, category_id, category_code, required_hours, completed_hours, learning_status, exam_status, exam_score, cert_no, certified_at, status) " +
  12562. "SELECT g.user_id, g.id, c.id, c.code, c.required_hours, c.required_hours, 'learning_completed', 'exam_passed', 100, CONCAT('CERT-', DATE_FORMAT(NOW(), '%Y%m%d'), '-', g.user_id), NOW(), 'certified' " +
  12563. "FROM guides g " +
  12564. "JOIN planner_cert_categories c ON c.code = 'planner' AND c.status = 1 " +
  12565. "WHERE g.status = 'active'");
  12566. log.info("迁移401: 存量规划师基础类别认证补登 {} 行", updated);
  12567. // 同步存量 active guide 状态为 active(保持不变),并确保新增认证不影响接单
  12568. } catch (Exception e) {
  12569. log.warn("迁移401: 存量规划师认证补登失败: {}", e.getMessage());
  12570. }
  12571. }
  12572. /**
  12573. * 迁移402: delivery_method COMMENT 增加 6=同城配送(履约多渠道,规格 2026-10-22-fulfillment-multi-mode-design)。
  12574. * 仅 COMMENT 变更,无数据变更,幂等可重复执行(MODIFY COLUMN 天然幂等 + 双层 try-catch)。
  12575. */
  12576. private void addDeliveryMethod6Comments() {
  12577. try {
  12578. jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN delivery_method TINYINT DEFAULT 1 "
  12579. + "COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送'");
  12580. log.info("迁移402: products.delivery_method COMMENT 已增加 6=同城配送");
  12581. } catch (Exception e) {
  12582. // COMMENT 变更失败(如列不存在)不影响启动
  12583. log.warn("迁移402: products.delivery_method COMMENT 更新失败: {}", e.getMessage());
  12584. }
  12585. try {
  12586. jdbcTemplate.execute("ALTER TABLE product_orders MODIFY COLUMN delivery_method INT DEFAULT NULL "
  12587. + "COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送'");
  12588. log.info("迁移402: product_orders.delivery_method COMMENT 已增加 6=同城配送");
  12589. } catch (Exception e) {
  12590. // COMMENT 变更失败(如列不存在)不影响启动
  12591. log.warn("迁移402: product_orders.delivery_method COMMENT 更新失败: {}", e.getMessage());
  12592. }
  12593. }
  12594. // 迁移403: withdrawal_requests 增加 reject_reason 列(审核中心统一详情:提现驳回原因口径统一)
  12595. private void addWithdrawalRejectReasonColumn() {
  12596. try {
  12597. if (!columnExists("withdrawal_requests", "reject_reason")) {
  12598. jdbcTemplate.execute("ALTER TABLE withdrawal_requests ADD COLUMN reject_reason VARCHAR(500) NULL COMMENT '驳回原因'");
  12599. log.info("迁移403: 已添加 withdrawal_requests.reject_reason 列");
  12600. }
  12601. } catch (Exception e) {
  12602. log.warn("迁移403: 添加 withdrawal_requests.reject_reason 列失败: {}", e.getMessage());
  12603. }
  12604. }
  12605. // 迁移404: articles.audio_url 由 varchar(500) 改为 MEDIUMTEXT
  12606. // 多段音频 JSON 数组(每段 OSS URL ~94 字符)在长文中可超 2900 字符,
  12607. // 超出 varchar(500) 导致 DataIntegrityViolationException("数据不完整或格式错误")
  12608. private void alterArticleAudioUrlToMediumtext() {
  12609. try {
  12610. jdbcTemplate.execute("ALTER TABLE articles MODIFY COLUMN audio_url MEDIUMTEXT NULL COMMENT '音频URL(单URL或JSON数组,多段)'");
  12611. log.info("迁移404: articles.audio_url 已改为 MEDIUMTEXT");
  12612. } catch (Exception e) {
  12613. log.warn("迁移404: articles.audio_url 修改失败: {}", e.getMessage());
  12614. }
  12615. }
  12616. // 迁移406: activity_poster_templates 增加 layout_config 列(活动海报模板元素位置/样式可配置;空值=小程序端内置默认布局)
  12617. private void addActivityPosterTemplateLayoutConfigColumn() {
  12618. try {
  12619. if (!columnExists("activity_poster_templates", "layout_config")) {
  12620. jdbcTemplate.execute("ALTER TABLE activity_poster_templates "
  12621. + "ADD COLUMN layout_config TEXT NULL COMMENT '布局配置JSON(元素位置/样式可配置,空=默认布局)'");
  12622. log.info("迁移406: 已添加 activity_poster_templates.layout_config 列");
  12623. }
  12624. } catch (Exception e) {
  12625. log.warn("迁移406: 添加 activity_poster_templates.layout_config 列失败: {}", e.getMessage());
  12626. }
  12627. }
  12628. // ==================================================================================
  12629. // F-1 数据层(CF 值体系统一):cf_balance / cf_balance_log / cf_earn_rule /
  12630. // cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit
  12631. // 口径:docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §五 + §3.6
  12632. // 仅建表 + 种子,不含任何发放/扣减业务逻辑(F-2 批)
  12633. // ==================================================================================
  12634. // 迁移407: 创建 cf_balance / cf_balance_log(个人 CF 值账本,成员维度)
  12635. private void createCfBalanceTables() {
  12636. try {
  12637. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_balance` ("
  12638. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  12639. + "`member_id` BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)', "
  12640. + "`user_id` BIGINT NULL COMMENT '关联登录用户ID(可为空,不能登录成员无账号)', "
  12641. + "`family_id` BIGINT NULL COMMENT '所属家庭ID(冗余,便于聚合)', "
  12642. + "`total_earned` INT NOT NULL DEFAULT 0 COMMENT '累计获得', "
  12643. + "`available` INT NOT NULL DEFAULT 0 COMMENT '可用', "
  12644. + "`frozen` INT NOT NULL DEFAULT 0 COMMENT '提现冻结', "
  12645. + "`withdrawn` INT NOT NULL DEFAULT 0 COMMENT '已提现', "
  12646. + "`exchanged` INT NOT NULL DEFAULT 0 COMMENT '已兑换消费', "
  12647. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  12648. + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  12649. + "UNIQUE KEY `uk_member` (`member_id`), "
  12650. + "KEY `idx_family` (`family_id`) "
  12651. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值余额(家庭成员维度)'");
  12652. log.info("迁移407: 已创建 cf_balance 表");
  12653. } catch (Exception e) {
  12654. log.warn("迁移407: 创建 cf_balance 表失败(可能已存在): {}", e.getMessage());
  12655. }
  12656. try {
  12657. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_balance_log` ("
  12658. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  12659. + "`member_id` BIGINT NOT NULL, "
  12660. + "`user_id` BIGINT NULL, "
  12661. + "`family_id` BIGINT NULL, "
  12662. + "`type` VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust/skip', "
  12663. + "`ref_type` VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)', "
  12664. + "`ref_id` BIGINT NULL, "
  12665. + "`amount` INT NOT NULL COMMENT '变动数量(正负)', "
  12666. + "`balance_after` INT NOT NULL COMMENT '变动后可用余额', "
  12667. + "`operator_user_id` BIGINT NULL COMMENT '操作人用户ID(管理员调整等人工操作留痕)', "
  12668. + "`remark` VARCHAR(255) NULL, "
  12669. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  12670. + "KEY `idx_member` (`member_id`), "
  12671. + "KEY `idx_family` (`family_id`), "
  12672. + "UNIQUE KEY `uk_ref_type` (`member_id`, `type`, `ref_type`, `ref_id`) "
  12673. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水'");
  12674. log.info("迁移407: 已创建 cf_balance_log 表");
  12675. } catch (Exception e) {
  12676. log.warn("迁移407: 创建 cf_balance_log 表失败(可能已存在): {}", e.getMessage());
  12677. }
  12678. }
  12679. // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history(CF 值获取规则 + 变更审计)
  12680. private void createCfEarnRuleTables() {
  12681. try {
  12682. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_rule` ("
  12683. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  12684. + "`code` VARCHAR(20) NOT NULL COMMENT '规则编码 F02~F24(F03 拆 F03a/F03b)', "
  12685. + "`name` VARCHAR(100) NOT NULL COMMENT '途径名称', "
  12686. + "`category` VARCHAR(20) NOT NULL COMMENT 'consume消费返佣/behavior成长行为/admin管理员', "
  12687. + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用(随时开关)', "
  12688. + "`rule_type` VARCHAR(20) NOT NULL COMMENT 'fixed固定/formula公式/per_item逐人发放', "
  12689. + "`amount` INT NULL COMMENT '固定数量(rule_type=fixed)', "
  12690. + "`formula` VARCHAR(500) NULL COMMENT '计算公式(rule_type=formula)', "
  12691. + "`params_json` TEXT NULL COMMENT '公式参数(bps/倍数/档位表等,JSON)', "
  12692. + "`daily_limit_per_member` INT NULL COMMENT '单成员每日上限,空=不限', "
  12693. + "`total_limit_per_member` INT NULL COMMENT '单成员累计上限,空=不限', "
  12694. + "`cooldown_seconds` INT NULL COMMENT '同一成员冷却秒数,空=不限', "
  12695. + "`dedup_ref_type` VARCHAR(50) NOT NULL COMMENT '幂等refType,写入cf_balance_log', "
  12696. + "`effective_from` DATETIME NULL COMMENT '生效起,空=立即', "
  12697. + "`effective_to` DATETIME NULL COMMENT '生效止,空=长期', "
  12698. + "`sort_order` INT NOT NULL DEFAULT 0, "
  12699. + "`remark` VARCHAR(255) NULL, "
  12700. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  12701. + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  12702. + "`updated_by` BIGINT NULL COMMENT '最后修改人', "
  12703. + "UNIQUE KEY `uk_code` (`code`) "
  12704. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值获取规则(运营可配置)'");
  12705. log.info("迁移408: 已创建 cf_earn_rule 表");
  12706. } catch (Exception e) {
  12707. log.warn("迁移408: 创建 cf_earn_rule 表失败(可能已存在): {}", e.getMessage());
  12708. }
  12709. try {
  12710. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_rule_history` ("
  12711. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  12712. + "`rule_code` VARCHAR(20) NOT NULL, "
  12713. + "`old_json` TEXT NULL COMMENT '变更前整行快照', "
  12714. + "`new_json` TEXT NULL COMMENT '变更后整行快照', "
  12715. + "`changed_by` BIGINT NOT NULL, "
  12716. + "`change_reason` VARCHAR(255) NULL, "
  12717. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  12718. + "KEY `idx_rule` (`rule_code`, `created_at`) "
  12719. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值规则变更历史'");
  12720. log.info("迁移408: 已创建 cf_earn_rule_history 表");
  12721. } catch (Exception e) {
  12722. log.warn("迁移408: 创建 cf_earn_rule_history 表失败(可能已存在): {}", e.getMessage());
  12723. }
  12724. }
  12725. /**
  12726. * 24 行规则种子(F02–F24,F03 拆 F03a/F03b),列序:
  12727. * code, name, category, ruleType, amount, formula, paramsJson, dailyLimit, totalLimit,
  12728. * cooldownSeconds, dedupRefType, sortOrder, remark
  12729. *
  12730. * 数值口径:§三 各条「参数来源」+ §九 修正后数值(F06=1990、F18 与 F02 同口径)。
  12731. * 注意:§三 公式写作 floor(实付(分)/100)×100,与 §九「19.9 元=1990 CF」不自洽;
  12732. * 按 §九 修正口径执行 —— 1 元=100 CF 即 实付(分)→CF 1:1,公式记作 floor(amt)。
  12733. */
  12734. private static final String[][] CF_EARN_RULE_SEEDS = {
  12735. {"F02", "消费返CF(所有订单)", "consume", "formula", "",
  12736. "floor(amt)",
  12737. "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"note\":\"1元=100CF,实付分→CF 1:1\"}",
  12738. "", "", "", "cf_consume_order", "10",
  12739. "所有消费订单(商品/套餐/测评/会员/订阅/调研)均返"},
  12740. {"F03a", "直接销售奖", "consume", "formula", "",
  12741. "floor(base_ppoints * rate_percent / 100)",
  12742. "{\"base\":\"team_annual_ppoints\",\"tier_table\":\"cf_direct_sales_tier\",\"window\":\"1y_rolling\",\"team_depth\":8,\"max_rate_percent\":70,\"downgrade\":true}",
  12743. "", "", "", "F03a", "20",
  12744. "基数=直接推荐人消费P点;档位按团队8层内年度累计P点(cf_direct_sales_tier);幂等ref_type与规则编码一致(F-4)"},
  12745. {"F03b", "间接销售奖", "consume", "formula", "",
  12746. "floor(base_ppoints * rate_percent / 100)",
  12747. "{\"base\":\"self_annual_ppoints\",\"tier_table\":\"cf_indirect_sales_tier\",\"window\":\"1y_rolling\",\"max_rate_percent\":20,\"downgrade\":true}",
  12748. "", "", "", "F03b", "30",
  12749. "基数=间接推荐人消费P点;档位按自身年度累计消费P点(cf_indirect_sales_tier);幂等ref_type与规则编码一致(F-4)"},
  12750. {"F04", "商品赠品CF", "consume", "per_item", "",
  12751. "",
  12752. "{\"source\":\"product_order_gift.cf_value\"}",
  12753. "", "", "", "product_order_gift", "40",
  12754. "赠品规则管理端配置"},
  12755. {"F05", "活动签到CF", "consume", "per_item", "",
  12756. "",
  12757. "{\"source\":\"activities.platform_points\"}",
  12758. "", "", "", "activity_checkin", "50",
  12759. "活动配置数量"},
  12760. {"F06", "菌群健康调研打卡返还", "consume", "formula", "",
  12761. "floor(amt)",
  12762. "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"fallback\":\"gut_health_project.cf_refund_amount\",\"example\":\"19.9元=1990CF\"}",
  12763. "7", "1", "", "gut_health_refund", "60",
  12764. "cf_refund_amount 为空时按项目价×100(19.9元=1990CF,§九修正值);一周项目只发一次"},
  12765. {"F07", "文章答题奖励", "behavior", "formula", "",
  12766. "score + (all_correct ? 3 : 0)",
  12767. "{\"bonus_all_correct\":3,\"dedup\":\"article\"}",
  12768. "5", "", "", "article_answer", "70",
  12769. "答对题数+全对3;日限5次(N9)"},
  12770. {"F08", "AI阅读答题奖励", "behavior", "fixed", "5",
  12771. "",
  12772. "{\"require_all_correct\":true}",
  12773. "3", "", "", "ai_reading_answer", "80",
  12774. "仅全部答对才发(原硬编码已外置)"},
  12775. {"F09", "完成测评建档", "behavior", "formula", "",
  12776. "first_report ? 50 : 10",
  12777. "{\"first_amount\":50,\"repeat_amount\":10,\"dedup_key\":\"report_hash\"}",
  12778. "3", "", "", "assessment_report", "90",
  12779. "第1份50/后续每份10,重复报告不计(N10)"},
  12780. {"F10", "家庭挑战完成", "behavior", "per_item", "",
  12781. "",
  12782. "{\"source\":\"family_challenge.reward_points\",\"distribution\":\"per_completed_member\"}",
  12783. "1", "1", "", "family_challenge", "100",
  12784. "按挑战配置数量(Q1 已定:每个完成成员各发一次)"},
  12785. {"F11", "邀请里程碑", "behavior", "per_item", "",
  12786. "",
  12787. "{\"source\":\"invite_milestone.reward_points\"}",
  12788. "", "", "", "invite_milestone", "110",
  12789. "按里程碑配置"},
  12790. {"F12", "成长任务", "behavior", "per_item", "",
  12791. "",
  12792. "{\"source\":\"growth_task.reward_points\"}",
  12793. "", "", "", "growth_task", "120",
  12794. "按任务模板配置"},
  12795. {"F13", "分享阅读奖励", "behavior", "fixed", "5",
  12796. "",
  12797. "{\"source\":\"cf.rate.share_read_reward_points\"}",
  12798. "5", "", "", "share_read", "130",
  12799. "默认5CF;日限5次"},
  12800. {"F14", "喝水打卡", "behavior", "fixed", "5",
  12801. "",
  12802. "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
  12803. "", "", "", "health_water", "140",
  12804. "5CF/次;日限按会员等级(N11)"},
  12805. {"F15", "饮食打卡", "behavior", "fixed", "5",
  12806. "",
  12807. "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
  12808. "", "", "", "health_meal", "150",
  12809. "5CF/次;日限按会员等级(N11)"},
  12810. {"F16", "每日健康打卡", "behavior", "fixed", "10",
  12811. "",
  12812. "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
  12813. "", "", "", "daily_health_checkin", "160",
  12814. "10CF/次;日限按会员等级(N11)"},
  12815. {"F17", "挑战赛子任务完成", "behavior", "per_item", "",
  12816. "",
  12817. "{\"source\":\"challenge_task.reward_cf\"}",
  12818. "", "", "", "challenge_sub_task", "170",
  12819. "按挑战赛任务配置"},
  12820. {"F18", "健康启航计划付费", "consume", "formula", "",
  12821. "floor(amt)",
  12822. "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"note\":\"与F02同口径\"}",
  12823. "1", "1", "", "health_start_plan", "180",
  12824. "实付分→CF 1:1(§九修正口径,原硬编码已外置)"},
  12825. {"F19", "管理员手动调整", "admin", "per_item", "",
  12826. "",
  12827. "{\"require_parent_password\":true,\"allow_negative\":true}",
  12828. "", "", "", "admin_adjust", "190",
  12829. "正负任意,需家长密码校验,必须留痕(Q9)"},
  12830. {"F20", "问题域全部任务完成", "behavior", "per_item", "",
  12831. "",
  12832. "{\"source\":\"problem_domain.reward_cf_value\"}",
  12833. "", "", "", "problem_domain", "200",
  12834. "按管理端配置"},
  12835. {"F21", "关卡通关奖励", "behavior", "per_item", "",
  12836. "",
  12837. "{\"source\":\"unlock_gate.reward_points\"}",
  12838. "", "", "", "unlock_gate", "210",
  12839. "按管理端配置"},
  12840. {"F22", "任务完成CF", "behavior", "per_item", "",
  12841. "",
  12842. "{\"source\":\"task.cf_amount\",\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
  12843. "", "", "", "task_complete", "220",
  12844. "保留个人发放,取消家庭池那条;日限按会员等级(N11)"},
  12845. {"F23", "成员间转让(收款方)", "behavior", "per_item", "",
  12846. "",
  12847. "{\"direction\":\"transfer_in\",\"pair_type\":\"transfer_out\"}",
  12848. "", "", "", "cf_transfer", "230",
  12849. "转入+X / 转出−X,同一事务"},
  12850. {"F24", "分享奖励(推荐注册)", "behavior", "fixed", "20",
  12851. "",
  12852. "{\"trigger\":\"referral_register\"}",
  12853. "10", "", "", "referral_register", "240",
  12854. "N8 新增:推荐他人注册即得,建议20CF/人,日限10次"}
  12855. };
  12856. /** 幂等写入 24 行规则种子(已存在则跳过,不覆盖运营改过的配置) */
  12857. private void seedCfEarnRules() {
  12858. int inserted = 0;
  12859. for (String[] r : CF_EARN_RULE_SEEDS) {
  12860. try {
  12861. Integer cnt = jdbcTemplate.queryForObject(
  12862. "SELECT COUNT(*) FROM cf_earn_rule WHERE code = ?", Integer.class, r[0]);
  12863. if (cnt != null && cnt > 0) {
  12864. continue;
  12865. }
  12866. jdbcTemplate.update("INSERT INTO cf_earn_rule (code, name, category, enabled, rule_type, amount, "
  12867. + "formula, params_json, daily_limit_per_member, total_limit_per_member, cooldown_seconds, "
  12868. + "dedup_ref_type, sort_order, remark, created_at, updated_at) "
  12869. + "VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())",
  12870. r[0], r[1], r[2], r[3], cfIntOrNull(r[4]), cfTextOrNull(r[5]), cfTextOrNull(r[6]),
  12871. cfIntOrNull(r[7]), cfIntOrNull(r[8]), cfIntOrNull(r[9]), r[10],
  12872. cfIntOrNull(r[11]) != null ? cfIntOrNull(r[11]) : 0, cfTextOrNull(r[12]));
  12873. inserted++;
  12874. } catch (Exception e) {
  12875. log.warn("迁移408: 写入规则种子 {} 失败: {}", r[0], e.getMessage());
  12876. }
  12877. }
  12878. if (inserted > 0) {
  12879. log.info("迁移408: cf_earn_rule 规则种子新增 {} 行(共 {} 条定义)", inserted, CF_EARN_RULE_SEEDS.length);
  12880. }
  12881. }
  12882. // 迁移409: 创建 cf_direct_sales_tier / cf_indirect_sales_tier(两级销售奖档位,数值由运营配置)
  12883. private void createCfSalesTierTables() {
  12884. try {
  12885. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_direct_sales_tier` ("
  12886. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  12887. + "`tier_name` VARCHAR(50) NOT NULL COMMENT '档位名称', "
  12888. + "`min_annual_ppoints` INT NOT NULL COMMENT '团队8层内年度累计P点下限', "
  12889. + "`rate_percent` INT NOT NULL COMMENT '分润比例(%)', "
  12890. + "`sort_order` INT NOT NULL DEFAULT 0, "
  12891. + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', "
  12892. + "`remark` VARCHAR(255) NULL, "
  12893. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  12894. + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  12895. + "UNIQUE KEY `uk_min` (`min_annual_ppoints`) "
  12896. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直接销售奖档位(按团队8层内年度P点累计)'");
  12897. log.info("迁移409: 已创建 cf_direct_sales_tier 表");
  12898. } catch (Exception e) {
  12899. log.warn("迁移409: 创建 cf_direct_sales_tier 表失败(可能已存在): {}", e.getMessage());
  12900. }
  12901. try {
  12902. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_indirect_sales_tier` ("
  12903. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  12904. + "`tier_name` VARCHAR(50) NOT NULL, "
  12905. + "`min_self_ppoints` INT NOT NULL COMMENT '自身年度累计消费P点下限', "
  12906. + "`rate_percent` INT NOT NULL COMMENT '分润比例(%)', "
  12907. + "`sort_order` INT NOT NULL DEFAULT 0, "
  12908. + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', "
  12909. + "`remark` VARCHAR(255) NULL, "
  12910. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  12911. + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  12912. + "UNIQUE KEY `uk_min` (`min_self_ppoints`) "
  12913. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='间接销售奖档位(按自身年度消费P点累计)'");
  12914. log.info("迁移409: 已创建 cf_indirect_sales_tier 表");
  12915. } catch (Exception e) {
  12916. log.warn("迁移409: 创建 cf_indirect_sales_tier 表失败(可能已存在): {}", e.getMessage());
  12917. }
  12918. }
  12919. // 迁移410: 创建 cf_earn_daily_limit(规则 × 会员等级 每日次数上限,N11)
  12920. private void createCfEarnDailyLimitTable() {
  12921. try {
  12922. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_daily_limit` ("
  12923. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  12924. + "`rule_code` VARCHAR(20) NOT NULL COMMENT '规则编码(cf_earn_rule.code)', "
  12925. + "`level_code` VARCHAR(32) NOT NULL COMMENT '会员等级编码 FREE/BASIC/FAMILY/LIFETIME/PREMIUM', "
  12926. + "`daily_limit` INT NOT NULL COMMENT '每日次数上限', "
  12927. + "`remark` VARCHAR(255) NULL, "
  12928. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  12929. + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  12930. + "UNIQUE KEY `uk_rule_level` (`rule_code`, `level_code`) "
  12931. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值规则×会员等级每日次数上限(N11)'");
  12932. log.info("迁移410: 已创建 cf_earn_daily_limit 表");
  12933. } catch (Exception e) {
  12934. log.warn("迁移410: 创建 cf_earn_daily_limit 表失败(可能已存在): {}", e.getMessage());
  12935. }
  12936. }
  12937. /** N11 会员等级日限:FREE=1(建议) / BASIC=3 / FAMILY=10 / LIFETIME=10 / PREMIUM=20,PROVIDER 待定不落行 */
  12938. private static final String[][] CF_DAILY_LIMIT_LEVELS = {
  12939. {"FREE", "1", "免费会员(建议值,Q11)"},
  12940. {"BASIC", "3", "9.9体验会员"},
  12941. {"FAMILY", "10", "家庭会员"},
  12942. {"LIFETIME", "10", "终身会员"},
  12943. {"PREMIUM", "20", "高级会员"}
  12944. };
  12945. /** 按会员等级限次的规则(任务/打卡类):F14 喝水 / F15 饮食 / F16 每日健康 / F22 任务完成 */
  12946. private static final String[] CF_DAILY_LIMIT_RULES = {"F14", "F15", "F16", "F22"};
  12947. private void seedCfEarnDailyLimits() {
  12948. int inserted = 0;
  12949. for (String ruleCode : CF_DAILY_LIMIT_RULES) {
  12950. for (String[] lv : CF_DAILY_LIMIT_LEVELS) {
  12951. try {
  12952. Integer cnt = jdbcTemplate.queryForObject(
  12953. "SELECT COUNT(*) FROM cf_earn_daily_limit WHERE rule_code = ? AND level_code = ?",
  12954. Integer.class, ruleCode, lv[0]);
  12955. if (cnt != null && cnt > 0) {
  12956. continue;
  12957. }
  12958. jdbcTemplate.update("INSERT INTO cf_earn_daily_limit (rule_code, level_code, daily_limit, remark, created_at, updated_at) "
  12959. + "VALUES (?, ?, ?, ?, NOW(), NOW())", ruleCode, lv[0], Integer.valueOf(lv[1]), lv[2]);
  12960. inserted++;
  12961. } catch (Exception e) {
  12962. log.warn("迁移410: 写入日限种子 {}/{} 失败: {}", ruleCode, lv[0], e.getMessage());
  12963. }
  12964. }
  12965. }
  12966. if (inserted > 0) {
  12967. log.info("迁移410: cf_earn_daily_limit 日限种子新增 {} 行", inserted);
  12968. }
  12969. }
  12970. // 迁移411: coupon / user_coupon 补 family_id(优惠券家庭化 N3)
  12971. private void addCouponFamilyIdColumns() {
  12972. if (!tableExists("coupon") || !tableExists("user_coupon")) {
  12973. log.warn("迁移411: coupon / user_coupon 表不存在,跳过 family_id 迁移");
  12974. return;
  12975. }
  12976. ensureColumn("coupon", "family_id", "BIGINT DEFAULT NULL COMMENT '模板块绑定家庭ID(NULL=全平台模板)'");
  12977. ensureColumn("user_coupon", "family_id", "BIGINT DEFAULT NULL COMMENT '券实例归属家庭ID(家庭内任意成员可用)'");
  12978. try {
  12979. if (!indexExists("user_coupon", "idx_family")) {
  12980. jdbcTemplate.execute("ALTER TABLE user_coupon ADD INDEX idx_family (family_id)");
  12981. log.info("迁移411: 已添加 user_coupon.idx_family 索引");
  12982. }
  12983. } catch (Exception e) {
  12984. log.warn("迁移411: 添加 user_coupon.idx_family 失败(可能已存在): {}", e.getMessage());
  12985. }
  12986. try {
  12987. if (!indexExists("coupon", "idx_family")) {
  12988. jdbcTemplate.execute("ALTER TABLE coupon ADD INDEX idx_family (family_id)");
  12989. log.info("迁移411: 已添加 coupon.idx_family 索引");
  12990. }
  12991. } catch (Exception e) {
  12992. log.warn("迁移411: 添加 coupon.idx_family 失败(可能已存在): {}", e.getMessage());
  12993. }
  12994. }
  12995. // 迁移412: 创建 service_config(L2 配置中心;W0.1)
  12996. private void createServiceConfigTable() {
  12997. try {
  12998. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `service_config` ("
  12999. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13000. + "`group_key` VARCHAR(64) NOT NULL DEFAULT 'general' COMMENT '配置分组(如 cf.rate / cf.commission)', "
  13001. + "`config_key` VARCHAR(128) NOT NULL COMMENT '配置键(如 cf.rate.points_multiplier)', "
  13002. + "`config_value` TEXT NULL COMMENT '配置值(统一字符串存储,按 value_type 解析)', "
  13003. + "`value_type` VARCHAR(16) NOT NULL DEFAULT 'string' COMMENT 'string/int/double/bool/json', "
  13004. + "`description` VARCHAR(255) NULL, "
  13005. + "`editable` TINYINT NOT NULL DEFAULT 1 COMMENT '1允许后台编辑 0系统托管', "
  13006. + "`updated_by` BIGINT NULL, "
  13007. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  13008. + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  13009. + "UNIQUE KEY `uk_config_key` (`config_key`), "
  13010. + "KEY `idx_group_key` (`group_key`) "
  13011. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务配置中心(L2,CF通用运营参数归口)'");
  13012. log.info("迁移412: 已创建 service_config 表");
  13013. } catch (Exception e) {
  13014. log.warn("迁移412: 创建 service_config 表失败(可能已存在): {}", e.getMessage());
  13015. }
  13016. }
  13017. /** W0.1 §3.6.6(2) 10 项 CF 通用运营参数 + cf.version 版本号 */
  13018. private void seedCfServiceConfig() {
  13019. ensureServiceConfig("cf.rate.points_multiplier", "1.0", "double",
  13020. "积分倍数(消费→CF 换算);原散落 products.points_multiplier / sys_config", 1);
  13021. ensureServiceConfig("cf.rate.repeat_rate_default", "1.0", "double",
  13022. "复购率默认系数(未匹配规则时不加权)", 1);
  13023. ensureServiceConfig("cf.commission.settle_days", "7", "int",
  13024. "佣金结算周期(天),财务口径常用", 1);
  13025. ensureServiceConfig("cf.invite.valid_days", "7", "int",
  13026. "邀请码有效期(天)", 1);
  13027. ensureServiceConfig("cf.price.default_factor", "1.0", "double",
  13028. "默认价格系数(未定价时原价)", 1);
  13029. ensureServiceConfig("cf.stock.deduction_mode", "immediate", "string",
  13030. "库存扣减策略 immediate/on_pay(Phase 4 定稿)", 1);
  13031. ensureServiceConfig("cf.rate.share_read_reward_points", "5", "int",
  13032. "分享阅读奖励 CF;原散落 sys_config.share_read_reward_points", 1);
  13033. ensureServiceConfig("cf.commission.service_rate", "1000", "int",
  13034. "佣金服务费率(bps);原散落 sys_config.commission_service_rate", 1);
  13035. ensureServiceConfig("cf.product.gut_health_price", "1990", "int",
  13036. "肠道健康项目定价(分);原散落 GutHealthProjectService.DEFAULT_PRICE / sys_config", 1);
  13037. ensureServiceConfig("cf.product.platform_points_share", "1000", "int",
  13038. "产品平台积分分成比例(bps);原散落 sys_config.product_platform_points_share", 1);
  13039. ensureServiceConfig("cf.version", "1", "int",
  13040. "CF 配置版本号(单调递增,前端据此局部刷新提示)", 0);
  13041. }
  13042. /**
  13043. * 迁移416:CF 账本流水表结构调整(F-2)
  13044. * <ul>
  13045. * <li>补 operator_user_id:管理员调整等人工操作留痕操作人;</li>
  13046. * <li>幂等键升级:旧 uk_ref(member_id,ref_type,ref_id) 会让同一业务对象的
  13047. * freeze→unfreeze/withdraw 互相冲突,升级为 uk_ref_type(member_id,type,ref_type,ref_id),
  13048. * earn 幂等口径不变(type='earn');</li>
  13049. * <li>种子:提现开关(默认关闭)+ 人民币→CF 汇率(1 元 = 100 CF,F02 口径)。</li>
  13050. * </ul>
  13051. * 幂等:列/索引/配置均先探测后变更,可重复执行。
  13052. */
  13053. private void migrateCfBalanceLogForLedger() {
  13054. if (!tableExists("cf_balance_log")) {
  13055. log.warn("迁移416: cf_balance_log 表不存在,跳过流水表结构调整");
  13056. return;
  13057. }
  13058. // 1) 操作人留痕列
  13059. ensureColumn("cf_balance_log", "operator_user_id",
  13060. "BIGINT NULL COMMENT '操作人用户ID(管理员调整等人工操作留痕)'");
  13061. // 2) 幂等键升级:先删旧唯一索引,再加新唯一索引
  13062. try {
  13063. if (indexExists("cf_balance_log", "uk_ref")) {
  13064. jdbcTemplate.execute("ALTER TABLE cf_balance_log DROP INDEX uk_ref");
  13065. log.info("迁移416: 已删除 cf_balance_log.uk_ref 旧唯一索引");
  13066. }
  13067. } catch (Exception e) {
  13068. log.warn("迁移416: 删除 cf_balance_log.uk_ref 失败: {}", e.getMessage());
  13069. }
  13070. try {
  13071. if (!indexExists("cf_balance_log", "uk_ref_type")) {
  13072. jdbcTemplate.execute("ALTER TABLE cf_balance_log "
  13073. + "ADD UNIQUE KEY uk_ref_type (member_id, type, ref_type, ref_id)");
  13074. log.info("迁移416: 已添加 cf_balance_log.uk_ref_type 唯一索引");
  13075. }
  13076. } catch (Exception e) {
  13077. log.warn("迁移416: 添加 cf_balance_log.uk_ref_type 失败: {}", e.getMessage());
  13078. }
  13079. // 3) CF 运营参数种子
  13080. ensureServiceConfig("cf.withdraw_enabled", "false", "bool",
  13081. "CF值提现开关(false=禁止提现;Q4 合规审查通过后再开启)", 1);
  13082. ensureServiceConfig("cf.rate.cf_per_yuan", "100", "int",
  13083. "人民币→CF值换算比例(1元=100CF,F02 消费返 CF 口径)", 1);
  13084. }
  13085. /**
  13086. * 迁移散落在 sys_config / products 的 CF 参数到 service_config:
  13087. * 优先取旧位置的实际值(存在则沿用),旧位置仅做标记清理,不删值(F-2/F-3 收口前保持兼容)。
  13088. * 幂等:新 key 已存在则跳过;旧位置标记带唯一前缀,重复执行不重复追加。
  13089. */
  13090. private void migrateScatteredCfConfigs() {
  13091. migrateSysConfigToServiceConfig("commission_service_rate", "cf.commission.service_rate", "1000");
  13092. migrateSysConfigToServiceConfig("product_platform_points_share", "cf.product.platform_points_share", "1000");
  13093. migrateSysConfigToServiceConfig("share_read_reward_points", "cf.rate.share_read_reward_points", "5");
  13094. migrateSysConfigToServiceConfig("gut_health_project_price", "cf.product.gut_health_price", "1990");
  13095. // products.points_multiplier(列)→ cf.rate.points_multiplier,并标记列注释
  13096. try {
  13097. if (!serviceConfigExists("cf.rate.points_multiplier")) {
  13098. String val = null;
  13099. try {
  13100. // 仅当所有商品倍数一致时才沿用该值,否则回落到 1.0(避免把某个商品的特殊倍数当成全局口径)
  13101. Integer distinct = jdbcTemplate.queryForObject(
  13102. "SELECT COUNT(DISTINCT points_multiplier) FROM products WHERE points_multiplier IS NOT NULL",
  13103. Integer.class);
  13104. if (distinct != null && distinct == 1) {
  13105. val = jdbcTemplate.queryForObject(
  13106. "SELECT CAST(points_multiplier AS CHAR) FROM products WHERE points_multiplier IS NOT NULL LIMIT 1",
  13107. String.class);
  13108. }
  13109. } catch (Exception ignored) {
  13110. // 列不存在或表为空
  13111. }
  13112. ensureServiceConfig("cf.rate.points_multiplier", val != null ? val : "1.0", "double",
  13113. "积分倍数(消费→CF 换算);原散落 products.points_multiplier", 1);
  13114. }
  13115. markProductsPointsMultiplierMigrated();
  13116. } catch (Exception e) {
  13117. log.warn("迁移412: products.points_multiplier 迁移失败: {}", e.getMessage());
  13118. }
  13119. }
  13120. private void migrateSysConfigToServiceConfig(String oldKey, String newKey, String defaultVal) {
  13121. try {
  13122. if (serviceConfigExists(newKey)) {
  13123. markSysConfigMigrated(oldKey, newKey);
  13124. return;
  13125. }
  13126. String val = null;
  13127. try {
  13128. val = jdbcTemplate.queryForObject(
  13129. "SELECT config_value FROM sys_config WHERE config_key = ? LIMIT 1", String.class, oldKey);
  13130. } catch (Exception ignored) {
  13131. // 旧配置不存在,使用默认值
  13132. }
  13133. ensureServiceConfig(newKey, val != null && !val.trim().isEmpty() ? val : defaultVal,
  13134. cfValueTypeOf(defaultVal), "由 sys_config." + oldKey + " 迁入(W0.1)", 1);
  13135. markSysConfigMigrated(oldKey, newKey);
  13136. } catch (Exception e) {
  13137. log.warn("迁移412: {} → {} 迁移失败: {}", oldKey, newKey, e.getMessage());
  13138. }
  13139. }
  13140. /** 旧 sys_config 行标记为已迁移(保留值做过渡兼容,不物理删除) */
  13141. private void markSysConfigMigrated(String oldKey, String newKey) {
  13142. try {
  13143. int updated = jdbcTemplate.update(
  13144. "UPDATE sys_config SET description = CONCAT(COALESCE(description, ''), ?) "
  13145. + "WHERE config_key = ? AND (description IS NULL OR description NOT LIKE '%【已迁移→service_config%')",
  13146. " 【已迁移→service_config " + newKey + ",本行仅过渡期兼容】", oldKey);
  13147. if (updated > 0) {
  13148. log.info("迁移412: 已标记 sys_config.{} 迁往 service_config.{}", oldKey, newKey);
  13149. }
  13150. } catch (Exception e) {
  13151. log.warn("迁移412: 标记 sys_config.{} 失败: {}", oldKey, e.getMessage());
  13152. }
  13153. }
  13154. /** products.points_multiplier 列注释标记(列保留兼容,不再作为权威来源) */
  13155. private void markProductsPointsMultiplierMigrated() {
  13156. try {
  13157. String comment = jdbcTemplate.queryForObject(
  13158. "SELECT COLUMN_COMMENT FROM information_schema.COLUMNS "
  13159. + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'products' AND COLUMN_NAME = 'points_multiplier'",
  13160. String.class);
  13161. if (comment != null && comment.contains("已迁移")) {
  13162. return;
  13163. }
  13164. jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN points_multiplier DECIMAL(10,2) NOT NULL DEFAULT 1.00 "
  13165. + "COMMENT '积分倍数【已迁移→service_config cf.rate.points_multiplier,本列仅过渡期兼容】'");
  13166. log.info("迁移412: 已标记 products.points_multiplier 迁往 service_config.cf.rate.points_multiplier");
  13167. } catch (Exception e) {
  13168. log.warn("迁移412: 标记 products.points_multiplier 失败: {}", e.getMessage());
  13169. }
  13170. }
  13171. private boolean serviceConfigExists(String key) {
  13172. try {
  13173. Integer cnt = jdbcTemplate.queryForObject(
  13174. "SELECT COUNT(*) FROM service_config WHERE config_key = ?", Integer.class, key);
  13175. return cnt != null && cnt > 0;
  13176. } catch (Exception e) {
  13177. return false;
  13178. }
  13179. }
  13180. /** 幂等写入 service_config(已存在则跳过,不覆盖运营改过的值) */
  13181. private void ensureServiceConfig(String key, String value, String valueType, String description, int editable) {
  13182. try {
  13183. if (serviceConfigExists(key)) {
  13184. return;
  13185. }
  13186. jdbcTemplate.update("INSERT INTO service_config (group_key, config_key, config_value, value_type, description, editable, created_at, updated_at) "
  13187. + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())",
  13188. groupKeyOfConfig(key), key, value, valueType, description, editable);
  13189. } catch (Exception e) {
  13190. log.warn("写入 service_config.{} 失败: {}", key, e.getMessage());
  13191. }
  13192. }
  13193. /** cf.rate.points_multiplier → cf.rate */
  13194. private String groupKeyOfConfig(String key) {
  13195. if (key == null) {
  13196. return "general";
  13197. }
  13198. int idx = key.lastIndexOf('.');
  13199. return idx > 0 ? key.substring(0, idx) : "general";
  13200. }
  13201. // ==================== 迁移413:W0.2 双配置中心合并(service_config 收编 product_config) ====================
  13202. /**
  13203. * 迁移413-1:service_config 增加 product_key 列(product 侧配置关联键;NULL = 全局/CF 通用参数)。
  13204. *
  13205. * 幂等:service_config 不存在 / 列已存在 / 索引已存在均跳过,可重复执行。
  13206. */
  13207. private void addServiceConfigProductKeyColumn() {
  13208. try {
  13209. if (!tableExists("service_config")) {
  13210. log.warn("迁移413: service_config 表不存在(迁移412 未执行?),跳过 product_key 列/索引");
  13211. return;
  13212. }
  13213. if (columnExists("service_config", "product_key")) {
  13214. log.info("迁移413: service_config.product_key 已存在,跳过加列");
  13215. } else {
  13216. ensureColumn("service_config", "product_key",
  13217. "VARCHAR(64) DEFAULT NULL COMMENT '关联产品标识(product 侧配置;NULL=全局/CF 通用参数)'");
  13218. log.info("迁移413: 已为 service_config 添加 product_key 列");
  13219. }
  13220. if (!indexExists("service_config", "idx_product_key")) {
  13221. jdbcTemplate.execute("ALTER TABLE service_config ADD KEY idx_product_key (product_key)");
  13222. log.info("迁移413: 已创建 service_config.idx_product_key 索引");
  13223. }
  13224. } catch (Exception e) {
  13225. log.warn("迁移413: 添加 product_key 列/索引失败: {}", e.getMessage());
  13226. }
  13227. }
  13228. /**
  13229. * 迁移413-2:把 product_config 表数据并入 service_config(product_key 填对应产品标识)。
  13230. *
  13231. * <p>product_key 标识规则(按 product_config 实际列优先级取第一个存在的列):</p>
  13232. * <ol>
  13233. * <li>{@code product_key}(字符串产品标识)</li>
  13234. * <li>{@code product_code}(产品编码)</li>
  13235. * <li>{@code product_id}(产品主键,CAST 为字符串;唯一索引冲突风险由 idempotentKey 拼接规避)</li>
  13236. * <li>group_key(计划文档 §3.6.6 中唯一保证存在的关联字段,作为兜底产品标识)</li>
  13237. * <li>以上皆缺 → {@code default}</li>
  13238. * </ol>
  13239. *
  13240. * <p>config_key 写入规则:product 侧键统一加 {@code product.{productKey}.} 前缀
  13241. * (见 {@link com.etotem.cfc.constant.ProductConfigKeys}),保证 config_key 全局唯一
  13242. * (service_config 唯一键 uk_config_key 保留不动,W0.2 不改 W0.1 种子与索引)。</p>
  13243. *
  13244. * <p>幂等:本仓库基线(origin/cfclub / 测试库)不存在 product_config 表,迁移为空跑;
  13245. * 若目标环境存在该表,按「service_config 中不存在同 key 才插入」幂等,INSERT IGNORE 兜底唯一键冲突。</p>
  13246. */
  13247. private void migrateProductConfigToServiceConfig() {
  13248. if (!tableExists("product_config")) {
  13249. log.info("迁移413: product_config 表不存在(本基线无该表),跳过数据并入(幂等空跑)");
  13250. return;
  13251. }
  13252. try {
  13253. String groupCol = firstExistingColumn("product_config", "group_key", "groupKey");
  13254. String keyCol = firstExistingColumn("product_config", "config_key", "configKey");
  13255. String valueCol = firstExistingColumn("product_config", "config_value", "configValue");
  13256. String typeCol = firstExistingColumn("product_config", "value_type", "valueType");
  13257. String descCol = firstExistingColumn("product_config", "description");
  13258. String editableCol = firstExistingColumn("product_config", "editable");
  13259. String upByCol = firstExistingColumn("product_config", "updated_by", "updatedBy");
  13260. String createdAtCol = firstExistingColumn("product_config", "created_at", "createdAt");
  13261. String updatedAtCol = firstExistingColumn("product_config", "updated_at", "updatedAt");
  13262. if (keyCol == null) {
  13263. log.warn("迁移413: product_config 缺少配置键列(config_key/configKey),无法并入,跳过");
  13264. return;
  13265. }
  13266. // 产品标识表达式(优先级:product_key → product_code → product_id → group_key → 'default')
  13267. // 统一加 p. 前缀限定,避免与 service_config 同名列歧义
  13268. String productKeyExpr = null;
  13269. for (String c : new String[]{"product_key", "product_code"}) {
  13270. if (columnExists("product_config", c)) {
  13271. productKeyExpr = "NULLIF(TRIM(p.`" + c + "`), '')";
  13272. break;
  13273. }
  13274. }
  13275. if (productKeyExpr == null && columnExists("product_config", "product_id")) {
  13276. productKeyExpr = "CAST(p.`product_id` AS CHAR)";
  13277. }
  13278. if (productKeyExpr == null && groupCol != null) {
  13279. productKeyExpr = "NULLIF(TRIM(p.`" + groupCol + "`), '')";
  13280. }
  13281. if (productKeyExpr == null) {
  13282. productKeyExpr = "'" + com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY + "'";
  13283. }
  13284. StringBuilder sql = new StringBuilder("INSERT IGNORE INTO service_config ")
  13285. .append("(group_key, config_key, config_value, value_type, description, editable, product_key, updated_by, created_at, updated_at) ")
  13286. .append("SELECT ");
  13287. sql.append("'").append(com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_GROUP_KEY).append("', ");
  13288. // config_key = product.{productKey}.{原key}(保证全局唯一;截断到 128 防超长)
  13289. sql.append("LEFT(CONCAT('").append(com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_KEY_PREFIX)
  13290. .append("', COALESCE(").append(productKeyExpr).append(", '")
  13291. .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), '.', p.`")
  13292. .append(keyCol).append("`), 128), ");
  13293. sql.append(valueCol != null ? "p.`" + valueCol + "`, " : "NULL, ");
  13294. sql.append(typeCol != null ? "COALESCE(NULLIF(TRIM(p.`" + typeCol + "`), ''), 'string'), " : "'string', ");
  13295. sql.append(descCol != null ? "LEFT(p.`" + descCol + "`, 255), " : "NULL, ");
  13296. sql.append(editableCol != null ? "COALESCE(p.`" + editableCol + "`, 1), " : "1, ");
  13297. sql.append("LEFT(COALESCE(").append(productKeyExpr).append(", '")
  13298. .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), 64), ");
  13299. sql.append(upByCol != null ? "p.`" + upByCol + "`, " : "NULL, ");
  13300. sql.append(createdAtCol != null ? "COALESCE(p.`" + createdAtCol + "`, NOW()), " : "NOW(), ");
  13301. sql.append(updatedAtCol != null ? "COALESCE(p.`" + updatedAtCol + "`, NOW()) " : "NOW() ");
  13302. sql.append("FROM product_config p WHERE NOT EXISTS "
  13303. + "(SELECT 1 FROM service_config s WHERE s.config_key = CONCAT('"
  13304. + com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_KEY_PREFIX
  13305. + "', COALESCE(").append(productKeyExpr).append(", '")
  13306. .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), '.', p.`")
  13307. .append(keyCol).append("`))");
  13308. int rows = jdbcTemplate.update(sql.toString());
  13309. log.info("迁移413: product_config → service_config 并入完成,新增 {} 行", rows);
  13310. } catch (Exception e) {
  13311. log.warn("迁移413: product_config 数据并入失败(跳过,可重试):{}", e.getMessage());
  13312. }
  13313. }
  13314. /** 返回 product_config 中第一个实际存在的列名(蛇形优先,兼容驼峰),全部缺失返回 null */
  13315. private String firstExistingColumn(String table, String... candidates) {
  13316. for (String c : candidates) {
  13317. if (columnExists(table, c)) {
  13318. return c;
  13319. }
  13320. }
  13321. return null;
  13322. }
  13323. private String cfValueTypeOf(String value) {
  13324. if (value == null) {
  13325. return "string";
  13326. }
  13327. try {
  13328. Integer.parseInt(value.trim());
  13329. return "int";
  13330. } catch (NumberFormatException ignored) {
  13331. // 非整数
  13332. }
  13333. try {
  13334. Double.parseDouble(value.trim());
  13335. return "double";
  13336. } catch (NumberFormatException ignored) {
  13337. // 非数值
  13338. }
  13339. return "string";
  13340. }
  13341. private Integer cfIntOrNull(String v) {
  13342. if (v == null || v.trim().isEmpty()) {
  13343. return null;
  13344. }
  13345. try {
  13346. return Integer.valueOf(v.trim());
  13347. } catch (NumberFormatException e) {
  13348. return null;
  13349. }
  13350. }
  13351. private String cfTextOrNull(String v) {
  13352. return (v == null || v.trim().isEmpty()) ? null : v;
  13353. }
  13354. private boolean indexExists(String table, String indexName) {
  13355. try {
  13356. Integer cnt = jdbcTemplate.queryForObject(
  13357. "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() "
  13358. + "AND TABLE_NAME = ? AND INDEX_NAME = ?",
  13359. Integer.class, table, indexName);
  13360. return cnt != null && cnt > 0;
  13361. } catch (Exception e) {
  13362. return false;
  13363. }
  13364. }
  13365. /**
  13366. * 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)。
  13367. * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。
  13368. */
  13369. private void createAuditLogTable() {
  13370. try {
  13371. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS audit_log (" +
  13372. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  13373. "operator_id BIGINT COMMENT '操作人ID(关联 users.id)'," +
  13374. "operator_name VARCHAR(100) COMMENT '操作人姓名'," +
  13375. "module VARCHAR(64) NOT NULL COMMENT '审计模块(config/earn_rule/tier/balance)'," +
  13376. "action VARCHAR(32) NOT NULL COMMENT '动作类型(save/update/toggle/delete/adjust)'," +
  13377. "target_type VARCHAR(32) NOT NULL COMMENT '目标类型(service_config/cf_earn_rule/cf_rate_tier/cf_balance)'," +
  13378. "target_id VARCHAR(128) COMMENT '目标标识(配置key / 规则code / 主键ID)'," +
  13379. "before_value TEXT COMMENT '变更前值(JSON)'," +
  13380. "after_value TEXT COMMENT '变更后值(JSON)'," +
  13381. "ip VARCHAR(45) COMMENT '操作来源IP'," +
  13382. "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间'," +
  13383. "KEY idx_module_target_created (module, target_id, created_at)" +
  13384. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志(管理端写操作前后值,W0.3)'");
  13385. log.info("迁移414: audit_log 表已就绪");
  13386. } catch (Exception e) {
  13387. log.warn("迁移414: 创建 audit_log 表失败(表可能已存在): {}", e.getMessage());
  13388. }
  13389. // 索引兜底:表已存在但索引缺失时补建(幂等)
  13390. try {
  13391. if (!indexExists("audit_log", "idx_module_target_created")) {
  13392. jdbcTemplate.execute("ALTER TABLE audit_log ADD INDEX idx_module_target_created (module, target_id, created_at)");
  13393. log.info("迁移414: 已为 audit_log 补建索引 idx_module_target_created");
  13394. }
  13395. } catch (Exception e) {
  13396. log.warn("迁移414: audit_log 索引检查/补建失败(可能已存在): {}", e.getMessage());
  13397. }
  13398. }
  13399. /**
  13400. * 迁移415: 创建 family_food_overview 表(家庭×食材 的持久化最小值总览,T28)。
  13401. * 家庭总览口径:家庭成员对该项食材最新推荐指数的最小值(最保守值),落库持久化。
  13402. * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。
  13403. */
  13404. private void createFamilyFoodOverviewTable() {
  13405. try {
  13406. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_food_overview (" +
  13407. "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
  13408. "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
  13409. "food_id BIGINT NOT NULL COMMENT '食材ID'," +
  13410. "food_name VARCHAR(100) COMMENT '食材名称快照'," +
  13411. "category VARCHAR(50) COMMENT '分类快照'," +
  13412. "min_index_score INT NOT NULL COMMENT '家庭成员该项食材最新推荐指数的最小值'," +
  13413. "member_count INT NOT NULL DEFAULT 0 COMMENT '参与计算的成员数(有该食材记录的)'," +
  13414. "member_scores JSON COMMENT '成员明细 [{userId,nickname,score}]'," +
  13415. "source_fingerprint VARCHAR(64) COMMENT '源数据指纹(成员指数快照哈希),用于幂等与变更检测'," +
  13416. "calculated_at DATETIME NOT NULL COMMENT '本次计算时间'," +
  13417. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
  13418. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
  13419. "UNIQUE KEY uk_family_food (family_id, food_id)," +
  13420. "INDEX idx_family (family_id)" +
  13421. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭×食材持久化最小值总览(T28)'");
  13422. log.info("迁移415: family_food_overview 表已就绪");
  13423. } catch (Exception e) {
  13424. log.warn("迁移415: 创建 family_food_overview 表失败(表可能已存在): {}", e.getMessage());
  13425. }
  13426. // 索引兜底:表已存在但索引缺失时补建(幂等)
  13427. try {
  13428. if (!indexExists("family_food_overview", "idx_family")) {
  13429. jdbcTemplate.execute("ALTER TABLE family_food_overview ADD INDEX idx_family (family_id)");
  13430. log.info("迁移415: 已为 family_food_overview 补建索引 idx_family");
  13431. }
  13432. } catch (Exception e) {
  13433. log.warn("迁移415: family_food_overview 索引检查/补建失败(可能已存在): {}", e.getMessage());
  13434. }
  13435. }
  13436. // ==================== 迁移418:F-4 分佣收口 ====================
  13437. /**
  13438. * 直接销售奖档位种子(F03a;初始运营值,管理端可调)。
  13439. * 列序:tier_name, min_annual_ppoints, rate_percent, sort_order, remark
  13440. */
  13441. private static final String[][] CF_DIRECT_SALES_TIER_SEEDS = {
  13442. {"新秀", "10000", "10", "1", "团队8层内年度P点≥10000,直接销售奖10%"},
  13443. {"小成", "50000", "25", "2", "团队8层内年度P点≥50000,直接销售奖25%"},
  13444. {"中富", "200000", "40", "3", "团队8层内年度P点≥200000,直接销售奖40%"},
  13445. {"大成", "500000", "55", "4", "团队8层内年度P点≥500000,直接销售奖55%"},
  13446. {"至富", "1000000", "70", "5", "团队8层内年度P点≥1000000,直接销售奖70%"}
  13447. };
  13448. /**
  13449. * 间接销售奖档位种子(F03b;初始运营值,管理端可调)。
  13450. * 列序:tier_name, min_self_ppoints, rate_percent, sort_order, remark
  13451. */
  13452. private static final String[][] CF_INDIRECT_SALES_TIER_SEEDS = {
  13453. {"新秀", "10000", "2", "1", "自身年度消费P点≥10000,间接销售奖2%"},
  13454. {"小成", "50000", "5", "2", "自身年度消费P点≥50000,间接销售奖5%"},
  13455. {"中富", "200000", "10", "3", "自身年度消费P点≥200000,间接销售奖10%"},
  13456. {"大成", "500000", "15", "4", "自身年度消费P点≥500000,间接销售奖15%"},
  13457. {"至富", "1000000", "20", "5", "自身年度消费P点≥1000000,间接销售奖20%"}
  13458. };
  13459. // 迁移418: 两级销售奖档位种子(幂等:min 下限唯一键已存在则跳过,不覆盖运营改过的档位)
  13460. // 迁移418(F-4):缺行才补的档位种子,包内可见以便单测直接调用
  13461. void seedCfSalesTiers() {
  13462. // 迁移431(×100) 后清理旧档残留:旧种子青铜(100)/白银(500)/黄金(2000)/铂金(5000)/钻石(10000)
  13463. // 的 min 阈值与迁移431 新档位(10000/50000/...)不同,缺行才补逻辑会在每次重启重新插入旧档,
  13464. // 导致新旧档并存、按 min DESC 匹配时旧档(如5000,55%)抢在新秀(10000,10%)前。
  13465. // 此处删除不在新档阈值集合内的旧档行(幂等,不影响已改名为新秀/小成/...的 1-5 行)。
  13466. try {
  13467. int cleaned = jdbcTemplate.update(
  13468. "DELETE FROM cf_direct_sales_tier "
  13469. + "WHERE min_annual_ppoints NOT IN (10000, 50000, 200000, 500000, 1000000)");
  13470. int cleaned2 = jdbcTemplate.update(
  13471. "DELETE FROM cf_indirect_sales_tier "
  13472. + "WHERE min_self_ppoints NOT IN (10000, 50000, 200000, 500000, 1000000)");
  13473. if (cleaned > 0 || cleaned2 > 0) {
  13474. log.info("迁移418: 清理旧档位残留 {} 行(直接)/{} 行(间接)", cleaned, cleaned2);
  13475. }
  13476. } catch (Exception e) {
  13477. log.warn("迁移418: 清理旧档位残留失败(幂等容忍): {}", e.getMessage());
  13478. }
  13479. int inserted = 0;
  13480. for (String[] t : CF_DIRECT_SALES_TIER_SEEDS) {
  13481. try {
  13482. Integer cnt = jdbcTemplate.queryForObject(
  13483. "SELECT COUNT(*) FROM cf_direct_sales_tier WHERE min_annual_ppoints = ?",
  13484. Integer.class, Integer.valueOf(t[1]));
  13485. if (cnt != null && cnt > 0) {
  13486. continue;
  13487. }
  13488. jdbcTemplate.update("INSERT INTO cf_direct_sales_tier "
  13489. + "(tier_name, min_annual_ppoints, rate_percent, sort_order, enabled, remark, created_at, updated_at) "
  13490. + "VALUES (?, ?, ?, ?, 1, ?, NOW(), NOW())",
  13491. t[0], Integer.valueOf(t[1]), Integer.valueOf(t[2]), Integer.valueOf(t[3]), t[4]);
  13492. inserted++;
  13493. } catch (Exception e) {
  13494. log.warn("迁移418: 写入直接销售奖档位种子 {} 失败: {}", t[0], e.getMessage());
  13495. }
  13496. }
  13497. for (String[] t : CF_INDIRECT_SALES_TIER_SEEDS) {
  13498. try {
  13499. Integer cnt = jdbcTemplate.queryForObject(
  13500. "SELECT COUNT(*) FROM cf_indirect_sales_tier WHERE min_self_ppoints = ?",
  13501. Integer.class, Integer.valueOf(t[1]));
  13502. if (cnt != null && cnt > 0) {
  13503. continue;
  13504. }
  13505. jdbcTemplate.update("INSERT INTO cf_indirect_sales_tier "
  13506. + "(tier_name, min_self_ppoints, rate_percent, sort_order, enabled, remark, created_at, updated_at) "
  13507. + "VALUES (?, ?, ?, ?, 1, ?, NOW(), NOW())",
  13508. t[0], Integer.valueOf(t[1]), Integer.valueOf(t[2]), Integer.valueOf(t[3]), t[4]);
  13509. inserted++;
  13510. } catch (Exception e) {
  13511. log.warn("迁移418: 写入间接销售奖档位种子 {} 失败: {}", t[0], e.getMessage());
  13512. }
  13513. }
  13514. if (inserted > 0) {
  13515. log.info("迁移418: 两级销售奖档位种子新增 {} 行(缺行才补,已存在不覆盖)", inserted);
  13516. }
  13517. }
  13518. // 迁移418: F03a/F03b 幂等 ref_type 对齐为规则编码(计划 §5.2 ref_type 见 F02-F24 编码;F-4)
  13519. // 迁移418(F-4):ref_type 对齐,包内可见以便单测直接调用
  13520. void alignSalesRuleRefTypes() {
  13521. try {
  13522. int updated = jdbcTemplate.update(
  13523. "UPDATE cf_earn_rule SET dedup_ref_type = 'F03a', updated_at = NOW() "
  13524. + "WHERE code = 'F03a' AND dedup_ref_type <> 'F03a'");
  13525. if (updated > 0) {
  13526. log.info("迁移418: F03a dedup_ref_type 已对齐为 F03a({} 行)", updated);
  13527. }
  13528. } catch (Exception e) {
  13529. log.warn("迁移418: F03a dedup_ref_type 对齐失败: {}", e.getMessage());
  13530. }
  13531. try {
  13532. int updated = jdbcTemplate.update(
  13533. "UPDATE cf_earn_rule SET dedup_ref_type = 'F03b', updated_at = NOW() "
  13534. + "WHERE code = 'F03b' AND dedup_ref_type <> 'F03b'");
  13535. if (updated > 0) {
  13536. log.info("迁移418: F03b dedup_ref_type 已对齐为 F03b({} 行)", updated);
  13537. }
  13538. } catch (Exception e) {
  13539. log.warn("迁移418: F03b dedup_ref_type 对齐失败: {}", e.getMessage());
  13540. }
  13541. }
  13542. // ==================== 迁移420:P 点消费日志(F-4 滚动一年档位数据源) ====================
  13543. // 迁移420: 创建 ppoint_consumption_log(消费支付成功时落一行,按消费时间滚动一年聚合)
  13544. // 迁移420(F-4):建表幂等,包内可见以便单测直接调用
  13545. /**
  13546. * 迁移422 前置:确保 health_indicators.collection_date 物理列存在。
  13547. * 放开 HealthIndicator.collectionDate 的 @TableField(exist=false) 之前必须先跑这一步;
  13548. * 任一环境缺列都会让所有 health_indicators 查询报 Unknown column(报告详情页整体崩溃)。
  13549. */
  13550. void ensureHealthIndicatorsCollectionDateColumn() {
  13551. if (!columnExists("health_indicators", "collection_date")) {
  13552. ensureColumn("health_indicators", "collection_date",
  13553. "DATE DEFAULT NULL COMMENT '指标采集日期(从报告提取的检测日期,非入库时间)'");
  13554. log.info("迁移422: 已补建 health_indicators.collection_date 列");
  13555. } else {
  13556. log.info("迁移422: health_indicators.collection_date 已存在,放开实体映射的前置条件满足");
  13557. }
  13558. }
  13559. /**
  13560. * 迁移422: indicator_values 增加日期来源标记 + 参考范围快照。
  13561. * date_source 区分采样日/上传日;ref_range_snapshot 保证历史判定可复现(字典改了不影响历史)。
  13562. */
  13563. void migrateIndicatorValuesTimelineColumns() {
  13564. ensureColumn("indicator_values", "date_source",
  13565. "VARCHAR(16) DEFAULT 'unknown' COMMENT 'report_date 来源: real=采样日/upload=上传日/confirm=确认日/unknown=未知(存量)'");
  13566. ensureColumn("indicator_values", "ref_range_snapshot",
  13567. "VARCHAR(100) DEFAULT NULL COMMENT '该期参考范围快照(为空时回落 indicator_definitions.ref_range)'");
  13568. log.info("迁移422: indicator_values 已补齐日期来源与参考范围快照 2 列");
  13569. }
  13570. // ==================== 迁移423:体脂秤测量记录表 ====================
  13571. /**
  13572. * 迁移423: 体脂秤测量记录表(一次测量一条记录)。
  13573. * 现状 sourceId=memberId + insert 会在第二次测量时撞 uk_source_definition 导致整批保存失败;
  13574. * 本表提供 recordId 作为 indicator_values.source_id 的新取值语义(服务层改造见 T05)。
  13575. */
  13576. void createBodyFatScaleRecordsTable() {
  13577. try {
  13578. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `body_fat_scale_records` ("
  13579. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13580. + "`member_id` BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)', "
  13581. + "`family_id` BIGINT NULL COMMENT '所属家庭', "
  13582. + "`image_url` VARCHAR(500) NULL COMMENT '体脂秤照片', "
  13583. + "`measured_at` DATE NULL COMMENT '测量日期(取不到为NULL,观测 report_date 记为上传日)', "
  13584. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13585. + "INDEX `idx_member_date` (`member_id`, `measured_at`) "
  13586. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体脂秤测量记录'");
  13587. log.info("迁移423: 已创建 body_fat_scale_records 表");
  13588. } catch (Exception e) {
  13589. log.warn("迁移423: 创建 body_fat_scale_records 表失败(可能已存在): {}", e.getMessage());
  13590. }
  13591. }
  13592. /**
  13593. * 迁移427: 内容维度阈值显隐配置表(A-后端)
  13594. * - content_type: 'PRODUCT'|'ARTICLE'|'ACTIVITY'|'MINIGAME'
  13595. * - dimension_code: 五维码(body/mind/wisdom/action/wealth)/子项码/自定义指标码
  13596. * - threshold: 0-100,与 FiveDimensionScore.score 同量纲
  13597. * - 显隐规则(OR 逻辑,在前端/列表服务层计算):任一维度得分 < 阈值 → 显示;全部维度得分 >= 阈值 → 隐藏
  13598. * 幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。
  13599. */
  13600. void createContentDimensionThresholdTable() {
  13601. try {
  13602. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `content_dimension_threshold` ("
  13603. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13604. + "`content_type` VARCHAR(20) NOT NULL COMMENT '内容类型: PRODUCT/ARTICLE/ACTIVITY/MINIGAME', "
  13605. + "`content_id` BIGINT NOT NULL COMMENT '内容ID(products/articles/activities/mini_games 主键)', "
  13606. + "`dimension_code` VARCHAR(50) NOT NULL COMMENT '维度码: 五维码/子项码/自定义指标码', "
  13607. + "`dimension_name` VARCHAR(100) NULL COMMENT '展示名(自定义指标即自定义名)', "
  13608. + "`threshold` INT NOT NULL COMMENT '阈值 0-100', "
  13609. + "`is_custom` TINYINT DEFAULT 0 COMMENT '1=管理员自定义指标', "
  13610. + "`enabled` TINYINT DEFAULT 1 COMMENT '1=启用', "
  13611. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13612. + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  13613. + "UNIQUE KEY `uk_content_dim` (`content_type`, `content_id`, `dimension_code`) "
  13614. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容维度阈值显隐配置'");
  13615. log.info("迁移427: 已创建 content_dimension_threshold 表");
  13616. } catch (Exception e) {
  13617. log.warn("迁移427: 创建 content_dimension_threshold 表失败(可能已存在): {}", e.getMessage());
  13618. }
  13619. }
  13620. // ==================== 迁移428:会员优惠券转赠 ====================
  13621. /**
  13622. * 迁移428: 会员优惠券转赠。
  13623. * <p>- 新建 coupon_gift_transfer 表(记录券实例从赠送方家庭转移到接收方家庭的过程,支持撤回)。
  13624. * - family_coupon 增加 is_gift 列(1=来自转赠,接收方可被非会员用于购买会员)。</p>
  13625. * 幂等:建表用 IF NOT EXISTS,加列前检查 information_schema。
  13626. */
  13627. void createCouponGiftTransferTable() {
  13628. try {
  13629. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `coupon_gift_transfer` ("
  13630. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13631. + "`giver_user_id` BIGINT NOT NULL COMMENT '赠送人用户ID(users.id)', "
  13632. + "`giver_family_id` BIGINT NOT NULL COMMENT '赠送人家庭ID', "
  13633. + "`receiver_user_id` BIGINT NOT NULL COMMENT '接收人用户ID(users.id)', "
  13634. + "`receiver_family_id` BIGINT NOT NULL COMMENT '接收人家庭ID', "
  13635. + "`coupon_id` BIGINT NOT NULL COMMENT '券模板ID(coupon.id)', "
  13636. + "`family_coupon_id` BIGINT NOT NULL COMMENT '被转移的 family_coupon 实例ID', "
  13637. + "`gift_level` INT NOT NULL COMMENT '推荐层级 1=直推 2=隔级', "
  13638. + "`status` VARCHAR(16) NOT NULL DEFAULT 'GIFTED' COMMENT 'GIFTED/USED/REVOKED', "
  13639. + "`remark` VARCHAR(255) NULL COMMENT '赠送留言', "
  13640. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13641. + "`used_at` DATETIME NULL, "
  13642. + "`revoked_at` DATETIME NULL, "
  13643. + "INDEX `idx_receiver` (`receiver_family_id`, `status`), "
  13644. + "INDEX `idx_giver` (`giver_user_id`), "
  13645. + "INDEX `idx_family_coupon` (`family_coupon_id`) "
  13646. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员优惠券转赠记录'");
  13647. log.info("迁移428: 已创建 coupon_gift_transfer 表");
  13648. } catch (Exception e) {
  13649. log.warn("迁移428: 创建 coupon_gift_transfer 表失败(可能已存在): {}", e.getMessage());
  13650. }
  13651. // family_coupon.is_gift 列(1=来自转赠)
  13652. try {
  13653. Integer colExists = jdbcTemplate.queryForObject(
  13654. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_coupon' AND COLUMN_NAME = 'is_gift'",
  13655. Integer.class);
  13656. if (colExists == null || colExists == 0) {
  13657. jdbcTemplate.execute("ALTER TABLE family_coupon ADD COLUMN is_gift INT DEFAULT 0 COMMENT '1=该券实例来自他人转赠'");
  13658. log.info("迁移428: 已为 family_coupon 增加 is_gift 列");
  13659. } else {
  13660. log.info("迁移428: family_coupon.is_gift 列已存在,跳过");
  13661. }
  13662. } catch (Exception e) {
  13663. log.warn("迁移428: 检查/添加 family_coupon.is_gift 列失败: {}", e.getMessage());
  13664. }
  13665. }
  13666. // ==================== 迁移434:SKU 商品组合 ====================
  13667. void migrateSkuCombo() {
  13668. try {
  13669. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_sku_combo_items` ("
  13670. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13671. + "`sku_id` BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)', "
  13672. + "`child_product_id` BIGINT NOT NULL COMMENT '组成商品ID', "
  13673. + "`child_sku_id` BIGINT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)', "
  13674. + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量', "
  13675. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13676. + "INDEX `idx_sku` (`sku_id`), "
  13677. + "INDEX `idx_child_product` (`child_product_id`) "
  13678. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细'");
  13679. log.info("迁移434: 已创建 product_sku_combo_items 表");
  13680. } catch (Exception e) {
  13681. log.warn("迁移434: 创建 product_sku_combo_items 表失败(可能已存在): {}", e.getMessage());
  13682. }
  13683. try {
  13684. Integer colExists = jdbcTemplate.queryForObject(
  13685. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'product_orders' AND COLUMN_NAME = 'sku_id'",
  13686. Integer.class);
  13687. if (colExists == null || colExists == 0) {
  13688. jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN sku_id BIGINT NULL COMMENT '下单SKU ID(组合SKU识别用)' AFTER product_type");
  13689. log.info("迁移434: 已为 product_orders 增加 sku_id 列");
  13690. } else {
  13691. log.info("迁移434: product_orders.sku_id 列已存在,跳过");
  13692. }
  13693. } catch (Exception e) {
  13694. log.warn("迁移434: 检查/添加 product_orders.sku_id 列失败: {}", e.getMessage());
  13695. }
  13696. }
  13697. // ==================== 迁移429:用户关注指标收藏(T04 方案A) ====================
  13698. /**
  13699. * 迁移429: 用户关注指标收藏表(跨端同步;5维+指标唯一)。
  13700. * 幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。
  13701. */
  13702. void createIndicatorFavoriteTable() {
  13703. try {
  13704. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `indicator_favorite` ("
  13705. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13706. + "`user_id` BIGINT NOT NULL COMMENT '收藏用户ID(users.id)', "
  13707. + "`family_id` BIGINT NOT NULL COMMENT '所属家庭ID', "
  13708. + "`member_id` BIGINT NOT NULL COMMENT '观测对象ID(family_members.id)', "
  13709. + "`source_type` VARCHAR(50) NOT NULL COMMENT '报告类型', "
  13710. + "`definition_id` BIGINT NOT NULL COMMENT '指标定义ID(indicator_definitions.id)', "
  13711. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13712. + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  13713. + "UNIQUE KEY `uk_user_family_member_source_def` (`user_id`, `family_id`, `member_id`, `source_type`, `definition_id`), "
  13714. + "KEY `idx_member_source` (`member_id`, `source_type`), "
  13715. + "KEY `idx_user_created` (`user_id`, `created_at`) "
  13716. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注指标收藏'");
  13717. log.info("迁移429: 已创建 indicator_favorite 表");
  13718. } catch (Exception e) {
  13719. log.warn("迁移429: 创建 indicator_favorite 表失败(可能已存在): {}", e.getMessage());
  13720. }
  13721. }
  13722. // ==================== 迁移433:家庭指标聚合方式配置(五维能量汇总/珍珠图/章鱼图汇集配置) ====================
  13723. /**
  13724. * 迁移433: 家庭指标聚合方式配置表 family_metric_agg_config。
  13725. * 平台级全局一份,按数据项一行一条。
  13726. * 预置 8 行数据:五维各维度 AVG + 综合 AVG + pearl.resources COLLECT + octopus.effects COLLECT
  13727. */
  13728. void createFamilyMetricAggConfigTable() {
  13729. try {
  13730. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `family_metric_agg_config` ("
  13731. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13732. + "`metric_key` VARCHAR(64) NOT NULL COMMENT '数据项唯一标识,如 energy.body、pearl.resources', "
  13733. + "`metric_name` VARCHAR(64) NOT NULL COMMENT '中文名,如「身体维度能量分」', "
  13734. + "`dimension_code` VARCHAR(32) COMMENT '所属维度:body/mind/wisdom/action/wealth/common', "
  13735. + "`agg_type` VARCHAR(16) NOT NULL COMMENT '汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集', "
  13736. + "`value_type` VARCHAR(16) NOT NULL COMMENT '值类型:NUMBER(数值)/ COLLECTION(集合)', "
  13737. + "`display_mode` VARCHAR(32) COMMENT '展示形态:number/bar/radar/merge_graph', "
  13738. + "`sort_order` INT DEFAULT 0 COMMENT '排序', "
  13739. + "`status` VARCHAR(16) DEFAULT 'active' COMMENT '状态:active/disabled', "
  13740. + "`remark` VARCHAR(255) COMMENT '备注', "
  13741. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13742. + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  13743. + "UNIQUE KEY `uk_metric_key` (`metric_key`) "
  13744. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭指标聚合方式配置'");
  13745. log.info("迁移433: 已创建 family_metric_agg_config 表");
  13746. } catch (Exception e) {
  13747. log.warn("迁移433: 创建 family_metric_agg_config 表失败(可能已存在): {}", e.getMessage());
  13748. }
  13749. // 预置数据(幂等 upsert)
  13750. upsertFamilyMetricAggConfig("energy.body", "身体维度能量分", "body", "AVG", "NUMBER", "number", 1, "active", "五维身体维度家庭汇总,默认平均值");
  13751. upsertFamilyMetricAggConfig("energy.mind", "心理维度能量分", "mind", "AVG", "NUMBER", "number", 2, "active", "五维心理维度家庭汇总,默认平均值");
  13752. upsertFamilyMetricAggConfig("energy.wisdom", "认知维度能量分", "wisdom", "AVG", "NUMBER", "number", 3, "active", "五维认知维度家庭汇总,默认平均值");
  13753. upsertFamilyMetricAggConfig("energy.action", "社交维度能量分", "action", "AVG", "NUMBER", "number", 4, "active", "五维社交维度家庭汇总,默认平均值");
  13754. upsertFamilyMetricAggConfig("energy.wealth", "成就维度能量分", "wealth", "AVG", "NUMBER", "number", 5, "active", "五维成就维度家庭汇总,默认平均值");
  13755. upsertFamilyMetricAggConfig("energy.overall", "综合能量分", "common", "AVG", "NUMBER", "number", 6, "active", "五维综合能量分家庭汇总,默认平均值");
  13756. upsertFamilyMetricAggConfig("pearl.resources", "珍珠图资源(我有什么)", "action", "COLLECT", "COLLECTION", "merge_graph", 7, "active", "珍珠图家庭汇集,合并成一张图按成员着色");
  13757. upsertFamilyMetricAggConfig("octopus.effects", "章鱼图关键人/成效", "action", "COLLECT", "COLLECTION", "merge_graph", 8, "active", "章鱼图家庭汇集,合并成一张图按成员着色");
  13758. }
  13759. private void upsertFamilyMetricAggConfig(String metricKey, String metricName, String dimensionCode,
  13760. String aggType, String valueType, String displayMode,
  13761. Integer sortOrder, String status, String remark) {
  13762. try {
  13763. // 先尝试更新
  13764. int updated = jdbcTemplate.update(
  13765. "UPDATE `family_metric_agg_config` SET metric_name=?, dimension_code=?, agg_type=?, value_type=?, display_mode=?, sort_order=?, status=?, remark=?, updated_at=CURRENT_TIMESTAMP WHERE metric_key=?",
  13766. metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark, metricKey);
  13767. if (updated == 0) {
  13768. // 不存在则插入
  13769. jdbcTemplate.update(
  13770. "INSERT INTO `family_metric_agg_config` (metric_key, metric_name, dimension_code, agg_type, value_type, display_mode, sort_order, status, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
  13771. metricKey, metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark);
  13772. log.info("迁移433: 已插入预置配置 {}", metricKey);
  13773. } else {
  13774. log.info("迁移433: 已更新预置配置 {}", metricKey);
  13775. }
  13776. } catch (Exception e) {
  13777. log.warn("迁移433: 预置配置 {} 处理失败(忽略): {}", metricKey, e.getMessage());
  13778. }
  13779. }
  13780. void createPpointConsumptionLogTable() {
  13781. try {
  13782. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `ppoint_consumption_log` ("
  13783. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13784. + "`user_id` BIGINT NOT NULL COMMENT '消费人用户ID(users.id)', "
  13785. + "`member_id` BIGINT NULL COMMENT '消费人家庭成员ID(family_members.id)', "
  13786. + "`family_id` BIGINT NULL COMMENT '消费家庭ID', "
  13787. + "`order_id` BIGINT NOT NULL COMMENT '订单ID', "
  13788. + "`order_type` VARCHAR(32) NOT NULL COMMENT '订单类型(product/package/assessment/membership/subscription)', "
  13789. + "`product_id` BIGINT NULL COMMENT '商品ID(商品订单)', "
  13790. + "`amount_cent` INT NULL COMMENT '实付金额(分)', "
  13791. + "`ppoints` INT NOT NULL DEFAULT 0 COMMENT '本单产生的P点', "
  13792. + "`remark` VARCHAR(255) NULL, "
  13793. + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间(订单支付成功时刻)', "
  13794. + "UNIQUE KEY `uk_order` (`order_type`, `order_id`), "
  13795. + "KEY `idx_user_time` (`user_id`, `created_at`) "
  13796. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='P点消费日志(滚动一年销售奖档位聚合数据源,F-4)'");
  13797. log.info("迁移420: 已创建 ppoint_consumption_log 表");
  13798. } catch (Exception e) {
  13799. log.warn("迁移420: 创建 ppoint_consumption_log 表失败(可能已存在): {}", e.getMessage());
  13800. }
  13801. }
  13802. // ==================== 迁移424:家庭归因阶段一·数据地基 ====================
  13803. /**
  13804. * 迁移424: health_plans 加 plan_type,区分健康方案与家庭归因干预方案(G5)。
  13805. * 默认 'health_plan',存量行自动命中默认值;幂等(列已存在则跳过)。
  13806. */
  13807. void migrateHealthPlansPlanType() {
  13808. ensureColumn("health_plans", "plan_type",
  13809. "VARCHAR(32) DEFAULT 'health_plan' COMMENT '方案类型: health_plan=健康方案, family_attribution_plan=家庭归因方案'");
  13810. }
  13811. // ==================== 迁移425:家庭归因常模值表(G10) ====================
  13812. /** 菌群常模 JSON 种子路径(classpath) */
  13813. static final String NORM_GUT_FLORA_CLASSPATH = "norm/attribution_norm_gut_flora.json";
  13814. /** 家庭归因确认问卷模板 JSON 种子路径(classpath) */
  13815. static final String SURVEY_FAMILY_ATTRIBUTION_CLASSPATH = "survey/family_attribution_questions.json";
  13816. /** 家庭归因确认问卷模板标题(幂等判重键) */
  13817. static final String SURVEY_FAMILY_ATTRIBUTION_TITLE = "家庭归因分析问卷(第八章16条)";
  13818. /**
  13819. * 迁移425: 家庭归因常模值表 attribution_norm_values。
  13820. * 菌群 710 常模落点;DAN 常模优先复用 cognitive_norms / health_norm_reference,不重复建。
  13821. */
  13822. void createAttributionNormValuesTable() {
  13823. try {
  13824. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `attribution_norm_values` ("
  13825. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13826. + "`norm_type` VARCHAR(20) NOT NULL COMMENT '常模类型: gut_flora(菌群)/dan(DAN测评)', "
  13827. + "`definition_id` BIGINT NULL COMMENT 'FK→indicator_definitions.id(归一化后关联,可空)', "
  13828. + "`code` VARCHAR(64) NOT NULL COMMENT '指标编码(菌种学名如Bifidobacterium 或 DAN维度key如emotionScore)', "
  13829. + "`display_name` VARCHAR(100) COMMENT '显示名', "
  13830. + "`member_age_band` VARCHAR(32) NOT NULL COMMENT '年龄段带: 0-3/4-6/7-12/13-18/19-45/46-65/66+/all', "
  13831. + "`gender` VARCHAR(10) DEFAULT 'all' COMMENT '性别: male/female/all', "
  13832. + "`p5` DECIMAL(10,4) NULL COMMENT 'P5百分位值', "
  13833. + "`p25` DECIMAL(10,4) NULL COMMENT 'P25百分位值', "
  13834. + "`p50` DECIMAL(10,4) NULL COMMENT 'P50百分位值(中位数)', "
  13835. + "`p75` DECIMAL(10,4) NULL COMMENT 'P75百分位值', "
  13836. + "`p95` DECIMAL(10,4) NULL COMMENT 'P95百分位值', "
  13837. + "`unit` VARCHAR(20) COMMENT '单位(如 %)', "
  13838. + "`norm_version` VARCHAR(20) NOT NULL DEFAULT 'v1' COMMENT '常模版本', "
  13839. + "`source` VARCHAR(200) COMMENT '数据来源(谷禾710常模/DAN常模/导入文件名)', "
  13840. + "`status` TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用', "
  13841. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13842. + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  13843. + "UNIQUE KEY `uk_type_code_band_gender_ver` (`norm_type`, `code`, `member_age_band`, `gender`, `norm_version`), "
  13844. + "INDEX `idx_type_code` (`norm_type`, `code`), "
  13845. + "INDEX `idx_def` (`definition_id`) "
  13846. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因常模值'");
  13847. log.info("迁移425: 已创建 attribution_norm_values 表");
  13848. } catch (Exception e) {
  13849. log.warn("迁移425: 创建 attribution_norm_values 表失败(可能已存在): {}", e.getMessage());
  13850. }
  13851. }
  13852. /**
  13853. * 迁移425-导入: 菌群常模可配置 JSON 导入(norm_type=gut_flora)。
  13854. * 空壳模板(placeholder=true / data 为空)→ 0 行导入且不报错;
  13855. * 真实数据到位后填充 classpath:norm/attribution_norm_gut_flora.json 即可增量导入。
  13856. */
  13857. void importAttributionNormGutFlora() {
  13858. try {
  13859. ClassPathResource resource = new ClassPathResource(NORM_GUT_FLORA_CLASSPATH);
  13860. if (!resource.exists()) {
  13861. log.warn("迁移425: 未找到常模种子文件 {},跳过导入", NORM_GUT_FLORA_CLASSPATH);
  13862. return;
  13863. }
  13864. String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
  13865. if (content.trim().isEmpty()) {
  13866. log.info("迁移425: 常模种子文件为空,0 行导入");
  13867. return;
  13868. }
  13869. ObjectMapper mapper = new ObjectMapper();
  13870. JsonNode root = mapper.readTree(content);
  13871. if (root == null || !root.isObject()) {
  13872. log.warn("迁移425: 常模种子文件格式非法(期望 JSON 对象),0 行导入");
  13873. return;
  13874. }
  13875. if (root.path("placeholder").asBoolean(false)) {
  13876. log.info("迁移425: 常模种子为占位模板(placeholder=true),0 行导入,等待业务侧填充真实常模");
  13877. return;
  13878. }
  13879. JsonNode data = root.get("data");
  13880. if (data == null || !data.isArray() || data.size() == 0) {
  13881. log.info("迁移425: 常模种子 data 为空,0 行导入");
  13882. return;
  13883. }
  13884. int inserted = 0;
  13885. for (JsonNode row : data) {
  13886. String code = text(row, "code");
  13887. if (code == null || code.trim().isEmpty()) {
  13888. continue;
  13889. }
  13890. String ageBand = defaultIfBlank(text(row, "memberAgeBand"), "all");
  13891. String gender = defaultIfBlank(text(row, "gender"), "all");
  13892. String normVersion = defaultIfBlank(text(row, "normVersion"), "v1");
  13893. jdbcTemplate.update(
  13894. "INSERT INTO attribution_norm_values "
  13895. + "(norm_type, definition_id, code, display_name, member_age_band, gender, "
  13896. + "p5, p25, p50, p75, p95, unit, norm_version, source, status, created_at, updated_at) "
  13897. + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), NOW()) "
  13898. + "ON DUPLICATE KEY UPDATE definition_id=VALUES(definition_id), display_name=VALUES(display_name), "
  13899. + "p5=VALUES(p5), p25=VALUES(p25), p50=VALUES(p50), p75=VALUES(p75), p95=VALUES(p95), "
  13900. + "unit=VALUES(unit), source=VALUES(source), status=VALUES(status), updated_at=NOW()",
  13901. "gut_flora",
  13902. longOrNull(row.get("definitionId")),
  13903. code.trim(),
  13904. text(row, "displayName"),
  13905. ageBand,
  13906. gender,
  13907. decimalOrNull(row.get("p5")),
  13908. decimalOrNull(row.get("p25")),
  13909. decimalOrNull(row.get("p50")),
  13910. decimalOrNull(row.get("p75")),
  13911. decimalOrNull(row.get("p95")),
  13912. text(row, "unit"),
  13913. normVersion,
  13914. text(row, "source"));
  13915. inserted++;
  13916. }
  13917. log.info("迁移425: 菌群常模导入完成,共 {} 行(norm_type=gut_flora,幂等 upsert)", inserted);
  13918. } catch (Exception e) {
  13919. log.warn("迁移425: 菌群常模导入失败(忽略): {}", e.getMessage());
  13920. }
  13921. }
  13922. /**
  13923. * 迁移425-种子: 家庭归因确认问卷模板种子(A2:第八章 16 条 / 45 采集项)。
  13924. * 幂等:按 title 判重;已存在则只确保 enabled=1(不覆盖管理员改过的 questions_json);
  13925. * questionsJson 存 §3.2 扩展结构(schemaVersion/target/questions)。
  13926. */
  13927. void seedFamilyAttributionSurveyTemplate() {
  13928. try {
  13929. ClassPathResource resource = new ClassPathResource(SURVEY_FAMILY_ATTRIBUTION_CLASSPATH);
  13930. if (!resource.exists()) {
  13931. log.warn("迁移425-种子: 未找到问卷模板种子 {},跳过", SURVEY_FAMILY_ATTRIBUTION_CLASSPATH);
  13932. return;
  13933. }
  13934. Integer cnt = jdbcTemplate.queryForObject(
  13935. "SELECT COUNT(*) FROM survey_templates WHERE title = ?",
  13936. Integer.class, SURVEY_FAMILY_ATTRIBUTION_TITLE);
  13937. if (cnt != null && cnt > 0) {
  13938. // 已存在:幂等启用(不覆盖管理员可能改过的 questions_json)
  13939. jdbcTemplate.update(
  13940. "UPDATE survey_templates SET enabled = 1, updated_at = NOW() WHERE title = ?",
  13941. SURVEY_FAMILY_ATTRIBUTION_TITLE);
  13942. log.info("迁移425-种子: 家庭归因问卷模板已存在(title={}),幂等启用(enabled=1)", SURVEY_FAMILY_ATTRIBUTION_TITLE);
  13943. return;
  13944. }
  13945. String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
  13946. // 校验 JSON 合法后再落库,避免脏种子进库
  13947. ObjectMapper mapper = new ObjectMapper();
  13948. JsonNode root = mapper.readTree(content);
  13949. if (root == null || !root.isObject() || root.get("questions") == null) {
  13950. log.warn("迁移425-种子: 问卷模板种子格式非法,跳过");
  13951. return;
  13952. }
  13953. jdbcTemplate.update(
  13954. "INSERT INTO survey_templates (title, dimension, questions_json, cycle_type, ai_generated, enabled, created_at, updated_at) "
  13955. + "VALUES (?, ?, ?, ?, 0, 1, NOW(), NOW())",
  13956. SURVEY_FAMILY_ATTRIBUTION_TITLE,
  13957. "family_attribution",
  13958. content,
  13959. "once");
  13960. log.info("迁移425-种子: 已写入家庭归因问卷模板种子 title={} questions={}(enabled=1,已启用)",
  13961. SURVEY_FAMILY_ATTRIBUTION_TITLE, root.get("questions").size());
  13962. } catch (Exception e) {
  13963. log.warn("迁移425-种子: 家庭归因问卷模板种子失败(忽略): {}", e.getMessage());
  13964. }
  13965. }
  13966. // ==================== 迁移426:家庭归因报告表(B1 核心闭环③) ====================
  13967. /**
  13968. * 迁移426: 家庭归因报告表 attribution_reports。
  13969. * 存十节结构 report_json + 归因中间态 intermediate_state(供 B2 条件路由 / B3 回填)
  13970. * + 红线命中 redline_hits + 状态机 status(ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED)。
  13971. * 幂等:CREATE TABLE IF NOT EXISTS。
  13972. */
  13973. void createAttributionReportsTable() {
  13974. try {
  13975. jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `attribution_reports` ("
  13976. + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
  13977. + "`family_id` BIGINT NOT NULL COMMENT '家庭ID', "
  13978. + "`member_ids` VARCHAR(200) NULL COMMENT '目标成员ID列表(逗号分隔;E6: 妈妈/女儿/儿子)', "
  13979. + "`plan_id` BIGINT NULL COMMENT '关联健康方案ID(health_plans.id)', "
  13980. + "`status` VARCHAR(20) NOT NULL DEFAULT 'ANALYZING' "
  13981. + "COMMENT '状态机: ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED', "
  13982. + "`report_json` MEDIUMTEXT NULL COMMENT '十节结构化归因报告(JSON)', "
  13983. + "`redline_hits` VARCHAR(255) NULL COMMENT '红线命中编号(JSON数组, 如 [\"RL02\"])', "
  13984. + "`intermediate_state` MEDIUMTEXT NULL COMMENT '归因中间态(JSON, 供B2条件路由/B3回填)', "
  13985. + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
  13986. + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
  13987. + "INDEX `idx_family` (`family_id`), "
  13988. + "INDEX `idx_status` (`status`), "
  13989. + "INDEX `idx_plan` (`plan_id`) "
  13990. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因分析报告(阶段二核心闭环)'");
  13991. log.info("迁移426: 已创建 attribution_reports 表");
  13992. } catch (Exception e) {
  13993. log.warn("迁移426: 创建 attribution_reports 表失败(可能已存在): {}", e.getMessage());
  13994. }
  13995. // 迁移435: product_bundle_items 增加套餐SKU锚点列(订单套餐自动定价)
  13996. // bundle_sku_id 非空 = 定价套餐行(套餐价取该SKU.price,CartService/ProductOrderService 匹配用);
  13997. // 为空 = 仅出库联动行(InventoryService 语义,不参与定价)。
  13998. ensureColumn("product_bundle_items", "bundle_sku_id",
  13999. "BIGINT NULL COMMENT '套餐SKU锚点:非空=订单套餐自动定价(套餐价取该SKU.price);空=仅出库联动'");
  14000. // 同时放开 bundle_product_id 可空:定价套餐行置 NULL,避免 InventoryService
  14001. // (按 bundle_product_id 关联、仅 productType=bundle 商品)误触发出库联动。
  14002. try {
  14003. jdbcTemplate.execute("ALTER TABLE product_bundle_items MODIFY COLUMN bundle_product_id BIGINT NULL COMMENT '套餐商品ID(出库联动行=套餐商品ID;定价套餐行置NULL)'");
  14004. log.info("迁移435: product_bundle_items.bundle_product_id 已放开可空");
  14005. } catch (Exception e) {
  14006. log.warn("迁移427: 放开 bundle_product_id 可空失败(可能已放开): {}", e.getMessage());
  14007. }
  14008. }
  14009. private static String text(JsonNode row, String field) {
  14010. if (row == null) return null;
  14011. JsonNode n = row.get(field);
  14012. if (n == null || n.isNull()) return null;
  14013. String s = n.asText();
  14014. return s == null || s.trim().isEmpty() ? null : s;
  14015. }
  14016. private static String defaultIfBlank(String v, String def) {
  14017. return (v == null || v.trim().isEmpty()) ? def : v;
  14018. }
  14019. private static Long longOrNull(JsonNode n) {
  14020. if (n == null || n.isNull()) return null;
  14021. return n.isNumber() ? n.longValue() : null;
  14022. }
  14023. private static java.math.BigDecimal decimalOrNull(JsonNode n) {
  14024. if (n == null || n.isNull() || n.asText().trim().isEmpty()) return null;
  14025. try {
  14026. return new java.math.BigDecimal(n.asText().trim());
  14027. } catch (NumberFormatException e) {
  14028. return null;
  14029. }
  14030. }
  14031. }