commit d9e575e853951dc46fea2814bd6128fef235d226 Author: well <347471159@qq.com> Date: Fri Mar 27 10:34:03 2026 +0800 first commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..a3c7a07 --- /dev/null +++ b/.air.toml @@ -0,0 +1,45 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["-config", "config.yaml"] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "bin", "deploy"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c1e74bc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,604 @@ +# 视频,video +# oggtheora +*.[oO][gG][gG][tT][hH][eE][oO][rR][aA] filter=lfs diff=lfs merge=binary -text +# m2t +*.[mM]2[tT] filter=lfs diff=lfs merge=binary -text +# mts +*.[mM][tT][sS] filter=lfs diff=lfs merge=binary -text +# mp4 +*.[mM][pP]4 filter=lfs diff=lfs merge=binary -text +# avi +*.[aA][vV][iI] filter=lfs diff=lfs merge=binary -text +# mkv +*.[mM][kK][vV] filter=lfs diff=lfs merge=binary -text +# wmv +*.[wW][mM][vV] filter=lfs diff=lfs merge=binary -text +# asf +*.[aA][sS][fF] filter=lfs diff=lfs merge=binary -text +# asx +*.[aA][sS][xX] filter=lfs diff=lfs merge=binary -text +# rm +*.[rR][mM] filter=lfs diff=lfs merge=binary -text +# rmvb +*.[rR][mM][vV][bB] filter=lfs diff=lfs merge=binary -text +# 3gp +*.3[gG][pP] filter=lfs diff=lfs merge=binary -text +# 3gpp +*.3[gG][pP][pP] filter=lfs diff=lfs merge=binary -text +# 3gpp2 +*.3[gG][pP][pP]2 filter=lfs diff=lfs merge=binary -text +# mov +*.[mM][oO][vV] filter=lfs diff=lfs merge=binary -text +# m4v +*.[mM]4[vV] filter=lfs diff=lfs merge=binary -text +# dat +*.[dD][aA][tT] filter=lfs diff=lfs merge=binary -text +# vob +*.[vV][oO][bB] filter=lfs diff=lfs merge=binary -text +# dv +*.[dD][vV] filter=lfs diff=lfs merge=binary -text +# mpeg +*.[mM][pP][eE][gG] filter=lfs diff=lfs merge=binary -text +# mpg +*.[mM][pP][gG] filter=lfs diff=lfs merge=binary -text +# mpe +*.[mM][pP][eE] filter=lfs diff=lfs merge=binary -text +# m2v +*.[mM]2[vV] filter=lfs diff=lfs merge=binary -text +# webm +*.[wW][eE][bB][mM] filter=lfs diff=lfs merge=binary -text +# flv +*.[fF][lL][vV] filter=lfs diff=lfs merge=binary -text +# swf +*.[sS][wW][fF] filter=lfs diff=lfs merge=binary -text +# avc +*.[aA][vV][cC] filter=lfs diff=lfs merge=binary -text +# arf +*.[aA][rR][fF] filter=lfs diff=lfs merge=binary -text +# vcr +*.[vV][cC][rR] filter=lfs diff=lfs merge=binary -text +# ogv +*.[oO][gG][vV] filter=lfs diff=lfs merge=binary -text + +# 音频, audio +# ape +*.[aA][pP][eE] filter=lfs diff=lfs merge=binary -text +# wav +*.[wW][aA][vV] filter=lfs diff=lfs merge=binary -text +# m4a +*.[mM]4[aA] filter=lfs diff=lfs merge=binary -text +# mp3 +*.[mM][pP]3 filter=lfs diff=lfs merge=binary -text +# flac +*.[fF][lL][aA][cC] filter=lfs diff=lfs merge=binary -text +# aif +*.[aA][iI][fF] filter=lfs diff=lfs merge=binary -text +# aiff +*.[aA][iI][fF][fF] filter=lfs diff=lfs merge=binary -text +# aac +*.[aA][aA][cC] filter=lfs diff=lfs merge=binary -text +# cpa +*.[cC][pP][aA] filter=lfs diff=lfs merge=binary -text +# swa +*.[sS][wW][aA] filter=lfs diff=lfs merge=binary -text +# sesx +*.[sS][eE][sS][xX] filter=lfs diff=lfs merge=binary -text +# ses +*.[sS][eE][sS] filter=lfs diff=lfs merge=binary -text +# bnk, Wwise audio +*.[bB][nN][kK] filter=lfs diff=lfs merge=binary -text +# wem, Wwise +*.[wW][eE][mM] filter=lfs diff=lfs merge=binary -text +# pca +*.[pP][cC][aA] filter=lfs diff=lfs merge=binary -text + +# 库文件,Library +# a +*.[aA] filter=lfs diff=lfs merge=binary -text +# o +*.[oO] filter=lfs diff=lfs merge=binary -text +# so +*.[sS][oO] filter=lfs diff=lfs merge=binary -text +# lib +*.[lL][iI][bB] filter=lfs diff=lfs merge=binary -text +# dll +*.[dD][lL][lL] filter=lfs diff=lfs merge=binary -text +# lbr +*.[lL][bB][rR] filter=lfs diff=lfs merge=binary -text +# tlb +*.[tT][lL][bB] filter=lfs diff=lfs merge=binary -text +# cab +*.[cC][aA][bB] filter=lfs diff=lfs merge=binary -text +# dylib +*.[dD][yY][lL][iI][bB] filter=lfs diff=lfs merge=binary -text +# dsym +*.[dD][sS][yY][mM] filter=lfs diff=lfs merge=binary -text +# app +*.[aA][pP][pP] filter=lfs diff=lfs merge=binary -text +# ipa +*.[iI][pP][aA] filter=lfs diff=lfs merge=binary -text +# dmg +*.[dD][mM][gG] filter=lfs diff=lfs merge=binary -text +# exe +*.[eE][xX][eE] filter=lfs diff=lfs merge=binary -text +# pdb +*.[pP][dD][bB] filter=lfs diff=lfs merge=binary -text +# dbg +*.[dD][bB][gG] filter=lfs diff=lfs merge=binary -text +# run +*.[rR][uU][nN] filter=lfs diff=lfs merge=binary -text +# pyd +*.[pP][yY][dD] filter=lfs diff=lfs merge=binary -text +# pyc +*.[pP][yY][cC] filter=lfs diff=lfs merge=binary -text +# nupkg, NuGet package +*.[nN][uU][pP][kK][gG] filter=lfs diff=lfs merge=binary -text +# pch +*.[pP][cC][hH] filter=lfs diff=lfs merge=binary -text +# ilk +*.[iI][lL][kK] filter=lfs diff=lfs merge=binary -text +# debug +*.[dD][eE][bB][uU][gG] filter=lfs diff=lfs merge=binary -text +# obj +*.[oO][bB][jJ] filter=lfs diff=lfs merge=binary -text +# stub +*.[sS][tT][uU][bB] filter=lfs diff=lfs merge=binary -text +# ddp +*.[dD][dD][pP] filter=lfs diff=lfs merge=binary -text +# sym +*.[sS][yY][mM] filter=lfs diff=lfs merge=binary -text +# lld +*.[lL][lL][dD] filter=lfs diff=lfs merge=binary -text +# res +*.[rR][eE][sS] filter=lfs diff=lfs merge=binary -text +# locres +*.[lL][oO][cC][rR][eE][sS] filter=lfs diff=lfs merge=binary -text +# aar +*.[aA][aA][rR] filter=lfs diff=lfs merge=binary -text +# udd +*.[uU][dD][dD] filter=lfs diff=lfs merge=binary -text +# mdb +*.[mM][dD][bB] filter=lfs diff=lfs merge=binary -text +# ddc +*.[dD][dD][cC] filter=lfs diff=lfs merge=binary -text +# udn +*.[uU][dD][nN] filter=lfs diff=lfs merge=binary -text +# h5 +*.[hH]5 filter=lfs diff=lfs merge=binary -text + + + +# 压缩包,Archive format +# =Archiving only= +# ar +*.[aA][rR] filter=lfs diff=lfs merge=binary -text +# cpio +*.[cC][pP][iI][oO] filter=lfs diff=lfs merge=binary -text +# shar +*.[sS][hH][aA][rR] filter=lfs diff=lfs merge=binary -text +# tar +*.[tT][aA][rR] filter=lfs diff=lfs merge=binary -text +# lbr +*.[lL][bB][rR] filter=lfs diff=lfs merge=binary -text +# =Compression only= +# Brotli +*.[bB][rR][oO][tT][lL][iI] filter=lfs diff=lfs merge=binary -text +# zip +*.[zZ][iI][pP] filter=lfs diff=lfs merge=binary -text +# bzip2 +*.[bB][zZ][iI][pP]2 filter=lfs diff=lfs merge=binary -text +# compress +*.[cC][oO][mM][pP][rR][eE][sS][sS] filter=lfs diff=lfs merge=binary -text +# gzip +*.[gG][zZ][iI][pP] filter=lfs diff=lfs merge=binary -text +# zopfli +*.[zZ][oO][pP][fF][lL][iI] filter=lfs diff=lfs merge=binary -text +# LZMA +*.[lL][zZ][mM][aA] filter=lfs diff=lfs merge=binary -text +# LZ4 +*.[lL][zZ]4 filter=lfs diff=lfs merge=binary -text +# lzip +*.[lL][zZ][iI][pP] filter=lfs diff=lfs merge=binary -text +# lzop +*.[lL][zZ][oO][pP] filter=lfs diff=lfs merge=binary -text +# SQ +*.[sS][qQ] filter=lfs diff=lfs merge=binary -text +# xz +*.[xX][zZ] filter=lfs diff=lfs merge=binary -text +# Zstandard +*.[zZ][sS][tT][aA][nN][dD][aA][rR][dD] filter=lfs diff=lfs merge=binary -text +# =Archiving and compression= +# 7z +*.7[zZ] filter=lfs diff=lfs merge=binary -text +# ace +*.[aA][cC][eE] filter=lfs diff=lfs merge=binary -text +# arc +*.[aA][rR][cC] filter=lfs diff=lfs merge=binary -text +# arj +*.[aA][rR][jJ] filter=lfs diff=lfs merge=binary -text +# b1 +*.[bB]1 filter=lfs diff=lfs merge=binary -text +# cabinet +*.[cC][aA][bB][iI][nN][eE][tT] filter=lfs diff=lfs merge=binary -text +# cfs +*.[cC][fF][sS] filter=lfs diff=lfs merge=binary -text +# cpt +*.[cC][pP][tT] filter=lfs diff=lfs merge=binary -text +# dar +*.[dD][aA][rR] filter=lfs diff=lfs merge=binary -text +# dgca +*.[dD][gG][cC][aA] filter=lfs diff=lfs merge=binary -text +# dmg +*.[dD][mM][gG] filter=lfs diff=lfs merge=binary -text +# egg +*.[eE][gG][gG] filter=lfs diff=lfs merge=binary -text +# kgb +*.[kK][gG][bB] filter=lfs diff=lfs merge=binary -text +# lha +*.[lL][hH][aA] filter=lfs diff=lfs merge=binary -text +# lzx +*.[lL][zZ][xX] filter=lfs diff=lfs merge=binary -text +# mpq +*.[mM][pP][qQ] filter=lfs diff=lfs merge=binary -text +# pea +*.[pP][eE][aA] filter=lfs diff=lfs merge=binary -text +# rar +*.[rR][aA][rR] filter=lfs diff=lfs merge=binary -text +# rzip +*.[rR][zZ][iI][pP] filter=lfs diff=lfs merge=binary -text +# sit +*.[sS][iI][tT] filter=lfs diff=lfs merge=binary -text +# sitx +*.[sS][iI][tT][xX] filter=lfs diff=lfs merge=binary -text +# sqx +*.[sS][qQ][xX] filter=lfs diff=lfs merge=binary -text +# uda +*.[uU][dD][aA] filter=lfs diff=lfs merge=binary -text +# xar +*.[xX][aA][rR] filter=lfs diff=lfs merge=binary -text +# zoo +*.[zZ][oO][oO] filter=lfs diff=lfs merge=binary -text +# zpaq +*.[zZ][pP][aA][qQ] filter=lfs diff=lfs merge=binary -text +# =Software packaging and distribution= +# apk +*.[aA][pP][kK] filter=lfs diff=lfs merge=binary -text +# appx +*.[aA][pP][pP][xX] filter=lfs diff=lfs merge=binary -text +# deb +*.[dD][eE][bB] filter=lfs diff=lfs merge=binary -text +# rpm +*.[rR][pP][mM] filter=lfs diff=lfs merge=binary -text +# msi +*.[mM][sS][iI] filter=lfs diff=lfs merge=binary -text +# ipa +*.[iI][pP][aA] filter=lfs diff=lfs merge=binary -text +# jar +*.[jJ][aA][rR] filter=lfs diff=lfs merge=binary -text +# war +*.[wW][aA][rR] filter=lfs diff=lfs merge=binary -text +# ear +*.[eE][aA][rR] filter=lfs diff=lfs merge=binary -text +# xap +*.[xX][aA][pP] filter=lfs diff=lfs merge=binary -text +# xbap +*.[xX][bB][aA][pP] filter=lfs diff=lfs merge=binary -text +# hap +*.[hH][aA][pP] filter=lfs diff=lfs merge=binary -text +# app +*.[aA][pP][pP] filter=lfs diff=lfs merge=binary -text +# gz +*.[gG][zZ] filter=lfs diff=lfs merge=binary -text +# tgz +*.[tT][gG][zZ] filter=lfs diff=lfs merge=binary -text +# bz2 +*.[bB][zZ]2 filter=lfs diff=lfs merge=binary -text +# z +*.[zZ] filter=lfs diff=lfs merge=binary -text +# pak +*.[pP][aA][kK] filter=lfs diff=lfs merge=binary -text +# archive +*.[aA][rR][cC][hH][iI][vV][eE] filter=lfs diff=lfs merge=binary -text +# vsix +*.[vV][sS][iI][xX] filter=lfs diff=lfs merge=binary -text +# disk image +# iso +*.[iI][sS][oO] filter=lfs diff=lfs merge=binary -text +# bin +*.[bB][iI][nN] filter=lfs diff=lfs merge=binary -text +# cue +*.[cC][uU][eE] filter=lfs diff=lfs merge=binary -text +# raw +*.[rR][aA][wW] filter=lfs diff=lfs merge=binary -text + +# Adobe +# Photoshop +# psd +*.[pP][sS][dD] filter=lfs diff=lfs merge=binary -text +# Illustrator +# ai +*.[aA][iI] filter=lfs diff=lfs merge=binary -text +# eps +*.[eE][pP][sS] filter=lfs diff=lfs merge=binary -text +# pdf +*.[pP][dD][fF] filter=lfs diff=lfs merge=binary -text + +# 原始图片,Raw image +# cr2 +*.[cC][rR]2 filter=lfs diff=lfs merge=binary -text +# crw +*.[cC][rR][wW] filter=lfs diff=lfs merge=binary -text +# nef +*.[nN][eE][fF] filter=lfs diff=lfs merge=binary -text +# nrw +*.[nN][rR][wW] filter=lfs diff=lfs merge=binary -text +# sr2 +*.[sS][rR]2 filter=lfs diff=lfs merge=binary -text +# dng +*.[dD][nN][gG] filter=lfs diff=lfs merge=binary -text +# arw +*.[aA][rR][wW] filter=lfs diff=lfs merge=binary -text +# ort +*.[oO][rR][fF] filter=lfs diff=lfs merge=binary -text +# fbx +*.[fF][bB][xX] filter=lfs diff=lfs merge=binary -text +# 3ds +*.3[dD][sS] filter=lfs diff=lfs merge=binary -text +# xcf +*.[xX][cC][fF] filter=lfs diff=lfs merge=binary -text +# hdr +*.[hH][dD][rR] filter=lfs diff=lfs merge=binary -text +# duf +*.[dD][uU][fF] filter=lfs diff=lfs merge=binary -text +# mb, maya +*.[mM][bB] filter=lfs diff=lfs merge=binary -text +# cubemap,unity 贴图 +*.[cC][uU][bB][eE][mM][aA][pP] filter=lfs diff=lfs merge=binary -text +# navmesh,unity +*.[nN][aA][vV][mM][eE][sS][hH] filter=lfs diff=lfs merge=binary -text +# osm,地理数据 +*.[oO][sS][mM] filter=lfs diff=lfs merge=binary -text +# hip, houdini +*.[hH][iI][pP] filter=lfs diff=lfs merge=binary -text +# cdr +*.[cC][dD][rR] filter=lfs diff=lfs merge=binary -text +# raw +*.[rR][aA][wW] filter=lfs diff=lfs merge=binary -text +# dae +*.[dD][aA][eE] filter=lfs diff=lfs merge=binary -text +# hda, houdini +*.[hH][dD][aA] filter=lfs diff=lfs merge=binary -text +# geo, houdini +*.[gG][eE][oO] filter=lfs diff=lfs merge=binary -text +# bgeo, houdini +*.[bB][gG][eE][oO] filter=lfs diff=lfs merge=binary -text +# ma, 3dmax +*.[mM][aA] filter=lfs diff=lfs merge=binary -text +# max, 3dmax +*.[mM][aA][xX] filter=lfs diff=lfs merge=binary -text +# 3dm, 3d模型 +*.3[dD][mM] filter=lfs diff=lfs merge=binary -text +# blend +*.[bB][lL][eE][nN][dD] filter=lfs diff=lfs merge=binary -text +# c4d +*.[cC]4[dD] filter=lfs diff=lfs merge=binary -text +# collada +*.[cC][oO][lL][lL][aA][dD][aA] filter=lfs diff=lfs merge=binary -text +# dxf +*.[dD][xX][fF] filter=lfs diff=lfs merge=binary -text +# jas +*.[jJ][aA][sS] filter=lfs diff=lfs merge=binary -text +# lws +*.[lL][wW][sS] filter=lfs diff=lfs merge=binary -text +# lxo +*.[lL][xX][oO] filter=lfs diff=lfs merge=binary -text +# ply +*.[pP][lL][yY] filter=lfs diff=lfs merge=binary -text +# skp +*.[sS][kK][pP] filter=lfs diff=lfs merge=binary -text +# stl +*.[sS][tT][lL] filter=lfs diff=lfs merge=binary -text +# ztl +*.[zZ][tT][lL] filter=lfs diff=lfs merge=binary -text +# it +*.[iI][tT] filter=lfs diff=lfs merge=binary -text +# mod +*.[mM][oO][dD] filter=lfs diff=lfs merge=binary -text +# ogg +*.[oO][gG][gG] filter=lfs diff=lfs merge=binary -text +# s3m +*.[sS]3[mM] filter=lfs diff=lfs merge=binary -text +# xm +*.[xX][mM] filter=lfs diff=lfs merge=binary -text +# glb +*.[gG][lL][bB] filter=lfs diff=lfs merge=binary -text +# gltf +*.[gG][lL][tT][fF] filter=lfs diff=lfs merge=binary -text +# off +*.[oO][fF][fF] filter=lfs diff=lfs merge=binary -text +# wrl +*.[wW][rR][lL] filter=lfs diff=lfs merge=binary -text +# 3mf +*.3[mM][fF] filter=lfs diff=lfs merge=binary -text +# amf +*.[aA][mM][fF] filter=lfs diff=lfs merge=binary -text +# ifc +*.[iI][fF][cC] filter=lfs diff=lfs merge=binary -text +# brep +*.[bB][rR][eE][pP] filter=lfs diff=lfs merge=binary -text +# step +*.[sS][tT][eE][pP] filter=lfs diff=lfs merge=binary -text +# fcstd +*.[fF][cC][sS][tT][dD] filter=lfs diff=lfs merge=binary -text +# bim +*.[bB][iI][mM] filter=lfs diff=lfs merge=binary -text + +# 图像,Image +# jpg +*.[jJ][pP][gG] filter=lfs diff=lfs merge=binary -text +# jpeg +*.[jJ][pP][eE][gG] filter=lfs diff=lfs merge=binary -text +# tiff +*.[tT][iI][fF][fF] filter=lfs diff=lfs merge=binary -text +# gif +*.[gG][iI][fF] filter=lfs diff=lfs merge=binary -text +# svg +*.[sS][vV][gG] filter=lfs diff=lfs merge=binary -text +# svgz +*.[sS][vV][gG][zZ] filter=lfs diff=lfs merge=binary -text +# bmp +*.[bB][mM][pP] filter=lfs diff=lfs merge=binary -text +# png +*.[pP][nN][gG] filter=lfs diff=lfs merge=binary -text +# tif +*.[tT][iI][fF] filter=lfs diff=lfs merge=binary -text +# tga +*.[tT][gG][aA] filter=lfs diff=lfs merge=binary -text +# prj +*.[pP][rR][jJ] filter=lfs diff=lfs merge=binary -text +# dwg +*.[dD][wW][gG] filter=lfs diff=lfs merge=binary -text +# flt +*.[fF][lL][tT] filter=lfs diff=lfs merge=binary -text +# htr +*.[hH][tT][rR] filter=lfs diff=lfs merge=binary -text +# iges +*.[iI][gG][eE][sS] filter=lfs diff=lfs merge=binary -text +# igs +*.[iI][gG][sS] filter=lfs diff=lfs merge=binary -text +# ige +*.[iI][gG][eE] filter=lfs diff=lfs merge=binary -text +# ipt +*.[iI][pP][tT] filter=lfs diff=lfs merge=binary -text +# iam +*.[iI][aA][mM] filter=lfs diff=lfs merge=binary -text +# lp +*.[lL][pP] filter=lfs diff=lfs merge=binary -text +# ls +*.[lL][sS] filter=lfs diff=lfs merge=binary -text +# shp +*.[sS][hH][pP] filter=lfs diff=lfs merge=binary -text +# aep +*.[aA][eE][pP] filter=lfs diff=lfs merge=binary -text +# psb +*.[pP][sS][bB] filter=lfs diff=lfs merge=binary -text +# edx +*.[eE][dD][xX] filter=lfs diff=lfs merge=binary -text +# cds +*.[cC][dD][sS] filter=lfs diff=lfs merge=binary -text +# exr +*.[eE][xX][rR] filter=lfs diff=lfs merge=binary -text +# bc +*.[bB][cC] filter=lfs diff=lfs merge=binary -text + + +# 文档,Document +# Microsoft Excel +# xls +*.[xX][lL][sS] filter=lfs diff=lfs merge=binary -text +# xlsx +*.[xX][lL][sS][xX] filter=lfs diff=lfs merge=binary -text +# xslsm +*.[xX][sS][lL][sS][mM] filter=lfs diff=lfs merge=binary -text +# xlt +*.[xX][lL][tT] filter=lfs diff=lfs merge=binary -text +# xltx +*.[xX][lL][tT][xX] filter=lfs diff=lfs merge=binary -text +# xltm +*.[xX][lL][tT][mM] filter=lfs diff=lfs merge=binary -text +# Microsoft powperpoint +# ppt +*.[pP][pP][tT] filter=lfs diff=lfs merge=binary -text +# pptx +*.[pP][pP][tT][xX] filter=lfs diff=lfs merge=binary -text +# pps +*.[pP][pP][sS] filter=lfs diff=lfs merge=binary -text +# ppsx +*.[pP][pP][sS][xX] filter=lfs diff=lfs merge=binary -text +# ppsm +*.[pP][pP][sS][mM] filter=lfs diff=lfs merge=binary -text +# pptm +*.[pP][pP][tT][mM] filter=lfs diff=lfs merge=binary -text +# pot +*.[pP][oO][tT] filter=lfs diff=lfs merge=binary -text +# potm +*.[pP][oO][tT][mM] filter=lfs diff=lfs merge=binary -text +# Microsoft word +# doc +*.[dD][oO][cC] filter=lfs diff=lfs merge=binary -text +# docx +*.[dD][oO][cC][xX] filter=lfs diff=lfs merge=binary -text +# docm +*.[dD][oO][cC][mM] filter=lfs diff=lfs merge=binary -text +# dot +*.[dD][oO][tT] filter=lfs diff=lfs merge=binary -text +# dotx +*.[dD][oO][tT][xX] filter=lfs diff=lfs merge=binary -text +# dotm +*.[dD][oO][tT][mM] filter=lfs diff=lfs merge=binary -text +# Apple keynotes +# key +*.[kK][eE][yY] filter=lfs diff=lfs merge=binary -text +# Apple pages +# pages, apple +*.[pP][aA][gG][eE][sS] filter=lfs diff=lfs merge=binary -text +# Apple numbers +# numbers, apple +*.[nN][uU][mM][bB][eE][rR][sS] filter=lfs diff=lfs merge=binary -text + +# 电子书,Book +# chm +*.[cC][hH][mM] filter=lfs diff=lfs merge=binary -text +# mobi +*.[mM][oO][bB][iI] filter=lfs diff=lfs merge=binary -text +# epub +*.[eE][pP][uU][bB] filter=lfs diff=lfs merge=binary -text +# azw +*.[aA][zZ][wW] filter=lfs diff=lfs merge=binary -text +# azw3 +*.[aA][zZ][wW]3 filter=lfs diff=lfs merge=binary -text +# iba +*.[iI][bB][aA] filter=lfs diff=lfs merge=binary -text +# lrs +*.[lL][rR][sS] filter=lfs diff=lfs merge=binary -text +# lrf +*.[lL][rR][fF] filter=lfs diff=lfs merge=binary -text +# lrx +*.[lL][rR][xX] filter=lfs diff=lfs merge=binary -text +# djvu +*.[dD][jJ][vV][uU] filter=lfs diff=lfs merge=binary -text +# lit +*.[lL][iI][tT] filter=lfs diff=lfs merge=binary -text +# rft +*.[rR][fF][tT] filter=lfs diff=lfs merge=binary -text +# cbr +*.[cC][bB][rR] filter=lfs diff=lfs merge=binary -text +# cbz +*.[cC][bB][zZ] filter=lfs diff=lfs merge=binary -text +# cb7 +*.[cC][bB]7 filter=lfs diff=lfs merge=binary -text +# cbt +*.[cC][bB][tT] filter=lfs diff=lfs merge=binary -text +# cba +*.[cC][bB][aA] filter=lfs diff=lfs merge=binary -text +# pdb +*.[pP][dD][bB] filter=lfs diff=lfs merge=binary -text + +# 字体,font +# ttf +*.[tT][tT][fF] filter=lfs diff=lfs merge=binary -text +# otf +*.[oO][tT][fF] filter=lfs diff=lfs merge=binary -text +# woff +*.[wW][oO][fF][fF] filter=lfs diff=lfs merge=binary -text +# woff2 +*.[wW][oO][fF][fF]2 filter=lfs diff=lfs merge=binary -text + +# 翻译,translate +# po +*.[pP][oO] filter=lfs diff=lfs merge=binary -text +# auto generated by UGit +*.tar filter=lfs diff=lfs merge=binary -text +*.mod filter=lfs diff=lfs merge=binary -text +go.mod !filter=lfs !diff=lfs !merge=binary -text + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d367257 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Build artifacts +dd_fiber_api +*.tar +*.tar.gz + +# Config files with secrets +config.local.yaml + +# OS +.DS_Store +Thumbs.db + +# 部署相关 +deploy/*.tar +deploy/config.yaml +deploy/storage/ +bin/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6f1f7b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# DD Fiber API Dockerfile +FROM alpine:3.20 + +# 安装必要的运行时依赖 +RUN apk add --no-cache ca-certificates tzdata + +# 设置时区 +ENV TZ=Asia/Shanghai + +# 创建工作目录 +WORKDIR /app + +# 复制编译好的二进制文件 +COPY bin/dd-fiber-api /app/dd-fiber-api + +# 暴露端口(Admin 和 API) +EXPOSE 8080 8081 + +# 运行服务(配置文件通过挂载提供) +ENTRYPOINT ["/app/dd-fiber-api", "-config", "/app/data/config/config.yaml"] + diff --git a/INSTALL_MONGODB.md b/INSTALL_MONGODB.md new file mode 100644 index 0000000..ec7e126 --- /dev/null +++ b/INSTALL_MONGODB.md @@ -0,0 +1,106 @@ +# MongoDB 安装和配置指南 + +## 一、解决 Docker 镜像拉取问题 + +### 方法 1: 配置 Docker 镜像加速器(推荐) + +**macOS (Docker Desktop)**: +1. 打开 Docker Desktop +2. 进入 Settings -> Docker Engine +3. 添加以下配置: +```json +{ + "registry-mirrors": [ + "https://docker.mirrors.ustc.edu.cn", + "https://hub-mirror.c.163.com", + "https://mirror.baidubce.com" + ] +} +``` +4. 点击 "Apply & Restart" +5. 重新拉取镜像:`docker pull mongo:7.0` + +**Linux**: +```bash +sudo mkdir -p /etc/docker +sudo tee /etc/docker/daemon.json <<-'EOF' +{ + "registry-mirrors": [ + "https://docker.mirrors.ustc.edu.cn", + "https://hub-mirror.c.163.com", + "https://mirror.baidubce.com" + ] +} +EOF +sudo systemctl restart docker +docker pull mongo:7.0 +``` + +### 方法 2: 使用国内镜像源 + +```bash +# 使用阿里云镜像 +docker pull registry.cn-hangzhou.aliyuncs.com/acs/mongo:7.0 +docker tag registry.cn-hangzhou.aliyuncs.com/acs/mongo:7.0 mongo:7.0 + +# 或使用网易镜像 +docker pull hub-mirror.c.163.com/library/mongo:7.0 +docker tag hub-mirror.c.163.com/library/mongo:7.0 mongo:7.0 +``` + +## 二、安装 Go MongoDB 驱动 + +```bash +cd dd_fiber_api +go get go.mongodb.org/mongo-driver/mongo +go get go.mongodb.org/mongo-driver/bson +go get go.mongodb.org/mongo-driver/mongo/options +go mod tidy +``` + +## 三、启动 MongoDB + +```bash +cd dd_fiber_api/deploy +chmod +x mongodb-start.sh mongodb-stop.sh +./mongodb-start.sh +``` + +## 四、验证连接 + +```bash +# 进入 MongoDB Shell +docker exec -it mongodb-question mongosh -u admin -p admin123456 --authenticationDatabase admin + +# 测试连接 +use question_db +db.questions.insertOne({_id: "test", title: "测试题目"}) +db.questions.find() +``` + +## 五、已创建的文件 + +1. ✅ `pkg/database/mongodb.go` - MongoDB 客户端封装 +2. ✅ `internal/question/dao/question_dao_mongo.go` - 题目 DAO 实现示例 +3. ✅ `deploy/docker-compose.mongodb.yml` - Docker Compose 配置 +4. ✅ `deploy/mongodb-start.sh` - 启动脚本(支持自动使用国内镜像) +5. ✅ `deploy/mongodb-stop.sh` - 停止脚本 +6. ✅ `config/config.go` - 已添加 MongoDBConfig +7. ✅ `config.yaml` - 已添加 MongoDB 配置示例 + +## 六、MongoDB 实现优势 + +相比 MySQL,MongoDB 在题库系统中的优势: + +1. **文档模型**:天然支持 JSON,题目、试卷等复杂结构更直观 +2. **数组查询**:标签等数组字段查询更方便 +3. **全文搜索**:内置文本索引,支持全文搜索 +4. **灵活扩展**:无需预定义表结构,易于扩展 +5. **性能优秀**:读性能好,适合读多写少的场景 + +## 七、注意事项 + +1. **全文搜索**:MongoDB 的文本索引对中文支持有限,可能需要使用 bleve 等第三方库 +2. **事务**:MongoDB 4.0+ 支持事务,但性能不如 MySQL +3. **数据备份**:定期备份 MongoDB 数据 + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a644cb --- /dev/null +++ b/Makefile @@ -0,0 +1,169 @@ +.PHONY: build run docker-build docker-run docker-clean tar tar-mac + +# 变量定义 +BINARY_NAME := dd-fiber-api +BINARY_PATH := bin/$(BINARY_NAME) + +# 构建参数(可通过 make tar OS=darwin ARCH=arm64 等方式指定) +OS ?= linux +ARCH ?= amd64 + +# 创建必要的目录 +init: + mkdir -p bin + mkdir -p deploy + +# 生成 Wire 代码 +wire: + @echo "生成 Wire 依赖注入代码..." + @WIRE_BIN=$$(go env GOPATH)/bin/wire; \ + if [ -f "$$WIRE_BIN" ]; then \ + $$WIRE_BIN gen ./internal/wire; \ + elif command -v wire >/dev/null 2>&1; then \ + wire gen ./internal/wire; \ + else \ + echo "使用 go run 方式运行 Wire..."; \ + go run github.com/google/wire/cmd/wire@latest gen ./internal/wire; \ + fi + @echo "✅ Wire 代码生成完成" + +# 构建项目 +build: wire + @echo "构建项目..." + @mkdir -p bin + @CGO_ENABLED=0 GOARCH=$(ARCH) GOOS=$(OS) go build -ldflags="-s -w" -o $(BINARY_PATH) . + @echo "✅ 构建完成: $(BINARY_PATH)" + +# 运行服务(开发环境) +run: + @echo "启动服务..." + @go run . -config config.yaml + +# 使用 Air 热重载运行(开发环境推荐) +air: + @echo "使用 Air 热重载启动服务..." + @AIR_BIN=$$(go env GOPATH)/bin/air; \ + if [ -f "$$AIR_BIN" ]; then \ + $$AIR_BIN; \ + elif command -v air >/dev/null 2>&1; then \ + air; \ + else \ + echo "❌ Air 未安装,请运行: go install github.com/air-verse/air@latest"; \ + echo "或者使用 make run 运行"; \ + exit 1; \ + fi + +# 打包部署(默认 Linux,可通过 OS=darwin ARCH=arm64 指定 macOS) +tar: + @if [ "$(OS)" = "darwin" ]; then \ + echo "构建 macOS 版本 ($(ARCH))..."; \ + echo "清理旧编译文件和生成代码..."; \ + rm -rf bin/; \ + rm -f internal/wire/wire_gen.go; \ + echo "生成 Wire 依赖注入代码..."; \ + WIRE_BIN=$$(go env GOPATH)/bin/wire; \ + if [ -f "$$WIRE_BIN" ]; then \ + $$WIRE_BIN gen ./internal/wire || exit 1; \ + elif command -v wire >/dev/null 2>&1; then \ + wire gen ./internal/wire || exit 1; \ + else \ + echo "使用 go run 方式运行 Wire..."; \ + go run github.com/google/wire/cmd/wire@latest gen ./internal/wire || exit 1; \ + fi; \ + if [ ! -f "internal/wire/wire_gen.go" ]; then \ + echo "❌ Wire 代码生成失败"; \ + exit 1; \ + fi; \ + echo "✅ Wire 代码生成完成"; \ + echo "编译 macOS 版本 ($(ARCH))..."; \ + mkdir -p bin; \ + CGO_ENABLED=0 GOOS=darwin GOARCH=$(ARCH) go build -ldflags="-s -w" -o $(BINARY_PATH) . || exit 1; \ + if [ ! -f "$(BINARY_PATH)" ]; then \ + echo "❌ 编译失败"; \ + exit 1; \ + fi; \ + echo "✅ macOS 版本构建完成: $(BINARY_PATH)"; \ + ls -lh $(BINARY_PATH); \ + else \ + echo "开始创建 Linux 部署包..."; \ + if ! command -v docker >/dev/null 2>&1; then \ + echo "❌ 未找到 Docker"; \ + exit 1; \ + fi; \ + echo "清理旧编译文件和生成代码..."; \ + rm -rf bin/; \ + rm -f internal/wire/wire_gen.go; \ + echo "生成 Wire 依赖注入代码..."; \ + WIRE_BIN=$$(go env GOPATH)/bin/wire; \ + if [ -f "$$WIRE_BIN" ]; then \ + $$WIRE_BIN gen ./internal/wire || exit 1; \ + elif command -v wire >/dev/null 2>&1; then \ + wire gen ./internal/wire || exit 1; \ + else \ + echo "使用 go run 方式运行 Wire..."; \ + go run github.com/google/wire/cmd/wire@latest gen ./internal/wire || exit 1; \ + fi; \ + if [ ! -f "internal/wire/wire_gen.go" ]; then \ + echo "❌ Wire 代码生成失败"; \ + exit 1; \ + fi; \ + echo "✅ Wire 代码生成完成"; \ + echo "编译 Linux 版本 ($(ARCH))..."; \ + mkdir -p bin; \ + CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) go build -ldflags="-s -w" -o $(BINARY_PATH) . || exit 1; \ + if [ ! -f "$(BINARY_PATH)" ]; then \ + echo "❌ 编译失败"; \ + exit 1; \ + fi; \ + echo "✅ 编译完成: $(BINARY_PATH)"; \ + ls -lh $(BINARY_PATH); \ + echo "清理旧 Docker 镜像..."; \ + docker rmi dd-fiber-api:latest 2>/dev/null || true; \ + echo "构建容器镜像 (linux/amd64)..."; \ + docker build --platform=linux/amd64 --no-cache -t dd-fiber-api:latest .; \ + echo "导出部署包..."; \ + mkdir -p deploy; \ + rm -f deploy/dd-fiber-api.tar; \ + docker save -o deploy/dd-fiber-api.tar dd-fiber-api:latest; \ + echo "✅ 部署包创建完成: deploy/dd-fiber-api.tar"; \ + ls -lh deploy/dd-fiber-api.tar; \ + fi + +# 快速构建 macOS 版本(便捷方法) +tar-mac: + @$(MAKE) tar OS=darwin ARCH=arm64 + +# 清理构建产物 +clean: + @echo "清理构建产物..." + @rm -rf bin/ + @rm -f deploy/dd-fiber-api.tar + @echo "✅ 清理完成" + +# Docker 构建(本地测试用) +docker-build: + @echo "构建 Docker 镜像..." + @docker build -t dd-fiber-api:latest . + @echo "✅ Docker 镜像构建完成" + +# Docker 运行(本地测试用) +docker-run: + @echo "运行 Docker 容器..." + @docker run -d \ + --name dd-fiber-api \ + --restart=unless-stopped \ + -p 8080:8080 \ + -p 8081:8081 \ + -v $(PWD)/deploy/config.yaml:/app/data/config/config.yaml:ro \ + -v $(PWD)/deploy/storage:/app/data/storage:ro \ + dd-fiber-api:latest + @echo "✅ 容器已启动" + +# 清理 Docker 资源 +docker-clean: + @echo "清理 Docker 资源..." + @docker stop dd-fiber-api 2>/dev/null || true + @docker rm dd-fiber-api 2>/dev/null || true + @docker rmi dd-fiber-api:latest 2>/dev/null || true + @echo "✅ 清理完成" + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..4b79b3f --- /dev/null +++ b/config.yaml @@ -0,0 +1,74 @@ +# DD Fiber API 配置 + +# 服务配置 +service: + name: "dd_fiber_api" + version: "1.0.0" + host: "0.0.0.0" + admin_port: 8080 # 管理端端口 + api_port: 8081 # 小程序API端口 + +# Redis配置 +redis: + host: "localhost" + port: 6379 + password: "" + db: 0 + pool_size: 10 + min_idle_conns: 5 + +# 阿里云OSS配置 +oss: + accessKeyId: "LTAI5tHyrdcfXrTUrUReXGDE" + accessKeySecret: "XpepGFPAo7IsXgJKcmvNZ5lFrrSDww" + roleArn: "acs:ram::1482621622160970:role/webtooss" + roleSessionName: "uploadToOss" + region: "cn-hangzhou" + bucketName: "duiduiminiprogram" + +# 微信支付配置(V3版本) +wechat: + app_id: "wx583d78cc00f29dc1" # 微信小程序或公众号 APPID + mch_id: "1503017331" # 商户号 + notify_url: "https://c7b2eea80773.ngrok-free.app/api/v1/payment/wechat/v3/notify" # 支付结果通知地址 + # notify_url: "https://api.duiduiedu.com/api/v1/payment/wechat/v3/notify" # 支付结果通知地址 + api_key_v3: "XJFEnQ7cHNdsu2ZjdFJQKJyurQWhekvB" # API v3密钥 + serial_no: "1A6ACD3F2763A812371294C939660B2EF3E17076" # 证书序列号 + private_key: "" # 私钥内容(PEM格式,如果设置了 private_key_path 则留空) + private_key_path: "storage/1503017331_20251231_cert/apiclient_key.pem" # 私钥文件路径 + cert_path: "storage/1503017331_20251231_cert/apiclient_cert.pem" # 证书文件路径(可选) + +# MySQL配置(可选) +mysql: + host: "39.97.6.139" # MySQL服务器地址 + port: 33066 # MySQL端口 + username: "root" # 用户名 + password: "shuang0304" # 密码 + database: "duidui_db" # 数据库名(如果为空则不初始化MySQL) + charset: "utf8mb4" # 字符集 + max_open_conns: 100 # 最大打开连接数 + max_idle_conns: 10 # 最大空闲连接数 + conn_max_lifetime: "3600s" # 连接最大生命周期 + +# MongoDB配置(用于题库系统) +mongodb: + # uri: "mongodb://admin:admin123456@localhost:27017" # MongoDB 连接字符串 + uri: "mongodb://admin:admin123456@39.106.239.223:27017" # MongoDB 连接字符串 + # uri: "mongodb://admin:admin123456@mongodb-question:27017" # MongoDB 连接字符串 + database: "question_db" # 数据库名 + timeout: "10s" # 连接超时时间 + +# 调度器配置(时间轮定时任务) +scheduler: + tick_interval: "100ms" # 时间轮刻度间隔 + slot_num: 3600 # 槽位数量 (100ms * 3600 = 6分钟最大延迟) + +# 管理员配置 +admin: + jwt_secret: "dd_fiber_api_admin_secret_key_2024" # JWT密钥 + jwt_expires_in: "24h" # JWT过期时间 + super_admin: + username: "admin" # 超级管理员用户名 + password: "" # 超级管理员密码(bcrypt加密后的,首次启动会自动创建) + email: "admin@duiduiedu.com" # 超级管理员邮箱 + diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d75959b --- /dev/null +++ b/config/config.go @@ -0,0 +1,255 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" +) + +// Config 应用配置 +type Config struct { + Service ServiceConfig `mapstructure:"service"` + Redis RedisConfig `mapstructure:"redis"` + MySQL MySQLConfig `mapstructure:"mysql"` + MongoDB MongoDBConfig `mapstructure:"mongodb"` + OSS OSSConfig `mapstructure:"oss"` + Wechat WechatConfig `mapstructure:"wechat"` + Scheduler SchedulerConfig `mapstructure:"scheduler"` + Admin AdminConfig `mapstructure:"admin"` +} + +// ServiceConfig 服务配置 +type ServiceConfig struct { + Name string `mapstructure:"name"` + Version string `mapstructure:"version"` + Host string `mapstructure:"host"` + AdminPort int `mapstructure:"admin_port"` + APIPort int `mapstructure:"api_port"` +} + +// RedisConfig Redis配置 +type RedisConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` + PoolSize int `mapstructure:"pool_size"` + MinIdleConns int `mapstructure:"min_idle_conns"` +} + +// OSSConfig 阿里云OSS配置 +type OSSConfig struct { + AccessKeyID string `mapstructure:"accessKeyId"` + AccessKeySecret string `mapstructure:"accessKeySecret"` + RoleARN string `mapstructure:"roleArn"` + RoleSessionName string `mapstructure:"roleSessionName"` + Region string `mapstructure:"region"` + BucketName string `mapstructure:"bucketName"` +} + +// WechatConfig 微信支付配置 +type WechatConfig struct { + AppID string `mapstructure:"app_id"` // 微信小程序或公众号 APPID + MchID string `mapstructure:"mch_id"` // 商户号 + NotifyURL string `mapstructure:"notify_url"` // 支付结果通知地址 + APIKeyV3 string `mapstructure:"api_key_v3"` // API v3密钥 + SerialNo string `mapstructure:"serial_no"` // 证书序列号 + PrivateKey string `mapstructure:"private_key"` // 私钥内容(PEM格式) + PrivateKeyPath string `mapstructure:"private_key_path"` // 私钥文件路径 + CertPath string `mapstructure:"cert_path"` // 证书文件路径(可选) +} + +// MySQLConfig MySQL配置 +type MySQLConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + Database string `mapstructure:"database"` + Charset string `mapstructure:"charset"` + MaxOpenConns int `mapstructure:"max_open_conns"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + ConnMaxLifetime string `mapstructure:"conn_max_lifetime"` // 如 "3600s" +} + +// MongoDBConfig MongoDB配置 +type MongoDBConfig struct { + URI string `mapstructure:"uri"` // 连接字符串,如: mongodb://admin:password@localhost:27017 + Database string `mapstructure:"database"` // 数据库名 + Timeout string `mapstructure:"timeout"` // 连接超时,如 "10s" +} + +// SchedulerConfig 调度器配置 +type SchedulerConfig struct { + TickInterval string `mapstructure:"tick_interval"` // 时间轮刻度间隔,如 "100ms" + SlotNum int `mapstructure:"slot_num"` // 槽位数量 +} + +// AdminConfig 管理员配置 +type AdminConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` // JWT密钥 + JWTExpiresIn string `mapstructure:"jwt_expires_in"` // JWT过期时间,如 "24h" +} + +// LoadConfig 加载配置文件 +// 支持从多个位置查找配置文件(优先级从高到低): +// 1. 命令行参数指定的路径 +// 2. /app/data/config/config.yaml(容器内挂载路径) +// 3. ./data/config/config.yaml(开发环境) +// 4. ./config.yaml(项目根目录,兼容旧配置) +func LoadConfig(configFile string) (*Config, error) { + v := viper.New() + v.SetConfigType("yaml") + + // 如果指定了配置文件且文件存在,直接使用 + if configFile != "" && configFile != "config.yaml" { + if _, err := os.Stat(configFile); err == nil { + v.SetConfigFile(configFile) + } + } + + // 如果还没有设置配置文件,尝试从多个位置查找 + if v.ConfigFileUsed() == "" { + configPaths := []string{ + "/app/data/config/config.yaml", // 容器内挂载路径 + "./data/config/config.yaml", // 开发环境 + "./config.yaml", // 项目根目录(兼容旧配置) + } + + for _, path := range configPaths { + if _, err := os.Stat(path); err == nil { + v.SetConfigFile(path) + break + } + } + } + + // 如果仍然没有找到配置文件,使用默认路径 + if v.ConfigFileUsed() == "" { + if configFile != "" { + v.SetConfigFile(configFile) + } else { + v.SetConfigFile("config.yaml") + } + } + + // 设置默认值 + setDefaults(v) + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("读取配置文件失败: %w (尝试的路径: %s)", err, v.ConfigFileUsed()) + } + + var config Config + if err := v.Unmarshal(&config); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + + // 处理证书路径:如果是绝对路径,直接验证;如果是相对路径,尝试从多个位置查找 + if config.Wechat.PrivateKeyPath != "" { + if filepath.IsAbs(config.Wechat.PrivateKeyPath) { + // 绝对路径:直接验证文件是否存在 + if _, err := os.Stat(config.Wechat.PrivateKeyPath); err != nil { + // 如果绝对路径不存在,尝试从其他位置查找 + // 提取相对路径部分(去掉 /app/data/storage/ 或 storage/ 前缀) + relPath := config.Wechat.PrivateKeyPath + if strings.HasPrefix(relPath, "/app/data/storage/") { + relPath = strings.TrimPrefix(relPath, "/app/data/storage/") + } else if strings.HasPrefix(relPath, "storage/") { + relPath = strings.TrimPrefix(relPath, "storage/") + } + + // 尝试从多个位置查找 + certBasePaths := []string{ + "/app/data/storage", // 容器内挂载路径 + "./deploy/storage", // 部署目录 + "./data/storage", // 开发环境 + "./storage", // 项目根目录(兼容旧配置) + } + + for _, basePath := range certBasePaths { + fullPath := filepath.Join(basePath, relPath) + if _, err := os.Stat(fullPath); err == nil { + config.Wechat.PrivateKeyPath = fullPath + if config.Wechat.CertPath != "" { + certRelPath := config.Wechat.CertPath + if strings.HasPrefix(certRelPath, "/app/data/storage/") { + certRelPath = strings.TrimPrefix(certRelPath, "/app/data/storage/") + } else if strings.HasPrefix(certRelPath, "storage/") { + certRelPath = strings.TrimPrefix(certRelPath, "storage/") + } + config.Wechat.CertPath = filepath.Join(basePath, certRelPath) + } + break + } + } + } + } else { + // 相对路径:尝试从多个位置查找 + certBasePaths := []string{ + "/app/data/storage", // 容器内挂载路径 + "./deploy/storage", // 部署目录 + "./data/storage", // 开发环境 + "./storage", // 项目根目录(兼容旧配置) + } + + // 提取相对路径部分(去掉 storage/ 前缀) + relPath := config.Wechat.PrivateKeyPath + if strings.HasPrefix(relPath, "storage/") { + relPath = strings.TrimPrefix(relPath, "storage/") + } + + for _, basePath := range certBasePaths { + fullPath := filepath.Join(basePath, relPath) + if _, err := os.Stat(fullPath); err == nil { + config.Wechat.PrivateKeyPath = fullPath + if config.Wechat.CertPath != "" { + certRelPath := config.Wechat.CertPath + if strings.HasPrefix(certRelPath, "storage/") { + certRelPath = strings.TrimPrefix(certRelPath, "storage/") + } + config.Wechat.CertPath = filepath.Join(basePath, certRelPath) + } + break + } + } + } + } + + return &config, nil +} + +// setDefaults 设置默认配置值 +func setDefaults(v *viper.Viper) { + v.SetDefault("service.name", "dd_fiber_api") + v.SetDefault("service.version", "1.0.0") + v.SetDefault("service.host", "0.0.0.0") + v.SetDefault("service.admin_port", 8080) + v.SetDefault("service.api_port", 8081) + + v.SetDefault("redis.host", "localhost") + v.SetDefault("redis.port", 6379) + v.SetDefault("redis.password", "") + v.SetDefault("redis.db", 0) + v.SetDefault("redis.pool_size", 10) + v.SetDefault("redis.min_idle_conns", 5) + + v.SetDefault("mysql.host", "localhost") + v.SetDefault("mysql.port", 3306) + v.SetDefault("mysql.username", "root") + v.SetDefault("mysql.password", "") + v.SetDefault("mysql.database", "") + v.SetDefault("mysql.charset", "utf8mb4") + v.SetDefault("mysql.max_open_conns", 100) + v.SetDefault("mysql.max_idle_conns", 10) + v.SetDefault("mysql.conn_max_lifetime", "3600s") + + v.SetDefault("scheduler.tick_interval", "100ms") + v.SetDefault("scheduler.slot_num", 3600) + + v.SetDefault("admin.jwt_secret", "your-secret-key-change-in-production") + v.SetDefault("admin.jwt_expires_in", "24h") +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..989fb2d --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,107 @@ +# DD Fiber API 部署指南 + +## 📦 部署文件说明 + +``` +deploy/ +├── config.yaml # 生产环境配置文件 +├── storage/ # 证书文件目录(挂载) +│ └── 1503017331_20251231_cert/ +│ ├── apiclient_key.pem +│ └── apiclient_cert.pem +├── deploy.sh # 自动化部署脚本 +└── dd-fiber-api.tar # Docker 镜像文件(通过 make tar 生成) +``` + +## 🚀 快速部署 + +### 1. 准备部署文件 + +确保以下文件存在: +- `dd-fiber-api.tar` - Docker 镜像文件 +- `config.yaml` - 生产环境配置文件 +- `storage/` - 证书文件目录 + +### 2. 执行部署脚本 + +```bash +cd deploy +./deploy.sh +``` + +部署脚本会自动: +- 检查必要文件 +- 停止并删除旧容器 +- 加载 Docker 镜像 +- 启动新容器 +- 挂载配置文件和证书目录 + +## 📋 手动部署 + +如果不想使用部署脚本,可以手动执行: + +```bash +# 1. 加载镜像 +docker load -i dd-fiber-api.tar + +# 2. 运行容器 +docker run -d \ + --name dd-fiber-api \ + --restart=unless-stopped \ + --network dd.net \ + -p 8080:8080 \ + -p 8081:8081 \ + -v $(pwd)/config.yaml:/app/data/config/config.yaml:ro \ + -v $(pwd)/storage:/app/data/storage:ro \ + dd-fiber-api:latest +``` + +## 🔧 配置说明 + +### 端口映射 + +- `8080` - Admin 管理端端口 +- `8081` - API 小程序接口端口 + +### 挂载目录 + +- `/app/data/config/config.yaml` - 配置文件(只读) +- `/app/data/storage/` - 证书文件目录(只读) + +### 网络 + +容器会加入 `dd.net` 网络,可以与其他服务通信。 + +## 📝 常用命令 + +```bash +# 查看日志 +docker logs -f dd-fiber-api + +# 停止服务 +docker stop dd-fiber-api + +# 重启服务 +docker restart dd-fiber-api + +# 删除容器 +docker rm -f dd-fiber-api + +# 查看容器状态 +docker ps | grep dd-fiber-api +``` + +## ⚠️ 注意事项 + +1. **配置文件路径**:容器内配置文件路径为 `/app/data/config/config.yaml` +2. **证书路径**:证书文件路径需要使用容器内路径 `/app/data/storage/...` +3. **网络**:确保 `dd.net` 网络已创建:`docker network create dd.net` +4. **权限**:证书文件建议设置只读权限:`chmod 600 storage/**/*.pem` + +## 🔒 安全建议 + +1. 不要将包含敏感信息的 `config.yaml` 提交到 Git +2. 使用只读挂载(`:ro` 标志) +3. 限制证书文件权限 +4. 生产环境建议使用密钥管理服务(如 Kubernetes Secrets) + diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..b249527 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,171 @@ +#!/bin/bash + +# DD Fiber API 部署脚本 +# 用途:在服务器上自动化部署容器(使用 Docker) + +set -e # 遇到错误立即退出 + +echo "=========================================" +echo " DD Fiber API 部署脚本" +echo "=========================================" +echo "" + +# 检查 Docker 是否安装 +if ! command -v docker &> /dev/null; then + echo "❌ 错误: 未找到 Docker,请先安装 Docker" + exit 1 +fi + +CONTAINER_CMD="docker" +echo "✅ 使用 Docker 运行" +echo "" + +# 变量定义 +IMAGE_NAME="dd-fiber-api" +IMAGE_TAG="latest" +IMAGE_TAR="dd-fiber-api.tar" +CONTAINER_NAME="dd-fiber-api" +CONFIG_FILE="config.yaml" +STORAGE_DIR="storage" + +# 检查必要文件是否存在 +echo "📋 检查部署文件..." +if [ ! -f "$IMAGE_TAR" ]; then + echo "❌ 错误: 找不到 Docker 镜像文件 $IMAGE_TAR" + exit 1 +fi + +if [ ! -f "$CONFIG_FILE" ]; then + echo "❌ 错误: 找不到配置文件 $CONFIG_FILE" + exit 1 +fi + +# 检查 storage 目录是否存在 +if [ ! -d "$STORAGE_DIR" ]; then + echo "⚠️ 警告: storage 目录不存在,正在创建..." + mkdir -p "$STORAGE_DIR" + echo "✅ storage 目录已创建" + echo "⚠️ 请确保将证书文件放到 $STORAGE_DIR/1503017331_20251231_cert/ 目录中" +else + echo "✅ storage 目录存在" +fi + +# 检查证书文件是否存在(如果配置了私钥路径) +CERT_DIR="$STORAGE_DIR/1503017331_20251231_cert" +if [ -d "$CERT_DIR" ]; then + if [ -f "$CERT_DIR/apiclient_key.pem" ]; then + echo "✅ 私钥文件存在: $CERT_DIR/apiclient_key.pem" + else + echo "⚠️ 警告: 私钥文件不存在: $CERT_DIR/apiclient_key.pem" + echo "⚠️ 请确保将证书文件放到 $CERT_DIR/ 目录中" + fi + if [ -f "$CERT_DIR/apiclient_cert.pem" ]; then + echo "✅ 证书文件存在: $CERT_DIR/apiclient_cert.pem" + else + echo "⚠️ 警告: 证书文件不存在: $CERT_DIR/apiclient_cert.pem" + fi +else + echo "⚠️ 警告: 证书目录不存在: $CERT_DIR" + echo "⚠️ 如果不需要微信支付功能,可以忽略此警告" +fi + +echo "✅ 部署文件检查通过" +echo "" + +# 停止并删除旧容器(如果存在) +echo "🛑 停止旧容器..." +if $CONTAINER_CMD ps -a | grep -q "$CONTAINER_NAME"; then + $CONTAINER_CMD stop "$CONTAINER_NAME" 2>/dev/null || true + $CONTAINER_CMD rm "$CONTAINER_NAME" 2>/dev/null || true + echo "✅ 旧容器已删除" +else + echo "ℹ️ 没有运行中的旧容器" +fi +echo "" + +# 加载容器镜像 +echo "📦 加载容器镜像..." +$CONTAINER_CMD load -i "$IMAGE_TAR" +echo "✅ 容器镜像加载完成" +echo "" + +# 运行新容器 +echo "🚀 启动新容器..." +# 获取当前目录的绝对路径 +DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)" + +# 验证挂载路径 +echo "📋 验证挂载路径..." +echo " 配置文件: $DEPLOY_DIR/$CONFIG_FILE -> /app/data/config/config.yaml" +echo " 证书目录: $DEPLOY_DIR/$STORAGE_DIR -> /app/data/storage" +if [ -d "$DEPLOY_DIR/$STORAGE_DIR" ]; then + echo " ✅ 证书目录存在: $DEPLOY_DIR/$STORAGE_DIR" + if [ -d "$DEPLOY_DIR/$STORAGE_DIR/1503017331_20251231_cert" ]; then + echo " ✅ 证书子目录存在" + if [ -f "$DEPLOY_DIR/$STORAGE_DIR/1503017331_20251231_cert/apiclient_key.pem" ]; then + echo " ✅ 私钥文件存在" + else + echo " ⚠️ 私钥文件不存在" + fi + else + echo " ⚠️ 证书子目录不存在" + fi +else + echo " ⚠️ 证书目录不存在" +fi +echo "" + +$CONTAINER_CMD run -d \ + --name "$CONTAINER_NAME" \ + --restart=unless-stopped \ + --network dd.net \ + -p 8080:8080 \ + -p 8081:8081 \ + -v "$DEPLOY_DIR/$CONFIG_FILE:/app/data/config/config.yaml:ro" \ + -v "$DEPLOY_DIR/$STORAGE_DIR:/app/data/storage:ro" \ + "$IMAGE_NAME:$IMAGE_TAG" + +echo "✅ 容器已启动" +echo "" + +# 等待服务启动 +echo "⏳ 等待服务启动..." +sleep 3 + +# 检查容器状态 +echo "📊 检查服务状态..." +if $CONTAINER_CMD ps | grep -q "$CONTAINER_NAME"; then + echo "✅ 服务运行正常" + echo "" + echo "容器信息:" + $CONTAINER_CMD ps | grep "$CONTAINER_NAME" + echo "" + echo "查看日志命令:" + echo " $CONTAINER_CMD logs -f $CONTAINER_NAME" +else + echo "❌ 服务启动失败" + echo "查看错误日志:" + $CONTAINER_CMD logs "$CONTAINER_NAME" + exit 1 +fi + +echo "" +echo "=========================================" +echo " 🎉 部署成功!" +echo "=========================================" +echo "" +echo "服务信息:" +echo " - 容器名称: $CONTAINER_NAME" +echo " - 网络: dd.net" +echo " - Admin 端口: 8080" +echo " - API 端口: 8081" +echo " - 配置文件: $DEPLOY_DIR/$CONFIG_FILE (挂载)" +echo " - 证书目录: $DEPLOY_DIR/$STORAGE_DIR (挂载)" +echo "" +echo "常用命令:" +echo " 查看日志: $CONTAINER_CMD logs -f $CONTAINER_NAME" +echo " 停止服务: $CONTAINER_CMD stop $CONTAINER_NAME" +echo " 重启服务: $CONTAINER_CMD restart $CONTAINER_NAME" +echo " 删除容器: $CONTAINER_CMD rm -f $CONTAINER_NAME" +echo "" + diff --git a/docs/migrations/add_doc_tables.sql b/docs/migrations/add_doc_tables.sql new file mode 100644 index 0000000..f7c1784 --- /dev/null +++ b/docs/migrations/add_doc_tables.sql @@ -0,0 +1,29 @@ +-- 文档管理:两层结构(文件夹 + 文件) +-- 执行: mysql -u root -p duidui_db < docs/migrations/add_doc_tables.sql +-- +-- 使用文档管理功能前,需在权限表中增加 document:manage(或在管理后台权限管理里新增), +-- 并为相应角色勾选该权限;超级管理员无需配置即可访问。 + +-- 文件夹表(仅一层,无 parent_id) +CREATE TABLE IF NOT EXISTS doc_folders ( + id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '主键', + name VARCHAR(128) NOT NULL COMMENT '文件夹名称', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序(升序)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档文件夹'; + +-- 文档表(属于某个文件夹) +CREATE TABLE IF NOT EXISTS doc_files ( + id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '主键', + folder_id VARCHAR(64) NOT NULL COMMENT '所属文件夹 id', + name VARCHAR(256) NOT NULL COMMENT '显示名称', + file_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT '原始文件名', + file_url VARCHAR(1024) NOT NULL COMMENT '文件访问 URL(OSS 或相对路径)', + file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', + mime_type VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型,如 application/pdf', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_folder_id (folder_id), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档文件'; diff --git a/docs/migrations/add_essay_review_statuses.sql b/docs/migrations/add_essay_review_statuses.sql new file mode 100644 index 0000000..ac6e8a7 --- /dev/null +++ b/docs/migrations/add_essay_review_statuses.sql @@ -0,0 +1,6 @@ +-- 申论题每题审核状态:为 camp_user_progress 增加 essay_review_statuses,存储每题审核状态 JSON 数组 +-- 任务最终审核状态由所有题目状态汇总:任一驳回→rejected,任一待审→pending,全部通过→approved +-- 执行前请确认表名与数据库一致 + +ALTER TABLE camp_user_progress + ADD COLUMN essay_review_statuses TEXT NULL COMMENT '申论题每题审核状态 JSON 数组,如 ["PENDING","APPROVED","REJECTED"]'; diff --git a/docs/migrations/add_objective_best_columns.sql b/docs/migrations/add_objective_best_columns.sql new file mode 100644 index 0000000..3a977e3 --- /dev/null +++ b/docs/migrations/add_objective_best_columns.sql @@ -0,0 +1,6 @@ +-- 客观题历史最佳正确率:为 camp_user_progress 增加两列,用于“达标后只保留最高正确率” +-- 执行前请确认表名与数据库一致 + +ALTER TABLE camp_user_progress + ADD COLUMN objective_best_correct_count INT NULL COMMENT '客观题历史最高正确数', + ADD COLUMN objective_best_total_count INT NULL COMMENT '客观题历史最高对应的总题数'; diff --git a/docs/migrations/add_prerequisite_task_id.sql b/docs/migrations/add_prerequisite_task_id.sql new file mode 100644 index 0000000..cf77e22 --- /dev/null +++ b/docs/migrations/add_prerequisite_task_id.sql @@ -0,0 +1,5 @@ +-- 任务表增加前置任务ID(解锁关系:需完成前置任务后才能开启本任务) +-- 执行前请确认表名与数据库一致 + +ALTER TABLE camp_tasks + ADD COLUMN prerequisite_task_id VARCHAR(64) NULL DEFAULT NULL COMMENT '前置任务ID,完成后才能开启本任务(递进关系)'; diff --git a/docs/migrations/add_task_title.sql b/docs/migrations/add_task_title.sql new file mode 100644 index 0000000..ad01fc4 --- /dev/null +++ b/docs/migrations/add_task_title.sql @@ -0,0 +1,5 @@ +-- 打卡营任务表增加任务标题字段 +-- 执行前请确认表名与数据库一致 + +ALTER TABLE camp_tasks + ADD COLUMN title VARCHAR(255) NULL DEFAULT '' COMMENT '任务标题(用于展示)'; diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e46cc68 --- /dev/null +++ b/go.mod @@ -0,0 +1,65 @@ +module dd_fiber_api + +go 1.25.5 + +require ( + github.com/aliyun/credentials-go v1.3.0 + github.com/didi/gendry v1.9.0 + github.com/go-sql-driver/mysql v1.9.3 + github.com/gofiber/fiber/v2 v2.52.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/wire v0.7.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/olekukonko/tablewriter v1.1.2 + github.com/redis/go-redis/v9 v9.3.0 + github.com/spf13/viper v1.21.0 + github.com/wechatpay-apiv3/wechatpay-go v0.2.21 + go.mongodb.org/mongo-driver v1.17.6 + golang.org/x/crypto v0.46.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 // indirect + github.com/alibabacloud-go/tea v1.1.8 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/clipperhouse/displaywidth v0.6.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.1.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + gopkg.in/ini.v1 v1.56.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f7c13ad --- /dev/null +++ b/go.sum @@ -0,0 +1,210 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.4.0 h1:yxQ63CFIA8Sxkh0vqIofuNrsXl/LZ42TpeTLV4Nb5HM= +github.com/DATA-DOG/go-sqlmock v1.4.0/go.mod h1:3TucWNLPFOLcHhha1CPp7Kis1UG2h/AqGROPyOeZzsM= +github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= +github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 h1:NqugFkGxx1TXSh/pBcU00Y6bljgDPaFdh5MUSeJ7e50= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/tea v1.1.8 h1:vFF0707fqjGiQTxrtMnIXRjOCvQXf49CuDVRtTopmwU= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/aliyun/credentials-go v1.3.0 h1:wfBNojfNJJyuHK3YUIIjRPwnlQIdmy/YMkia1XOnPtY= +github.com/aliyun/credentials-go v1.3.0/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s= +github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/didi/gendry v1.9.0 h1:wGsFMix5Y89ex6Fge4+Ofuc7ypGA6wFtwnOL1DeM9uY= +github.com/didi/gendry v1.9.0/go.mod h1:cSLuShZ1Zbs1S05RIOLNQv616aBaOQ1BDrXJP9A3J+M= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.3 h1:sV2jrhQGq5B3W0nENUISCR6azIPf7UBUpVq0x/y70Fg= +github.com/olekukonko/ll v0.1.3/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/tablewriter v1.1.2 h1:L2kI1Y5tZBct/O/TyZK1zIE9GlBj/TVs+AY5tZDCDSc= +github.com/olekukonko/tablewriter v1.1.2/go.mod h1:z7SYPugVqGVavWoA2sGsFIoOVNmEHxUAAMrhXONtfkg= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= +github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.56.0 h1:DPMeDvGTM54DXbPkVIZsp19fp/I2K7zwA/itHYHKo8Y= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/admin/admin_user_routes.go b/internal/admin/admin_user_routes.go new file mode 100644 index 0000000..655288d --- /dev/null +++ b/internal/admin/admin_user_routes.go @@ -0,0 +1,26 @@ +package admin + +import ( + admin_auth_handler "dd_fiber_api/internal/admin_auth/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupAdminUserRoutes 设置管理员用户路由 +func SetupAdminUserRoutes(router fiber.Router, adminUserHandler *admin_auth_handler.AdminUserHandler) { + if adminUserHandler == nil { + return + } + + adminUsers := router.Group("/admin-users") + adminUsers.Get("/list", admin_auth_middleware.PermissionMiddleware("admin:user:read"), adminUserHandler.ListAdminUsers).Name("获取管理员列表") + adminUsers.Get("/detail", admin_auth_middleware.PermissionMiddleware("admin:user:read"), adminUserHandler.GetAdminUser).Name("获取管理员详情") + adminUsers.Post("/create", admin_auth_middleware.PermissionMiddleware("admin:user:create"), adminUserHandler.CreateAdminUser).Name("创建管理员") + adminUsers.Post("/update", admin_auth_middleware.PermissionMiddleware("admin:user:update"), adminUserHandler.UpdateAdminUser).Name("更新管理员") + adminUsers.Post("/delete", admin_auth_middleware.PermissionMiddleware("admin:user:delete"), adminUserHandler.DeleteAdminUser).Name("删除管理员") + + // 用户角色管理 + adminUsers.Get("/roles", admin_auth_middleware.PermissionMiddleware("admin:user:roles:read"), adminUserHandler.GetUserRoles).Name("获取用户角色") + adminUsers.Post("/roles", admin_auth_middleware.PermissionMiddleware("admin:user:roles:update"), adminUserHandler.SetUserRoles).Name("设置用户角色") +} diff --git a/internal/admin/camp_routes.go b/internal/admin/camp_routes.go new file mode 100644 index 0000000..a546f3c --- /dev/null +++ b/internal/admin/camp_routes.go @@ -0,0 +1,77 @@ +package admin + +import ( + camp_handler "dd_fiber_api/internal/camp/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupCampRoutes Camp管理路由 +func SetupCampRoutes(router fiber.Router, campCategoryHandler *camp_handler.CategoryHandler, campHandler *camp_handler.CampHandler, sectionHandler *camp_handler.SectionHandler, taskHandler *camp_handler.TaskHandler, progressHandler *camp_handler.ProgressHandler, userCampHandler *camp_handler.UserCampHandler) { + // 如果所有handler都为空,则不设置路由 + if campCategoryHandler == nil && campHandler == nil && sectionHandler == nil && taskHandler == nil && progressHandler == nil && userCampHandler == nil { + return + } + + camp := router.Group("/camp") + + // 分类管理 + if campCategoryHandler != nil { + categories := camp.Group("/categories") + categories.Post("/create", admin_auth_middleware.PermissionMiddleware("camp:category:create"), campCategoryHandler.CreateCategory).Name("创建分类") + categories.Get("/detail", admin_auth_middleware.PermissionMiddleware("camp:category:read"), campCategoryHandler.GetCategory).Name("获取分类") + categories.Post("/update", admin_auth_middleware.PermissionMiddleware("camp:category:update"), campCategoryHandler.UpdateCategory).Name("更新分类") + categories.Post("/delete", admin_auth_middleware.PermissionMiddleware("camp:category:delete"), campCategoryHandler.DeleteCategory).Name("删除分类") + categories.Get("", admin_auth_middleware.PermissionMiddleware("camp:category:read"), campCategoryHandler.ListCategories).Name("列出分类") + } + + // 打卡营管理 + if campHandler != nil { + camps := camp.Group("/camps") + camps.Post("/create", admin_auth_middleware.PermissionMiddleware("camp:camp:create"), campHandler.CreateCamp).Name("创建打卡营") + camps.Get("/detail", admin_auth_middleware.PermissionMiddleware("camp:camp:read"), campHandler.GetCamp).Name("获取打卡营") + camps.Post("/update", admin_auth_middleware.PermissionMiddleware("camp:camp:update"), campHandler.UpdateCamp).Name("更新打卡营") + camps.Post("/delete", admin_auth_middleware.PermissionMiddleware("camp:camp:delete"), campHandler.DeleteCamp).Name("删除打卡营") + camps.Get("", admin_auth_middleware.PermissionMiddleware("camp:camp:read"), campHandler.ListCamps).Name("列出打卡营") + } + + // 小节管理 + if sectionHandler != nil { + sections := camp.Group("/sections") + sections.Post("/create", admin_auth_middleware.PermissionMiddleware("camp:section:create"), sectionHandler.CreateSection).Name("创建小节") + sections.Get("/detail", admin_auth_middleware.PermissionMiddleware("camp:section:read"), sectionHandler.GetSection).Name("获取小节") + sections.Post("/update", admin_auth_middleware.PermissionMiddleware("camp:section:update"), sectionHandler.UpdateSection).Name("更新小节") + sections.Post("/delete", admin_auth_middleware.PermissionMiddleware("camp:section:delete"), sectionHandler.DeleteSection).Name("删除小节") + sections.Get("", admin_auth_middleware.PermissionMiddleware("camp:section:read"), sectionHandler.ListSections).Name("列出小节") + } + + // 任务管理 + if taskHandler != nil { + tasks := camp.Group("/tasks") + tasks.Post("/create", admin_auth_middleware.PermissionMiddleware("camp:task:create"), taskHandler.CreateTask).Name("创建任务") + tasks.Get("/detail", admin_auth_middleware.PermissionMiddleware("camp:task:read"), taskHandler.GetTask).Name("获取任务") + tasks.Post("/update", admin_auth_middleware.PermissionMiddleware("camp:task:update"), taskHandler.UpdateTask).Name("更新任务") + tasks.Post("/delete", admin_auth_middleware.PermissionMiddleware("camp:task:delete"), taskHandler.DeleteTask).Name("删除任务") + tasks.Get("", admin_auth_middleware.PermissionMiddleware("camp:task:read"), taskHandler.ListTasks).Name("列出任务") + } + + // 用户进度管理 + if progressHandler != nil { + progress := camp.Group("/progress") + progress.Put("", admin_auth_middleware.PermissionMiddleware("camp:progress:update"), progressHandler.UpdateUserProgress).Name("更新用户进度") + progress.Get("", admin_auth_middleware.PermissionMiddleware("camp:progress:read"), progressHandler.GetUserProgress).Name("获取用户进度") + progress.Get("/list", admin_auth_middleware.PermissionMiddleware("camp:progress:read"), progressHandler.ListUserProgress).Name("列出用户进度") + progress.Get("/pending-count", admin_auth_middleware.PermissionMiddleware("camp:progress:read"), progressHandler.GetPendingReviewCount).Name("待审核任务数量") + progress.Post("/reset", admin_auth_middleware.PermissionMiddleware("camp:progress:reset"), progressHandler.ResetTaskProgress).Name("重置任务进度") + } + + // 用户打卡营管理 + if userCampHandler != nil { + userCamp := camp.Group("/user-camp") + userCamp.Post("/join", admin_auth_middleware.PermissionMiddleware("camp:user-camp:join"), userCampHandler.JoinCamp).Name("加入打卡营") + userCamp.Get("/status", admin_auth_middleware.PermissionMiddleware("camp:user-camp:read"), userCampHandler.CheckUserCampStatus).Name("检查用户打卡营状态") + userCamp.Get("/list", admin_auth_middleware.PermissionMiddleware("camp:user-camp:read"), userCampHandler.ListUserCamps).Name("获取用户打卡营列表") + userCamp.Post("/reset-progress", admin_auth_middleware.PermissionMiddleware("camp:user-camp:reset"), userCampHandler.ResetCampProgress).Name("重置打卡营进度") + } +} diff --git a/internal/admin/document_routes.go b/internal/admin/document_routes.go new file mode 100644 index 0000000..2d1d59e --- /dev/null +++ b/internal/admin/document_routes.go @@ -0,0 +1,27 @@ +package admin + +import ( + document_handler "dd_fiber_api/internal/document/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupDocumentRoutes 文档管理路由 +func SetupDocumentRoutes(router fiber.Router, documentHandler *document_handler.Handler) { + if documentHandler == nil { + return + } + doc := router.Group("/document") + perm := admin_auth_middleware.PermissionMiddleware("document:manage") + // 文件夹 + doc.Get("/folders", perm, documentHandler.ListFolders).Name("列出文件夹") + doc.Post("/folders/create", perm, documentHandler.CreateFolder).Name("创建文件夹") + doc.Post("/folders/update", perm, documentHandler.UpdateFolder).Name("更新文件夹") + doc.Post("/folders/delete", perm, documentHandler.DeleteFolder).Name("删除文件夹") + // 文件 + doc.Get("/files", perm, documentHandler.ListFiles).Name("列出文件") + doc.Post("/files/create", perm, documentHandler.CreateFile).Name("创建文档") + doc.Post("/files/update", perm, documentHandler.UpdateFile).Name("更新文档") + doc.Post("/files/delete", perm, documentHandler.DeleteFile).Name("删除文档") +} diff --git a/internal/admin/order_routes.go b/internal/admin/order_routes.go new file mode 100644 index 0000000..51f3373 --- /dev/null +++ b/internal/admin/order_routes.go @@ -0,0 +1,21 @@ +package admin + +import ( + order_handler "dd_fiber_api/internal/order/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupOrderRoutes 订单管理路由 +func SetupOrderRoutes(router fiber.Router, orderHandler *order_handler.OrderHandler) { + if orderHandler == nil { + return + } + + orders := router.Group("/orders") + orders.Post("", admin_auth_middleware.PermissionMiddleware("order:create"), orderHandler.CreateOrder).Name("创建订单") + orders.Get("/:id", admin_auth_middleware.PermissionMiddleware("order:read"), orderHandler.GetOrder).Name("获取订单详情") + orders.Get("", admin_auth_middleware.PermissionMiddleware("order:read"), orderHandler.ListOrders).Name("查询订单列表") + orders.Put("/status", admin_auth_middleware.PermissionMiddleware("order:update"), orderHandler.UpdateOrderStatus).Name("更新订单状态") +} diff --git a/internal/admin/oss_routes.go b/internal/admin/oss_routes.go new file mode 100644 index 0000000..87c5aa2 --- /dev/null +++ b/internal/admin/oss_routes.go @@ -0,0 +1,23 @@ +package admin + +import ( + "dd_fiber_api/internal/oss" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupOSSRoutes OSS凭证管理路由 +func SetupOSSRoutes(router fiber.Router, ossHandler *oss.Handler) { + if ossHandler == nil { + return + } + + oss := router.Group("/oss") + + // 获取OSS上传凭证 + oss.Get("/upload/signature", admin_auth_middleware.PermissionMiddleware("oss:upload:signature"), ossHandler.GetPolicyToken).Name("获取OSS上传凭证") + + // 测试接口(模拟凭证) + oss.Get("/upload/signature/mock", admin_auth_middleware.PermissionMiddleware("oss:upload:signature"), ossHandler.GetMockPolicyToken).Name("获取OSS上传凭证(模拟)") +} diff --git a/internal/admin/payment_routes.go b/internal/admin/payment_routes.go new file mode 100644 index 0000000..95a5fa7 --- /dev/null +++ b/internal/admin/payment_routes.go @@ -0,0 +1,23 @@ +package admin + +import ( + "dd_fiber_api/internal/payment" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupPaymentRoutes 支付管理路由 +func SetupPaymentRoutes(router fiber.Router, paymentHandler *payment.Handler) { + if paymentHandler == nil { + return + } + + payment := router.Group("/payment") + + // 微信支付V3 + wechat := payment.Group("/wechat/v3") + wechat.Post("", admin_auth_middleware.PermissionMiddleware("payment:wechat:create"), paymentHandler.CreateWechatPayV3).Name("创建支付订单") + // 支付通知回调不需要权限验证(由微信服务器调用) + wechat.Post("/notify", paymentHandler.HandleWechatPayV3Notify).Name("支付通知回调") +} diff --git a/internal/admin/permission_routes.go b/internal/admin/permission_routes.go new file mode 100644 index 0000000..ad5ae20 --- /dev/null +++ b/internal/admin/permission_routes.go @@ -0,0 +1,24 @@ +package admin + +import ( + admin_auth_handler "dd_fiber_api/internal/admin_auth/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupPermissionRoutes 设置权限路由 +func SetupPermissionRoutes(router fiber.Router, permissionHandler *admin_auth_handler.PermissionHandler) { + if permissionHandler == nil { + return + } + + permissions := router.Group("/permissions") + permissions.Get("/list", admin_auth_middleware.PermissionMiddleware("admin:permission:read"), permissionHandler.ListPermissions).Name("获取权限列表") + permissions.Get("/detail", admin_auth_middleware.PermissionMiddleware("admin:permission:read"), permissionHandler.GetPermission).Name("获取权限详情") + permissions.Post("/create", admin_auth_middleware.PermissionMiddleware("admin:permission:create"), permissionHandler.CreatePermission).Name("创建权限") + permissions.Post("/update", admin_auth_middleware.PermissionMiddleware("admin:permission:update"), permissionHandler.UpdatePermission).Name("更新权限") + permissions.Post("/delete", admin_auth_middleware.PermissionMiddleware("admin:permission:delete"), permissionHandler.DeletePermission).Name("删除权限") + permissions.Get("/resources", admin_auth_middleware.PermissionMiddleware("admin:permission:resources:read"), permissionHandler.GetResources).Name("获取资源列表") +} + diff --git a/internal/admin/question_routes.go b/internal/admin/question_routes.go new file mode 100644 index 0000000..570cc92 --- /dev/null +++ b/internal/admin/question_routes.go @@ -0,0 +1,67 @@ +package admin + +import ( + question_handler "dd_fiber_api/internal/question/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupQuestionRoutes 设置题目相关路由(管理端) +func SetupQuestionRoutes(router fiber.Router, questionHandler *question_handler.QuestionHandler, paperHandler *question_handler.PaperHandler, answerRecordHandler *question_handler.AnswerRecordHandler, materialHandler *question_handler.MaterialHandler, knowledgeTreeHandler *question_handler.KnowledgeTreeHandler) { + // 题目相关路由 + if questionHandler != nil { + questions := router.Group("/questions") + questions.Post("", admin_auth_middleware.PermissionMiddleware("question:create"), questionHandler.CreateQuestion).Name("创建题目") + questions.Get("/detail", admin_auth_middleware.PermissionMiddleware("question:read"), questionHandler.GetQuestion).Name("获取题目详情") + questions.Get("/search", admin_auth_middleware.PermissionMiddleware("question:read"), questionHandler.SearchQuestions).Name("搜索题目") + questions.Post("/update", admin_auth_middleware.PermissionMiddleware("question:update"), questionHandler.UpdateQuestion).Name("更新题目") + questions.Post("/delete", admin_auth_middleware.PermissionMiddleware("question:delete"), questionHandler.DeleteQuestion).Name("删除题目") + questions.Post("/batch_delete", admin_auth_middleware.PermissionMiddleware("question:batch_delete"), questionHandler.BatchDeleteQuestions).Name("批量删除题目") + } + + // 试卷相关路由 + if paperHandler != nil { + papers := router.Group("/papers") + papers.Post("/create", admin_auth_middleware.PermissionMiddleware("paper:create"), paperHandler.CreatePaper).Name("创建试卷") + papers.Get("/detail", admin_auth_middleware.PermissionMiddleware("paper:read"), paperHandler.GetPaper).Name("获取试卷详情") + papers.Get("/search", admin_auth_middleware.PermissionMiddleware("paper:read"), paperHandler.SearchPapers).Name("搜索试卷") + papers.Post("/update", admin_auth_middleware.PermissionMiddleware("paper:update"), paperHandler.UpdatePaper).Name("更新试卷") + papers.Post("/delete", admin_auth_middleware.PermissionMiddleware("paper:delete"), paperHandler.DeletePaper).Name("删除试卷") + papers.Post("/batch_delete", admin_auth_middleware.PermissionMiddleware("paper:batch_delete"), paperHandler.BatchDeletePapers).Name("批量删除试卷") + papers.Post("/add_question", admin_auth_middleware.PermissionMiddleware("paper:add_question"), paperHandler.AddQuestionToPaper).Name("添加题目到试卷") + papers.Post("/remove_question", admin_auth_middleware.PermissionMiddleware("paper:remove_question"), paperHandler.RemoveQuestionFromPaper).Name("从试卷移除题目") + } + + // 答题记录相关路由 + if answerRecordHandler != nil { + answerRecords := router.Group("/answer-records") + answerRecords.Post("/create", admin_auth_middleware.PermissionMiddleware("answer_record:create"), answerRecordHandler.CreateAnswerRecords).Name("批量创建答题记录") + answerRecords.Get("/detail", admin_auth_middleware.PermissionMiddleware("answer_record:read"), answerRecordHandler.GetAnswerRecord).Name("获取答题记录") + answerRecords.Get("/user", admin_auth_middleware.PermissionMiddleware("answer_record:read"), answerRecordHandler.GetUserAnswerRecord).Name("获取用户答题记录") + answerRecords.Get("/statistics", admin_auth_middleware.PermissionMiddleware("answer_record:statistics"), answerRecordHandler.GetPaperAnswerStatistics).Name("获取试卷答题统计") + answerRecords.Post("/delete", admin_auth_middleware.PermissionMiddleware("answer_record:delete"), answerRecordHandler.DeleteAnswerRecord).Name("删除答题记录") + } + + // 材料相关路由 + if materialHandler != nil { + materials := router.Group("/materials") + materials.Post("", admin_auth_middleware.PermissionMiddleware("material:create"), materialHandler.CreateMaterial).Name("创建材料") + materials.Get("/detail", admin_auth_middleware.PermissionMiddleware("material:read"), materialHandler.GetMaterial).Name("获取材料详情") + materials.Get("/search", admin_auth_middleware.PermissionMiddleware("material:read"), materialHandler.SearchMaterials).Name("搜索材料") + materials.Put("/:id", admin_auth_middleware.PermissionMiddleware("material:update"), materialHandler.UpdateMaterial).Name("更新材料") + materials.Delete("/:id", admin_auth_middleware.PermissionMiddleware("material:delete"), materialHandler.DeleteMaterial).Name("删除材料") + } + + // 知识树相关路由 + if knowledgeTreeHandler != nil { + knowledgeTrees := router.Group("/knowledge-trees") + knowledgeTrees.Post("", admin_auth_middleware.PermissionMiddleware("knowledge_tree:create"), knowledgeTreeHandler.CreateKnowledgeTreeNode).Name("创建知识树节点") + knowledgeTrees.Get("/detail", admin_auth_middleware.PermissionMiddleware("knowledge_tree:read"), knowledgeTreeHandler.GetKnowledgeTreeNode).Name("获取知识树节点详情") + knowledgeTrees.Get("/list", admin_auth_middleware.PermissionMiddleware("knowledge_tree:read"), knowledgeTreeHandler.GetAllKnowledgeTreeNodes).Name("获取所有知识树节点") + knowledgeTrees.Get("/children", admin_auth_middleware.PermissionMiddleware("knowledge_tree:read"), knowledgeTreeHandler.GetKnowledgeTreeByParentID).Name("根据父节点获取子节点") + knowledgeTrees.Get("/tree", admin_auth_middleware.PermissionMiddleware("knowledge_tree:read"), knowledgeTreeHandler.GetKnowledgeTree).Name("获取完整知识树") + knowledgeTrees.Post("/update", admin_auth_middleware.PermissionMiddleware("knowledge_tree:update"), knowledgeTreeHandler.UpdateKnowledgeTreeNode).Name("更新知识树节点") + knowledgeTrees.Delete("/:id", admin_auth_middleware.PermissionMiddleware("knowledge_tree:delete"), knowledgeTreeHandler.DeleteKnowledgeTreeNode).Name("删除知识树节点") + } +} diff --git a/internal/admin/role_routes.go b/internal/admin/role_routes.go new file mode 100644 index 0000000..2ae3e56 --- /dev/null +++ b/internal/admin/role_routes.go @@ -0,0 +1,25 @@ +package admin + +import ( + admin_auth_handler "dd_fiber_api/internal/admin_auth/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupRoleRoutes 设置角色路由 +func SetupRoleRoutes(router fiber.Router, roleHandler *admin_auth_handler.RoleHandler) { + if roleHandler == nil { + return + } + + roles := router.Group("/roles") + roles.Get("/list", admin_auth_middleware.PermissionMiddleware("admin:role:read"), roleHandler.ListRoles).Name("获取角色列表") + roles.Get("/detail", admin_auth_middleware.PermissionMiddleware("admin:role:read"), roleHandler.GetRole).Name("获取角色详情") + roles.Post("/create", admin_auth_middleware.PermissionMiddleware("admin:role:create"), roleHandler.CreateRole).Name("创建角色") + roles.Post("/update", admin_auth_middleware.PermissionMiddleware("admin:role:update"), roleHandler.UpdateRole).Name("更新角色") + roles.Post("/delete", admin_auth_middleware.PermissionMiddleware("admin:role:delete"), roleHandler.DeleteRole).Name("删除角色") + roles.Get("/permissions", admin_auth_middleware.PermissionMiddleware("admin:role:permissions:read"), roleHandler.GetRolePermissions).Name("获取角色权限") + roles.Post("/permissions", admin_auth_middleware.PermissionMiddleware("admin:role:permissions:update"), roleHandler.SetRolePermissions).Name("设置角色权限") +} + diff --git a/internal/admin/routes.go b/internal/admin/routes.go new file mode 100644 index 0000000..86e8310 --- /dev/null +++ b/internal/admin/routes.go @@ -0,0 +1,59 @@ +package admin + +import ( + "dd_fiber_api/internal/admin/statistics" + admin_auth_handler "dd_fiber_api/internal/admin_auth/handler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + admin_auth_service "dd_fiber_api/internal/admin_auth/service" + camp_handler "dd_fiber_api/internal/camp/handler" + document_handler "dd_fiber_api/internal/document/handler" + order_handler "dd_fiber_api/internal/order/handler" + "dd_fiber_api/internal/oss" + "dd_fiber_api/internal/payment" + question_handler "dd_fiber_api/internal/question/handler" + "dd_fiber_api/internal/scheduler" + + "github.com/gofiber/fiber/v2" +) + +// SetupRoutes 设置Admin路由(管理端) +func SetupRoutes(app *fiber.App, ossHandler *oss.Handler, paymentHandler *payment.Handler, schedulerHandler *scheduler.Handler, campCategoryHandler *camp_handler.CategoryHandler, campHandler *camp_handler.CampHandler, sectionHandler *camp_handler.SectionHandler, taskHandler *camp_handler.TaskHandler, progressHandler *camp_handler.ProgressHandler, userCampHandler *camp_handler.UserCampHandler, orderHandler *order_handler.OrderHandler, questionHandler *question_handler.QuestionHandler, paperHandler *question_handler.PaperHandler, answerRecordHandler *question_handler.AnswerRecordHandler, materialHandler *question_handler.MaterialHandler, knowledgeTreeHandler *question_handler.KnowledgeTreeHandler, documentHandler *document_handler.Handler, authHandler *admin_auth_handler.AuthHandler, authService *admin_auth_service.AuthService, statisticsHandler *statistics.Handler, adminUserHandler *admin_auth_handler.AdminUserHandler, roleHandler *admin_auth_handler.RoleHandler, permissionHandler *admin_auth_handler.PermissionHandler) { + // 健康检查 + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "service": "admin", + }) + }).Name("health.check") + + // API版本前缀 + v1 := app.Group("/admin/v1") + + // 认证相关路由(不需要认证) + if authHandler != nil { + auth := v1.Group("/auth") + auth.Post("/login", authHandler.Login).Name("管理员登录") + auth.Post("/logout", authHandler.Logout).Name("管理员登出") + } + + // 需要认证的路由 + authenticated := v1.Group("", admin_auth_middleware.AuthMiddleware(authService)) + + // 获取当前用户信息 + if authHandler != nil { + authenticated.Get("/auth/me", admin_auth_middleware.PermissionMiddleware("admin:auth:me:read"), authHandler.GetMe).Name("获取当前用户信息") + } + + // 各模块路由设置(需要认证) + SetupOSSRoutes(authenticated, ossHandler) + SetupPaymentRoutes(authenticated, paymentHandler) + SetupSchedulerRoutes(authenticated, schedulerHandler) + SetupCampRoutes(authenticated, campCategoryHandler, campHandler, sectionHandler, taskHandler, progressHandler, userCampHandler) + SetupOrderRoutes(authenticated, orderHandler) + SetupQuestionRoutes(authenticated, questionHandler, paperHandler, answerRecordHandler, materialHandler, knowledgeTreeHandler) + SetupStatisticsRoutes(authenticated, statisticsHandler) + SetupAdminUserRoutes(authenticated, adminUserHandler) + SetupRoleRoutes(authenticated, roleHandler) + SetupPermissionRoutes(authenticated, permissionHandler) + SetupDocumentRoutes(authenticated, documentHandler) +} diff --git a/internal/admin/scheduler_routes.go b/internal/admin/scheduler_routes.go new file mode 100644 index 0000000..d4d8156 --- /dev/null +++ b/internal/admin/scheduler_routes.go @@ -0,0 +1,24 @@ +package admin + +import ( + "dd_fiber_api/internal/scheduler" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupSchedulerRoutes 调度器管理路由 +func SetupSchedulerRoutes(router fiber.Router, schedulerHandler *scheduler.Handler) { + if schedulerHandler == nil { + return + } + + sched := router.Group("/scheduler") + + // 任务管理 + sched.Post("/tasks", admin_auth_middleware.PermissionMiddleware("scheduler:task:create"), schedulerHandler.AddTask).Name("添加任务") + sched.Delete("/tasks/:task_id", admin_auth_middleware.PermissionMiddleware("scheduler:task:delete"), schedulerHandler.RemoveTask).Name("删除任务") + sched.Get("/tasks/:task_id/status", admin_auth_middleware.PermissionMiddleware("scheduler:task:read"), schedulerHandler.GetTaskStatus).Name("查询任务状态") + sched.Get("/tasks", admin_auth_middleware.PermissionMiddleware("scheduler:task:read"), schedulerHandler.ListTasks).Name("列出所有任务") + sched.Get("/tasks/count", admin_auth_middleware.PermissionMiddleware("scheduler:task:read"), schedulerHandler.GetTaskCount).Name("获取任务数量") +} diff --git a/internal/admin/statistics/handler.go b/internal/admin/statistics/handler.go new file mode 100644 index 0000000..c0885a1 --- /dev/null +++ b/internal/admin/statistics/handler.go @@ -0,0 +1,34 @@ +package statistics + +import ( + "github.com/gofiber/fiber/v2" +) + +// Handler 统计处理器 +type Handler struct { + service *Service +} + +// NewHandler 创建统计处理器 +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + } +} + +// GetStatistics 获取统计数据 +func (h *Handler) GetStatistics(c *fiber.Ctx) error { + stats, err := h.service.GetStatistics() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取统计数据失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取统计数据成功", + "data": stats, + }) +} diff --git a/internal/admin/statistics/service.go b/internal/admin/statistics/service.go new file mode 100644 index 0000000..aaa188c --- /dev/null +++ b/internal/admin/statistics/service.go @@ -0,0 +1,74 @@ +package statistics + +import ( + "dd_fiber_api/internal/camp/dao" + question_dao "dd_fiber_api/internal/question/dao" + "dd_fiber_api/pkg/database" + "fmt" +) + +// Service 统计服务 +type Service struct { + mysqlClient *database.MySQLClient + campDAO *dao.CampDAO + questionDAO question_dao.QuestionDAOInterface + paperDAO question_dao.PaperDAOInterface +} + +// NewService 创建统计服务 +func NewService( + mysqlClient *database.MySQLClient, + campDAO *dao.CampDAO, + questionDAO question_dao.QuestionDAOInterface, + paperDAO question_dao.PaperDAOInterface, +) *Service { + return &Service{ + mysqlClient: mysqlClient, + campDAO: campDAO, + questionDAO: questionDAO, + paperDAO: paperDAO, + } +} + +// Statistics 统计数据 +type Statistics struct { + UserCount int `json:"user_count"` // 用户总数 + CampCount int `json:"camp_count"` // 打卡营数量 + QuestionCount int `json:"question_count"` // 题目数量 + PaperCount int `json:"paper_count"` // 试卷数量 +} + +// GetStatistics 获取统计数据 +func (s *Service) GetStatistics() (*Statistics, error) { + stats := &Statistics{} + + // 统计用户总数(从 camp_user_camps 表中统计不重复的 user_id) + userCountQuery := `SELECT COUNT(DISTINCT user_id) FROM camp_user_camps` + err := s.mysqlClient.DB.QueryRow(userCountQuery).Scan(&stats.UserCount) + if err != nil { + return nil, fmt.Errorf("统计用户总数失败: %v", err) + } + + // 统计打卡营数量(排除已删除的) + campCountQuery := `SELECT COUNT(*) FROM camp_camps WHERE deleted_at IS NULL` + err = s.mysqlClient.DB.QueryRow(campCountQuery).Scan(&stats.CampCount) + if err != nil { + return nil, fmt.Errorf("统计打卡营数量失败: %v", err) + } + + // 统计题目数量(使用 MongoDB 接口) + _, total, err := s.questionDAO.Search("", 0, nil, 1, 1) + if err != nil { + return nil, fmt.Errorf("统计题目数量失败: %v", err) + } + stats.QuestionCount = int(total) + + // 统计试卷数量(使用 MongoDB 接口) + _, total, err = s.paperDAO.Search("", 1, 1) + if err != nil { + return nil, fmt.Errorf("统计试卷数量失败: %v", err) + } + stats.PaperCount = int(total) + + return stats, nil +} diff --git a/internal/admin/statistics_routes.go b/internal/admin/statistics_routes.go new file mode 100644 index 0000000..34f83b8 --- /dev/null +++ b/internal/admin/statistics_routes.go @@ -0,0 +1,18 @@ +package admin + +import ( + "dd_fiber_api/internal/admin/statistics" + admin_auth_middleware "dd_fiber_api/internal/admin_auth/middleware" + + "github.com/gofiber/fiber/v2" +) + +// SetupStatisticsRoutes 设置统计路由 +func SetupStatisticsRoutes(router fiber.Router, statisticsHandler *statistics.Handler) { + if statisticsHandler == nil { + return + } + + stats := router.Group("/statistics") + stats.Get("/dashboard", admin_auth_middleware.PermissionMiddleware("statistics:dashboard:read"), statisticsHandler.GetStatistics).Name("获取仪表盘统计数据") +} diff --git a/internal/admin_auth/dao/admin_user_dao.go b/internal/admin_auth/dao/admin_user_dao.go new file mode 100644 index 0000000..cef2365 --- /dev/null +++ b/internal/admin_auth/dao/admin_user_dao.go @@ -0,0 +1,484 @@ +package dao + +import ( + "database/sql" + "fmt" + "time" + + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/snowflake" + + "github.com/didi/gendry/builder" +) + +// AdminUserDAO 管理员用户数据访问对象 +type AdminUserDAO struct { + client *database.MySQLClient +} + +// NewAdminUserDAO 创建管理员用户DAO +func NewAdminUserDAO(client *database.MySQLClient) *AdminUserDAO { + return &AdminUserDAO{ + client: client, + } +} + +// GetByPhone 根据手机号码获取管理员 +func (d *AdminUserDAO) GetByPhone(phone string) (*admin_auth.AdminUser, error) { + query := `SELECT id, username, phone, password, nickname, avatar, status, is_super_admin, + last_login_at, last_login_ip, created_at, updated_at + FROM admin_users WHERE phone = ? AND status = 1` + + var user admin_auth.AdminUser + var usernameField, phoneField, nicknameField, avatarField, lastLoginIPField sql.NullString + var lastLoginAt sql.NullTime + + err := d.client.DB.QueryRow(query, phone).Scan( + &user.ID, + &usernameField, + &phoneField, + &user.Password, + &nicknameField, + &avatarField, + &user.Status, + &user.IsSuperAdmin, + &lastLoginAt, + &lastLoginIPField, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询管理员失败: %v", err) + } + + if usernameField.Valid { + user.Username = usernameField.String + } + if phoneField.Valid { + user.Phone = phoneField.String + } + if nicknameField.Valid { + user.Nickname = nicknameField.String + } + if avatarField.Valid { + user.Avatar = avatarField.String + } + if lastLoginIPField.Valid { + user.LastLoginIP = lastLoginIPField.String + } + if lastLoginAt.Valid { + user.LastLoginAt = admin_auth.NewDateTimePtr(lastLoginAt.Time) + } + + return &user, nil +} + +// GetByUsername 根据用户名获取管理员 +func (d *AdminUserDAO) GetByUsername(username string) (*admin_auth.AdminUser, error) { + query := `SELECT id, username, phone, password, nickname, avatar, status, is_super_admin, + last_login_at, last_login_ip, created_at, updated_at + FROM admin_users WHERE username = ? AND status = 1` + + var user admin_auth.AdminUser + var usernameField, phoneField, nicknameField, avatarField, lastLoginIPField sql.NullString + var lastLoginAt sql.NullTime + + err := d.client.DB.QueryRow(query, username).Scan( + &user.ID, + &usernameField, + &phoneField, + &user.Password, + &nicknameField, + &avatarField, + &user.Status, + &user.IsSuperAdmin, + &lastLoginAt, + &lastLoginIPField, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询管理员失败: %v", err) + } + + if usernameField.Valid { + user.Username = usernameField.String + } + if phoneField.Valid { + user.Phone = phoneField.String + } + if nicknameField.Valid { + user.Nickname = nicknameField.String + } + if avatarField.Valid { + user.Avatar = avatarField.String + } + if lastLoginIPField.Valid { + user.LastLoginIP = lastLoginIPField.String + } + if lastLoginAt.Valid { + user.LastLoginAt = admin_auth.NewDateTimePtr(lastLoginAt.Time) + } + + return &user, nil +} + +// GetByID 根据ID获取管理员 +func (d *AdminUserDAO) GetByID(id string) (*admin_auth.AdminUser, error) { + query := `SELECT id, username, phone, password, nickname, avatar, status, is_super_admin, + last_login_at, last_login_ip, created_at, updated_at + FROM admin_users WHERE id = ?` + + var user admin_auth.AdminUser + var usernameField, phoneField, nicknameField, avatarField, lastLoginIPField sql.NullString + var lastLoginAt sql.NullTime + + err := d.client.DB.QueryRow(query, id).Scan( + &user.ID, + &usernameField, + &phoneField, + &user.Password, + &nicknameField, + &avatarField, + &user.Status, + &user.IsSuperAdmin, + &lastLoginAt, + &lastLoginIPField, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询管理员失败: %v", err) + } + + if usernameField.Valid { + user.Username = usernameField.String + } + if phoneField.Valid { + user.Phone = phoneField.String + } + if nicknameField.Valid { + user.Nickname = nicknameField.String + } + if avatarField.Valid { + user.Avatar = avatarField.String + } + if lastLoginIPField.Valid { + user.LastLoginIP = lastLoginIPField.String + } + if lastLoginAt.Valid { + user.LastLoginAt = admin_auth.NewDateTimePtr(lastLoginAt.Time) + } + + return &user, nil +} + +// UpdateLastLogin 更新最后登录时间和IP +func (d *AdminUserDAO) UpdateLastLogin(id, ip string) error { + query := `UPDATE admin_users SET last_login_at = ?, last_login_ip = ? WHERE id = ?` + _, err := d.client.DB.Exec(query, time.Now(), ip, id) + if err != nil { + return fmt.Errorf("更新最后登录信息失败: %v", err) + } + return nil +} + +// GetUserRoles 获取用户的角色代码列表 +func (d *AdminUserDAO) GetUserRoles(userID string) ([]string, error) { + query := `SELECT r.code FROM admin_roles r + INNER JOIN admin_user_roles ur ON r.id = ur.role_id + WHERE ur.user_id = ? AND r.status = 1` + + rows, err := d.client.DB.Query(query, userID) + if err != nil { + return nil, fmt.Errorf("查询用户角色失败: %v", err) + } + defer rows.Close() + + var roles []string + for rows.Next() { + var code string + if err := rows.Scan(&code); err != nil { + return nil, fmt.Errorf("扫描角色代码失败: %v", err) + } + roles = append(roles, code) + } + + return roles, nil +} + +// GetUserPermissions 获取用户的权限代码列表(包括角色权限) +func (d *AdminUserDAO) GetUserPermissions(userID string) ([]string, error) { + // 如果是超级管理员,返回所有权限(这里简化处理,实际应该从权限表查询) + query := `SELECT DISTINCT p.code FROM admin_permissions p + INNER JOIN admin_role_permissions rp ON p.id = rp.permission_id + INNER JOIN admin_user_roles ur ON rp.role_id = ur.role_id + WHERE ur.user_id = ?` + + rows, err := d.client.DB.Query(query, userID) + if err != nil { + return nil, fmt.Errorf("查询用户权限失败: %v", err) + } + defer rows.Close() + + var permissions []string + for rows.Next() { + var code string + if err := rows.Scan(&code); err != nil { + return nil, fmt.Errorf("扫描权限代码失败: %v", err) + } + permissions = append(permissions, code) + } + + return permissions, nil +} + +// GetUserRoleIDs 获取用户的角色ID列表 +func (d *AdminUserDAO) GetUserRoleIDs(userID string) ([]string, error) { + query := `SELECT role_id FROM admin_user_roles WHERE user_id = ?` + + rows, err := d.client.DB.Query(query, userID) + if err != nil { + return nil, fmt.Errorf("查询用户角色失败: %v", err) + } + defer rows.Close() + + var roleIDs []string + for rows.Next() { + var roleID string + if err := rows.Scan(&roleID); err != nil { + continue + } + roleIDs = append(roleIDs, roleID) + } + + return roleIDs, nil +} + +// SetUserRoles 设置用户的角色 +func (d *AdminUserDAO) SetUserRoles(userID string, roleIDs []string) error { + // 先删除原有角色 + deleteQuery := `DELETE FROM admin_user_roles WHERE user_id = ?` + _, err := d.client.DB.Exec(deleteQuery, userID) + if err != nil { + return fmt.Errorf("删除用户角色失败: %v", err) + } + + // 插入新角色 + if len(roleIDs) > 0 { + table := "admin_user_roles" + data := make([]map[string]any, 0, len(roleIDs)) + for _, roleID := range roleIDs { + // 生成关联ID + id := snowflake.GenerateID() + data = append(data, map[string]any{ + "id": id, + "user_id": userID, + "role_id": roleID, + }) + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("设置用户角色失败: %v", err) + } + } + + return nil +} + +// List 列出管理员用户(支持分页和搜索) +func (d *AdminUserDAO) List(keyword string, page, pageSize int) ([]*admin_auth.AdminUser, int, error) { + table := "admin_users" + + // 构建查询条件 + where := map[string]any{} + if keyword != "" { + where["_or"] = []map[string]any{ + {"username like": "%" + keyword + "%"}, + {"phone like": "%" + keyword + "%"}, + {"nickname like": "%" + keyword + "%"}, + } + } + + // 查询总数 + countFields := []string{"COUNT(*) as total"} + countCond, countVals, err := builder.BuildSelect(table, where, countFields) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询管理员总数失败: %v", err) + } + + // 查询数据 + selectFields := []string{"id", "username", "phone", "nickname", "avatar", "status", "is_super_admin", "last_login_at", "last_login_ip", "created_at", "updated_at"} + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + + // 添加排序和分页 + offset := (page - 1) * pageSize + cond += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询管理员列表失败: %v", err) + } + defer rows.Close() + + var users []*admin_auth.AdminUser + for rows.Next() { + var user admin_auth.AdminUser + var usernameField, phoneField, nicknameField, avatarField, lastLoginIPField sql.NullString + var lastLoginAt sql.NullTime + + err := rows.Scan( + &user.ID, + &usernameField, + &phoneField, + &nicknameField, + &avatarField, + &user.Status, + &user.IsSuperAdmin, + &lastLoginAt, + &lastLoginIPField, + &user.CreatedAt, + &user.UpdatedAt, + ) + if err != nil { + continue + } + + if usernameField.Valid { + user.Username = usernameField.String + } + if phoneField.Valid { + user.Phone = phoneField.String + } + if nicknameField.Valid { + user.Nickname = nicknameField.String + } + if avatarField.Valid { + user.Avatar = avatarField.String + } + if lastLoginIPField.Valid { + user.LastLoginIP = lastLoginIPField.String + } + if lastLoginAt.Valid { + user.LastLoginAt = admin_auth.NewDateTimePtr(lastLoginAt.Time) + } + + users = append(users, &user) + } + + return users, total, nil +} + +// Create 创建管理员用户 +func (d *AdminUserDAO) Create(user *admin_auth.AdminUser) error { + table := "admin_users" + data := []map[string]any{ + { + "id": user.ID, + "username": user.Username, + "phone": user.Phone, + "password": user.Password, + "nickname": user.Nickname, + "avatar": user.Avatar, + "status": user.Status, + "is_super_admin": user.IsSuperAdmin, + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建管理员失败: %v", err) + } + + return nil +} + +// Update 更新管理员用户 +func (d *AdminUserDAO) Update(user *admin_auth.AdminUser) error { + table := "admin_users" + where := map[string]any{ + "id": user.ID, + } + + data := map[string]any{ + "username": user.Username, + "phone": user.Phone, + "nickname": user.Nickname, + "avatar": user.Avatar, + "status": user.Status, + "is_super_admin": user.IsSuperAdmin, + } + + // 如果密码不为空,则更新密码 + if user.Password != "" { + data["password"] = user.Password + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新管理员失败: %v", err) + } + + return nil +} + +// Delete 删除管理员用户(软删除,更新状态为禁用) +func (d *AdminUserDAO) Delete(id string) error { + where := map[string]any{ + "id": id, + } + data := map[string]any{ + "status": 0, // 禁用状态 + } + + cond, vals, err := builder.BuildUpdate("admin_users", where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("删除管理员失败: %v", err) + } + + return nil +} diff --git a/internal/admin_auth/dao/permission_dao.go b/internal/admin_auth/dao/permission_dao.go new file mode 100644 index 0000000..f48a783 --- /dev/null +++ b/internal/admin_auth/dao/permission_dao.go @@ -0,0 +1,263 @@ +package dao + +import ( + "database/sql" + "fmt" + + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/pkg/database" + + "github.com/didi/gendry/builder" +) + +// PermissionDAO 权限数据访问对象 +type PermissionDAO struct { + client *database.MySQLClient +} + +// NewPermissionDAO 创建权限DAO +func NewPermissionDAO(client *database.MySQLClient) *PermissionDAO { + return &PermissionDAO{ + client: client, + } +} + +// List 列出权限(支持分页和搜索) +func (d *PermissionDAO) List(keyword, resource string, page, pageSize int) ([]*admin_auth.AdminPermission, int, error) { + table := "admin_permissions" + + // 构建查询条件 + where := map[string]any{} + if keyword != "" { + where["_or"] = []map[string]any{ + {"name like": "%" + keyword + "%"}, + {"code like": "%" + keyword + "%"}, + {"description like": "%" + keyword + "%"}, + } + } + if resource != "" { + where["resource"] = resource + } + + // 查询总数 + countFields := []string{"COUNT(*) as total"} + countCond, countVals, err := builder.BuildSelect(table, where, countFields) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询权限总数失败: %v", err) + } + + // 查询数据 + selectFields := []string{"id", "name", "code", "resource", "action", "description", "created_at", "updated_at"} + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + + // 添加排序和分页 + offset := (page - 1) * pageSize + cond += " ORDER BY resource, action ASC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询权限列表失败: %v", err) + } + defer rows.Close() + + var permissions []*admin_auth.AdminPermission + for rows.Next() { + var permission admin_auth.AdminPermission + var description sql.NullString + + err := rows.Scan( + &permission.ID, + &permission.Name, + &permission.Code, + &permission.Resource, + &permission.Action, + &description, + &permission.CreatedAt, + &permission.UpdatedAt, + ) + if err != nil { + continue + } + + if description.Valid { + permission.Description = description.String + } + + permissions = append(permissions, &permission) + } + + return permissions, total, nil +} + +// GetByID 根据ID获取权限 +func (d *PermissionDAO) GetByID(id string) (*admin_auth.AdminPermission, error) { + query := `SELECT id, name, code, resource, action, description, created_at, updated_at + FROM admin_permissions WHERE id = ?` + + var permission admin_auth.AdminPermission + var description sql.NullString + + err := d.client.DB.QueryRow(query, id).Scan( + &permission.ID, + &permission.Name, + &permission.Code, + &permission.Resource, + &permission.Action, + &description, + &permission.CreatedAt, + &permission.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询权限失败: %v", err) + } + + if description.Valid { + permission.Description = description.String + } + + return &permission, nil +} + +// GetByCode 根据代码获取权限 +func (d *PermissionDAO) GetByCode(code string) (*admin_auth.AdminPermission, error) { + query := `SELECT id, name, code, resource, action, description, created_at, updated_at + FROM admin_permissions WHERE code = ?` + + var permission admin_auth.AdminPermission + var description sql.NullString + + err := d.client.DB.QueryRow(query, code).Scan( + &permission.ID, + &permission.Name, + &permission.Code, + &permission.Resource, + &permission.Action, + &description, + &permission.CreatedAt, + &permission.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询权限失败: %v", err) + } + + if description.Valid { + permission.Description = description.String + } + + return &permission, nil +} + +// Create 创建权限 +func (d *PermissionDAO) Create(permission *admin_auth.AdminPermission) error { + table := "admin_permissions" + data := []map[string]any{ + { + "id": permission.ID, + "name": permission.Name, + "code": permission.Code, + "resource": permission.Resource, + "action": permission.Action, + "description": permission.Description, + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建权限失败: %v", err) + } + + return nil +} + +// Update 更新权限 +func (d *PermissionDAO) Update(permission *admin_auth.AdminPermission) error { + table := "admin_permissions" + where := map[string]any{ + "id": permission.ID, + } + + data := map[string]any{ + "name": permission.Name, + "code": permission.Code, + "resource": permission.Resource, + "action": permission.Action, + "description": permission.Description, + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新权限失败: %v", err) + } + + return nil +} + +// Delete 删除权限 +func (d *PermissionDAO) Delete(id string) error { + // 先删除角色权限关联 + deleteRolePermQuery := `DELETE FROM admin_role_permissions WHERE permission_id = ?` + _, err := d.client.DB.Exec(deleteRolePermQuery, id) + if err != nil { + return fmt.Errorf("删除角色权限关联失败: %v", err) + } + + // 删除权限 + deleteQuery := `DELETE FROM admin_permissions WHERE id = ?` + _, err = d.client.DB.Exec(deleteQuery, id) + if err != nil { + return fmt.Errorf("删除权限失败: %v", err) + } + + return nil +} + +// GetResources 获取所有资源列表 +func (d *PermissionDAO) GetResources() ([]string, error) { + query := `SELECT DISTINCT resource FROM admin_permissions ORDER BY resource` + + rows, err := d.client.DB.Query(query) + if err != nil { + return nil, fmt.Errorf("查询资源列表失败: %v", err) + } + defer rows.Close() + + var resources []string + for rows.Next() { + var resource string + if err := rows.Scan(&resource); err != nil { + continue + } + resources = append(resources, resource) + } + + return resources, nil +} + diff --git a/internal/admin_auth/dao/role_dao.go b/internal/admin_auth/dao/role_dao.go new file mode 100644 index 0000000..4fe9d55 --- /dev/null +++ b/internal/admin_auth/dao/role_dao.go @@ -0,0 +1,304 @@ +package dao + +import ( + "database/sql" + "fmt" + + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/snowflake" + + "github.com/didi/gendry/builder" +) + +// RoleDAO 角色数据访问对象 +type RoleDAO struct { + client *database.MySQLClient +} + +// NewRoleDAO 创建角色DAO +func NewRoleDAO(client *database.MySQLClient) *RoleDAO { + return &RoleDAO{ + client: client, + } +} + +// List 列出角色(支持分页和搜索) +func (d *RoleDAO) List(keyword string, page, pageSize int) ([]*admin_auth.AdminRole, int, error) { + table := "admin_roles" + + // 构建查询条件 + where := map[string]any{} + if keyword != "" { + where["_or"] = []map[string]any{ + {"name like": "%" + keyword + "%"}, + {"code like": "%" + keyword + "%"}, + {"description like": "%" + keyword + "%"}, + } + } + + // 查询总数 + countFields := []string{"COUNT(*) as total"} + countCond, countVals, err := builder.BuildSelect(table, where, countFields) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询角色总数失败: %v", err) + } + + // 查询数据 + selectFields := []string{"id", "name", "code", "description", "status", "created_at", "updated_at"} + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + + // 添加排序和分页 + offset := (page - 1) * pageSize + cond += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询角色列表失败: %v", err) + } + defer rows.Close() + + var roles []*admin_auth.AdminRole + for rows.Next() { + var role admin_auth.AdminRole + var description sql.NullString + + err := rows.Scan( + &role.ID, + &role.Name, + &role.Code, + &description, + &role.Status, + &role.CreatedAt, + &role.UpdatedAt, + ) + if err != nil { + continue + } + + if description.Valid { + role.Description = description.String + } + + roles = append(roles, &role) + } + + return roles, total, nil +} + +// GetByID 根据ID获取角色 +func (d *RoleDAO) GetByID(id string) (*admin_auth.AdminRole, error) { + query := `SELECT id, name, code, description, status, created_at, updated_at + FROM admin_roles WHERE id = ?` + + var role admin_auth.AdminRole + var description sql.NullString + + err := d.client.DB.QueryRow(query, id).Scan( + &role.ID, + &role.Name, + &role.Code, + &description, + &role.Status, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询角色失败: %v", err) + } + + if description.Valid { + role.Description = description.String + } + + return &role, nil +} + +// GetByCode 根据代码获取角色 +func (d *RoleDAO) GetByCode(code string) (*admin_auth.AdminRole, error) { + query := `SELECT id, name, code, description, status, created_at, updated_at + FROM admin_roles WHERE code = ?` + + var role admin_auth.AdminRole + var description sql.NullString + + err := d.client.DB.QueryRow(query, code).Scan( + &role.ID, + &role.Name, + &role.Code, + &description, + &role.Status, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询角色失败: %v", err) + } + + if description.Valid { + role.Description = description.String + } + + return &role, nil +} + +// Create 创建角色 +func (d *RoleDAO) Create(role *admin_auth.AdminRole) error { + table := "admin_roles" + data := []map[string]any{ + { + "id": role.ID, + "name": role.Name, + "code": role.Code, + "description": role.Description, + "status": role.Status, + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建角色失败: %v", err) + } + + return nil +} + +// Update 更新角色 +func (d *RoleDAO) Update(role *admin_auth.AdminRole) error { + table := "admin_roles" + where := map[string]any{ + "id": role.ID, + } + + data := map[string]any{ + "name": role.Name, + "code": role.Code, + "description": role.Description, + "status": role.Status, + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新角色失败: %v", err) + } + + return nil +} + +// Delete 删除角色(软删除,更新状态为禁用) +func (d *RoleDAO) Delete(id string) error { + where := map[string]any{ + "id": id, + } + data := map[string]any{ + "status": 0, // 禁用状态 + } + + cond, vals, err := builder.BuildUpdate("admin_roles", where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("删除角色失败: %v", err) + } + + return nil +} + +// GetRolePermissions 获取角色的权限列表 +func (d *RoleDAO) GetRolePermissions(roleID string) ([]string, error) { + query := `SELECT permission_id FROM admin_role_permissions WHERE role_id = ?` + + rows, err := d.client.DB.Query(query, roleID) + if err != nil { + return nil, fmt.Errorf("查询角色权限失败: %v", err) + } + defer rows.Close() + + var permissionIDs []string + for rows.Next() { + var permissionID string + if err := rows.Scan(&permissionID); err != nil { + continue + } + permissionIDs = append(permissionIDs, permissionID) + } + + return permissionIDs, nil +} + +// SetRolePermissions 设置角色的权限 +func (d *RoleDAO) SetRolePermissions(roleID string, permissionIDs []string) error { + // 先删除原有权限 + deleteQuery := `DELETE FROM admin_role_permissions WHERE role_id = ?` + _, err := d.client.DB.Exec(deleteQuery, roleID) + if err != nil { + return fmt.Errorf("删除角色权限失败: %v", err) + } + + // 插入新权限 + if len(permissionIDs) > 0 { + table := "admin_role_permissions" + data := make([]map[string]any, 0, len(permissionIDs)) + for _, permissionID := range permissionIDs { + // 生成关联ID + id, err := d.generateID() + if err != nil { + return fmt.Errorf("生成ID失败: %v", err) + } + data = append(data, map[string]any{ + "id": id, + "role_id": roleID, + "permission_id": permissionID, + }) + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("设置角色权限失败: %v", err) + } + } + + return nil +} + +// generateID 生成ID(使用雪花算法) +func (d *RoleDAO) generateID() (string, error) { + return snowflake.GenerateID(), nil +} + diff --git a/internal/admin_auth/handler/admin_user_handler.go b/internal/admin_auth/handler/admin_user_handler.go new file mode 100644 index 0000000..e57e0b4 --- /dev/null +++ b/internal/admin_auth/handler/admin_user_handler.go @@ -0,0 +1,288 @@ +package handler + +import ( + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/service" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// AdminUserHandler 管理员用户处理器 +type AdminUserHandler struct { + service *service.AdminUserService +} + +// NewAdminUserHandler 创建管理员用户处理器 +func NewAdminUserHandler(adminUserService *service.AdminUserService) *AdminUserHandler { + return &AdminUserHandler{ + service: adminUserService, + } +} + +// ListAdminUsers 列出管理员用户 +func (h *AdminUserHandler) ListAdminUsers(c *fiber.Ctx) error { + keyword := c.Query("keyword", "") + page, _ := strconv.Atoi(c.Query("page", "1")) + pageSize, _ := strconv.Atoi(c.Query("page_size", "10")) + + users, total, err := h.service.ListAdminUsers(keyword, page, pageSize) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取管理员列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取管理员列表成功", + "data": fiber.Map{ + "list": users, + "total": total, + "page": page, + "page_size": pageSize, + }, + }) +} + +// GetAdminUser 获取管理员用户详情 +func (h *AdminUserHandler) GetAdminUser(c *fiber.Ctx) error { + id := c.Query("id", "") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "管理员ID不能为空", + }) + } + + user, err := h.service.GetAdminUser(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取管理员详情失败: " + err.Error(), + }) + } + + if user == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": "管理员不存在", + }) + } + + // 不返回密码 + user.Password = "" + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取管理员详情成功", + "data": user, + }) +} + +// CreateAdminUser 创建管理员用户 +func (h *AdminUserHandler) CreateAdminUser(c *fiber.Ctx) error { + var req struct { + Username string `json:"username"` + Phone string `json:"phone"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Status int `json:"status"` + IsSuperAdmin int `json:"is_super_admin"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证必填字段 + if req.Username == "" && req.Phone == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户名或手机号至少填写一个", + }) + } + + if req.Password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "密码不能为空", + }) + } + + user := &admin_auth.AdminUser{ + Username: req.Username, + Phone: req.Phone, + Nickname: req.Nickname, + Avatar: req.Avatar, + Status: req.Status, + IsSuperAdmin: req.IsSuperAdmin, + } + + if err := h.service.CreateAdminUser(user, req.Password); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建管理员失败: " + err.Error(), + }) + } + + // 不返回密码 + user.Password = "" + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "创建管理员成功", + "data": user, + }) +} + +// UpdateAdminUser 更新管理员用户 +func (h *AdminUserHandler) UpdateAdminUser(c *fiber.Ctx) error { + var req struct { + ID string `json:"id"` + Username string `json:"username"` + Phone string `json:"phone"` + Password string `json:"password"` // 可选,如果为空则不更新密码 + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Status int `json:"status"` + IsSuperAdmin int `json:"is_super_admin"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "管理员ID不能为空", + }) + } + + user := &admin_auth.AdminUser{ + ID: req.ID, + Username: req.Username, + Phone: req.Phone, + Nickname: req.Nickname, + Avatar: req.Avatar, + Status: req.Status, + IsSuperAdmin: req.IsSuperAdmin, + } + + if err := h.service.UpdateAdminUser(user, req.Password); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新管理员失败: " + err.Error(), + }) + } + + // 不返回密码 + user.Password = "" + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "更新管理员成功", + "data": user, + }) +} + +// DeleteAdminUser 删除管理员用户 +func (h *AdminUserHandler) DeleteAdminUser(c *fiber.Ctx) error { + // 支持从 Query 或 Body 中获取 id + id := c.Query("id", "") + if id == "" { + var req struct { + ID string `json:"id"` + } + if err := c.BodyParser(&req); err == nil { + id = req.ID + } + } + + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "管理员ID不能为空", + }) + } + + if err := h.service.DeleteAdminUser(id); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除管理员失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "删除管理员成功", + }) +} + +// GetUserRoles 获取用户的角色列表 +func (h *AdminUserHandler) GetUserRoles(c *fiber.Ctx) error { + userID := c.Query("user_id", "") + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + + roleIDs, err := h.service.GetUserRoles(userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取用户角色失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取用户角色成功", + "data": roleIDs, + }) +} + +// SetUserRoles 设置用户的角色 +func (h *AdminUserHandler) SetUserRoles(c *fiber.Ctx) error { + var req struct { + UserID string `json:"user_id"` + RoleIDs []string `json:"role_ids"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + + if err := h.service.SetUserRoles(req.UserID, req.RoleIDs); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "设置用户角色失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "设置用户角色成功", + }) +} diff --git a/internal/admin_auth/handler/auth_handler.go b/internal/admin_auth/handler/auth_handler.go new file mode 100644 index 0000000..52024dc --- /dev/null +++ b/internal/admin_auth/handler/auth_handler.go @@ -0,0 +1,99 @@ +package handler + +import ( + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/service" + + "github.com/gofiber/fiber/v2" +) + +// AuthHandler 认证处理器 +type AuthHandler struct { + authService *service.AuthService +} + +// NewAuthHandler 创建认证处理器 +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +// Login 登录 +func (h *AuthHandler) Login(c *fiber.Ctx) error { + var req admin_auth.LoginRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证必填字段 + if req.Username == "" && req.Phone == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户名或手机号不能为空", + }) + } + if req.Password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "密码不能为空", + }) + } + + // 获取客户端IP + clientIP := c.IP() + + // 调用服务层 + resp, err := h.authService.Login(&req, clientIP) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "登录失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusUnauthorized).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetMe 获取当前用户信息 +func (h *AuthHandler) GetMe(c *fiber.Ctx) error { + // 从中间件获取token(中间件会将claims存储到locals中) + _, ok := c.Locals("claims").(*admin_auth.JWTClaims) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "未授权", + }) + } + + // 从数据库获取最新用户信息 + userInfo, err := h.authService.GetUserInfo(c.Get("Authorization")) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "获取用户信息失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "user": userInfo, + }) +} + +// Logout 登出(前端删除token即可,这里可以记录日志或清除缓存) +func (h *AuthHandler) Logout(c *fiber.Ctx) error { + // 可以在这里实现token黑名单、清除缓存等逻辑 + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "登出成功", + }) +} diff --git a/internal/admin_auth/handler/permission_handler.go b/internal/admin_auth/handler/permission_handler.go new file mode 100644 index 0000000..3e2a45c --- /dev/null +++ b/internal/admin_auth/handler/permission_handler.go @@ -0,0 +1,245 @@ +package handler + +import ( + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/service" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// PermissionHandler 权限处理器 +type PermissionHandler struct { + service *service.PermissionService +} + +// NewPermissionHandler 创建权限处理器 +func NewPermissionHandler(permissionService *service.PermissionService) *PermissionHandler { + return &PermissionHandler{ + service: permissionService, + } +} + +// ListPermissions 列出权限 +func (h *PermissionHandler) ListPermissions(c *fiber.Ctx) error { + keyword := c.Query("keyword", "") + resource := c.Query("resource", "") + page, _ := strconv.Atoi(c.Query("page", "1")) + pageSize, _ := strconv.Atoi(c.Query("page_size", "10")) + + permissions, total, err := h.service.ListPermissions(keyword, resource, page, pageSize) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取权限列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取权限列表成功", + "data": fiber.Map{ + "list": permissions, + "total": total, + "page": page, + "page_size": pageSize, + }, + }) +} + +// GetPermission 获取权限详情 +func (h *PermissionHandler) GetPermission(c *fiber.Ctx) error { + id := c.Query("id", "") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "权限ID不能为空", + }) + } + + permission, err := h.service.GetPermission(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取权限详情失败: " + err.Error(), + }) + } + + if permission == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": "权限不存在", + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取权限详情成功", + "data": permission, + }) +} + +// CreatePermission 创建权限 +func (h *PermissionHandler) CreatePermission(c *fiber.Ctx) error { + var req struct { + Name string `json:"name"` + Code string `json:"code"` + Resource string `json:"resource"` + Action string `json:"action"` + Description string `json:"description"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "权限名称不能为空", + }) + } + + if req.Code == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "权限代码不能为空", + }) + } + + if req.Resource == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "资源不能为空", + }) + } + + if req.Action == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "操作不能为空", + }) + } + + permission := &admin_auth.AdminPermission{ + Name: req.Name, + Code: req.Code, + Resource: req.Resource, + Action: req.Action, + Description: req.Description, + } + + if err := h.service.CreatePermission(permission); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建权限失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "创建权限成功", + "data": permission, + }) +} + +// UpdatePermission 更新权限 +func (h *PermissionHandler) UpdatePermission(c *fiber.Ctx) error { + var req struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Resource string `json:"resource"` + Action string `json:"action"` + Description string `json:"description"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "权限ID不能为空", + }) + } + + permission := &admin_auth.AdminPermission{ + ID: req.ID, + Name: req.Name, + Code: req.Code, + Resource: req.Resource, + Action: req.Action, + Description: req.Description, + } + + if err := h.service.UpdatePermission(permission); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新权限失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "更新权限成功", + "data": permission, + }) +} + +// DeletePermission 删除权限 +func (h *PermissionHandler) DeletePermission(c *fiber.Ctx) error { + id := c.Query("id", "") + if id == "" { + var req struct { + ID string `json:"id"` + } + if err := c.BodyParser(&req); err == nil { + id = req.ID + } + } + + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "权限ID不能为空", + }) + } + + if err := h.service.DeletePermission(id); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除权限失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "删除权限成功", + }) +} + +// GetResources 获取所有资源列表 +func (h *PermissionHandler) GetResources(c *fiber.Ctx) error { + resources, err := h.service.GetResources() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取资源列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取资源列表成功", + "data": resources, + }) +} + diff --git a/internal/admin_auth/handler/role_handler.go b/internal/admin_auth/handler/role_handler.go new file mode 100644 index 0000000..5bd9da6 --- /dev/null +++ b/internal/admin_auth/handler/role_handler.go @@ -0,0 +1,261 @@ +package handler + +import ( + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/service" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// RoleHandler 角色处理器 +type RoleHandler struct { + service *service.RoleService +} + +// NewRoleHandler 创建角色处理器 +func NewRoleHandler(roleService *service.RoleService) *RoleHandler { + return &RoleHandler{ + service: roleService, + } +} + +// ListRoles 列出角色 +func (h *RoleHandler) ListRoles(c *fiber.Ctx) error { + keyword := c.Query("keyword", "") + page, _ := strconv.Atoi(c.Query("page", "1")) + pageSize, _ := strconv.Atoi(c.Query("page_size", "10")) + + roles, total, err := h.service.ListRoles(keyword, page, pageSize) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取角色列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取角色列表成功", + "data": fiber.Map{ + "list": roles, + "total": total, + "page": page, + "page_size": pageSize, + }, + }) +} + +// GetRole 获取角色详情 +func (h *RoleHandler) GetRole(c *fiber.Ctx) error { + id := c.Query("id", "") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "角色ID不能为空", + }) + } + + role, err := h.service.GetRole(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取角色详情失败: " + err.Error(), + }) + } + + if role == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": "角色不存在", + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取角色详情成功", + "data": role, + }) +} + +// CreateRole 创建角色 +func (h *RoleHandler) CreateRole(c *fiber.Ctx) error { + var req struct { + Name string `json:"name"` + Code string `json:"code"` // 可选,如果为空则由后端自动生成 + Description string `json:"description"` + Status int `json:"status"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "角色名称不能为空", + }) + } + + role := &admin_auth.AdminRole{ + Name: req.Name, + Code: req.Code, // 如果为空,service 层会自动生成 + Description: req.Description, + Status: req.Status, + } + + if err := h.service.CreateRole(role); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建角色失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "创建角色成功", + "data": role, + }) +} + +// UpdateRole 更新角色 +func (h *RoleHandler) UpdateRole(c *fiber.Ctx) error { + var req struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description"` + Status int `json:"status"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "角色ID不能为空", + }) + } + + role := &admin_auth.AdminRole{ + ID: req.ID, + Name: req.Name, + Code: req.Code, + Description: req.Description, + Status: req.Status, + } + + if err := h.service.UpdateRole(role); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新角色失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "更新角色成功", + "data": role, + }) +} + +// DeleteRole 删除角色 +func (h *RoleHandler) DeleteRole(c *fiber.Ctx) error { + id := c.Query("id", "") + if id == "" { + var req struct { + ID string `json:"id"` + } + if err := c.BodyParser(&req); err == nil { + id = req.ID + } + } + + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "角色ID不能为空", + }) + } + + if err := h.service.DeleteRole(id); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除角色失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "删除角色成功", + }) +} + +// GetRolePermissions 获取角色的权限列表 +func (h *RoleHandler) GetRolePermissions(c *fiber.Ctx) error { + roleID := c.Query("role_id", "") + if roleID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "角色ID不能为空", + }) + } + + permissionIDs, err := h.service.GetRolePermissions(roleID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取角色权限失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "获取角色权限成功", + "data": permissionIDs, + }) +} + +// SetRolePermissions 设置角色的权限 +func (h *RoleHandler) SetRolePermissions(c *fiber.Ctx) error { + var req struct { + RoleID string `json:"role_id"` + PermissionIDs []string `json:"permission_ids"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.RoleID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "角色ID不能为空", + }) + } + + if err := h.service.SetRolePermissions(req.RoleID, req.PermissionIDs); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "设置角色权限失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "设置角色权限成功", + }) +} + diff --git a/internal/admin_auth/middleware/auth_middleware.go b/internal/admin_auth/middleware/auth_middleware.go new file mode 100644 index 0000000..17b4b16 --- /dev/null +++ b/internal/admin_auth/middleware/auth_middleware.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/service" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// AuthMiddleware 认证中间件 +func AuthMiddleware(authService *service.AuthService) fiber.Handler { + return func(c *fiber.Ctx) error { + // 获取Authorization header + authHeader := c.Get("Authorization") + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "未授权,请先登录", + }) + } + + // 解析Bearer token + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "token格式错误", + }) + } + + tokenString := parts[1] + + // 验证token + claims, err := authService.VerifyToken(tokenString) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "token无效或已过期", + }) + } + + // 将claims存储到locals中,供后续使用 + c.Locals("claims", claims) + c.Locals("user_id", claims.UserID) + c.Locals("username", claims.Username) + c.Locals("phone", claims.Phone) + c.Locals("is_super_admin", claims.IsSuperAdmin) + + return c.Next() + } +} + +// PermissionMiddleware 权限中间件 +func PermissionMiddleware(permissionCode string) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, ok := c.Locals("claims").(*admin_auth.JWTClaims) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "未授权", + }) + } + + // 超级管理员拥有所有权限 + if claims.IsSuperAdmin { + return c.Next() + } + + // 检查是否有指定权限 + hasPermission := false + for _, perm := range claims.Permissions { + if perm == permissionCode { + hasPermission = true + break + } + } + + if !hasPermission { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "success": false, + "message": "没有权限访问", + }) + } + + return c.Next() + } +} + +// SuperAdminMiddleware 超级管理员中间件 +func SuperAdminMiddleware() fiber.Handler { + return func(c *fiber.Ctx) error { + isSuperAdmin, ok := c.Locals("is_super_admin").(bool) + if !ok || !isSuperAdmin { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "success": false, + "message": "需要超级管理员权限", + }) + } + + return c.Next() + } +} diff --git a/internal/admin_auth/service/admin_user_service.go b/internal/admin_auth/service/admin_user_service.go new file mode 100644 index 0000000..d208113 --- /dev/null +++ b/internal/admin_auth/service/admin_user_service.go @@ -0,0 +1,93 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/dao" + "dd_fiber_api/pkg/snowflake" + + "golang.org/x/crypto/bcrypt" +) + +// AdminUserService 管理员用户服务 +type AdminUserService struct { + dao *dao.AdminUserDAO +} + +// NewAdminUserService 创建管理员用户服务 +func NewAdminUserService(adminUserDAO *dao.AdminUserDAO) *AdminUserService { + return &AdminUserService{ + dao: adminUserDAO, + } +} + +// ListAdminUsers 列出管理员用户 +func (s *AdminUserService) ListAdminUsers(keyword string, page, pageSize int) ([]*admin_auth.AdminUser, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + return s.dao.List(keyword, page, pageSize) +} + +// GetAdminUser 获取管理员用户详情 +func (s *AdminUserService) GetAdminUser(id string) (*admin_auth.AdminUser, error) { + return s.dao.GetByID(id) +} + +// CreateAdminUser 创建管理员用户 +func (s *AdminUserService) CreateAdminUser(user *admin_auth.AdminUser, password string) error { + // 生成ID + id := snowflake.GenerateID() + user.ID = id + + // 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("加密密码失败: %v", err) + } + user.Password = string(hashedPassword) + + // 设置默认值 + if user.Status == 0 { + user.Status = 1 // 默认启用 + } + + return s.dao.Create(user) +} + +// UpdateAdminUser 更新管理员用户 +func (s *AdminUserService) UpdateAdminUser(user *admin_auth.AdminUser, password string) error { + // 如果提供了新密码,则加密 + if password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("加密密码失败: %v", err) + } + user.Password = string(hashedPassword) + } + + return s.dao.Update(user) +} + +// DeleteAdminUser 删除管理员用户 +func (s *AdminUserService) DeleteAdminUser(id string) error { + return s.dao.Delete(id) +} + +// GetUserRoles 获取用户的角色ID列表 +func (s *AdminUserService) GetUserRoles(userID string) ([]string, error) { + return s.dao.GetUserRoleIDs(userID) +} + +// SetUserRoles 设置用户的角色 +func (s *AdminUserService) SetUserRoles(userID string, roleIDs []string) error { + return s.dao.SetUserRoles(userID, roleIDs) +} diff --git a/internal/admin_auth/service/auth_service.go b/internal/admin_auth/service/auth_service.go new file mode 100644 index 0000000..c20922e --- /dev/null +++ b/internal/admin_auth/service/auth_service.go @@ -0,0 +1,187 @@ +package service + +import ( + "fmt" + "time" + + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/dao" + jwt_pkg "dd_fiber_api/pkg/jwt" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +// AuthService 认证服务 +type AuthService struct { + adminUserDAO *dao.AdminUserDAO + jwt *jwt_pkg.JWT + jwtExpiresIn time.Duration +} + +// NewAuthService 创建认证服务 +func NewAuthService(adminUserDAO *dao.AdminUserDAO, jwtSecret string, jwtExpiresIn time.Duration) *AuthService { + return &AuthService{ + adminUserDAO: adminUserDAO, + jwt: jwt_pkg.NewJWT(jwtSecret, jwtExpiresIn), + jwtExpiresIn: jwtExpiresIn, + } +} + +// Login 登录(支持用户名或手机号登录) +func (s *AuthService) Login(req *admin_auth.LoginRequest, clientIP string) (*admin_auth.LoginResponse, error) { + var user *admin_auth.AdminUser + var err error + + // 优先使用用户名登录,如果没有用户名则使用手机号 + if req.Username != "" { + user, err = s.adminUserDAO.GetByUsername(req.Username) + } else if req.Phone != "" { + user, err = s.adminUserDAO.GetByPhone(req.Phone) + } else { + return &admin_auth.LoginResponse{ + Success: false, + Message: "用户名或手机号不能为空", + }, nil + } + + if err != nil { + return &admin_auth.LoginResponse{ + Success: false, + Message: "登录失败: " + err.Error(), + }, nil + } + + if user == nil { + return &admin_auth.LoginResponse{ + Success: false, + Message: "用户名或密码错误", + }, nil + } + + // 验证密码 + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + return &admin_auth.LoginResponse{ + Success: false, + Message: "用户名或密码错误", + }, nil + } + + // 获取用户角色和权限 + roles, err := s.adminUserDAO.GetUserRoles(user.ID) + if err != nil { + return &admin_auth.LoginResponse{ + Success: false, + Message: "获取用户角色失败: " + err.Error(), + }, nil + } + if roles == nil { + roles = []string{} + } + + permissions, err := s.adminUserDAO.GetUserPermissions(user.ID) + if err != nil { + return &admin_auth.LoginResponse{ + Success: false, + Message: "获取用户权限失败: " + err.Error(), + }, nil + } + if permissions == nil { + permissions = []string{} + } + + // 如果是超级管理员,添加所有权限标记 + isSuperAdmin := user.IsSuperAdmin == 1 + + // 生成JWT token + claims := &admin_auth.JWTClaims{ + UserID: user.ID, + Username: user.Username, + Phone: user.Phone, + IsSuperAdmin: isSuperAdmin, + Roles: roles, + Permissions: permissions, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.jwtExpiresIn)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + + token, err := s.jwt.GenerateToken(claims) + if err != nil { + return &admin_auth.LoginResponse{ + Success: false, + Message: "生成token失败: " + err.Error(), + }, nil + } + + // 更新最后登录信息 + if err := s.adminUserDAO.UpdateLastLogin(user.ID, clientIP); err != nil { + // 登录信息更新失败不影响登录,只记录日志 + fmt.Printf("更新最后登录信息失败: %v\n", err) + } + + // 构建用户信息 + userInfo := &admin_auth.AdminUserInfo{ + ID: user.ID, + Username: user.Username, + Phone: user.Phone, + Nickname: user.Nickname, + Avatar: user.Avatar, + IsSuperAdmin: isSuperAdmin, + Roles: roles, + Permissions: permissions, + } + + return &admin_auth.LoginResponse{ + Success: true, + Message: "登录成功", + Token: token, + User: userInfo, + }, nil +} + +// GetUserInfo 获取用户信息(从token中解析) +func (s *AuthService) GetUserInfo(tokenString string) (*admin_auth.AdminUserInfo, error) { + claims := &admin_auth.JWTClaims{} + if err := s.jwt.ParseToken(tokenString, claims); err != nil { + return nil, err + } + + // 从数据库获取最新用户信息 + user, err := s.adminUserDAO.GetByID(claims.UserID) + if err != nil || user == nil { + return nil, fmt.Errorf("用户不存在") + } + + // 获取最新角色和权限 + roles, _ := s.adminUserDAO.GetUserRoles(user.ID) + if roles == nil { + roles = []string{} + } + permissions, _ := s.adminUserDAO.GetUserPermissions(user.ID) + if permissions == nil { + permissions = []string{} + } + + return &admin_auth.AdminUserInfo{ + ID: user.ID, + Username: user.Username, + Phone: user.Phone, + Nickname: user.Nickname, + Avatar: user.Avatar, + IsSuperAdmin: user.IsSuperAdmin == 1, + Roles: roles, + Permissions: permissions, + }, nil +} + +// VerifyToken 验证token +func (s *AuthService) VerifyToken(tokenString string) (*admin_auth.JWTClaims, error) { + claims := &admin_auth.JWTClaims{} + if err := s.jwt.ParseToken(tokenString, claims); err != nil { + return nil, err + } + return claims, nil +} diff --git a/internal/admin_auth/service/permission_service.go b/internal/admin_auth/service/permission_service.go new file mode 100644 index 0000000..4ad1d3f --- /dev/null +++ b/internal/admin_auth/service/permission_service.go @@ -0,0 +1,94 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/dao" + "dd_fiber_api/pkg/snowflake" +) + +// PermissionService 权限服务 +type PermissionService struct { + permissionDAO *dao.PermissionDAO +} + +// NewPermissionService 创建权限服务 +func NewPermissionService(permissionDAO *dao.PermissionDAO) *PermissionService { + return &PermissionService{ + permissionDAO: permissionDAO, + } +} + +// ListPermissions 列出权限 +func (s *PermissionService) ListPermissions(keyword, resource string, page, pageSize int) ([]*admin_auth.AdminPermission, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + return s.permissionDAO.List(keyword, resource, page, pageSize) +} + +// GetPermission 获取权限详情 +func (s *PermissionService) GetPermission(id string) (*admin_auth.AdminPermission, error) { + return s.permissionDAO.GetByID(id) +} + +// CreatePermission 创建权限 +func (s *PermissionService) CreatePermission(permission *admin_auth.AdminPermission) error { + // 检查代码是否已存在 + existing, err := s.permissionDAO.GetByCode(permission.Code) + if err != nil { + return fmt.Errorf("检查权限代码失败: %v", err) + } + if existing != nil { + return fmt.Errorf("权限代码已存在: %s", permission.Code) + } + + // 生成ID + permission.ID = snowflake.GenerateID() + + return s.permissionDAO.Create(permission) +} + +// UpdatePermission 更新权限 +func (s *PermissionService) UpdatePermission(permission *admin_auth.AdminPermission) error { + // 检查权限是否存在 + existing, err := s.permissionDAO.GetByID(permission.ID) + if err != nil { + return fmt.Errorf("查询权限失败: %v", err) + } + if existing == nil { + return fmt.Errorf("权限不存在") + } + + // 如果修改了代码,检查新代码是否已存在 + if permission.Code != existing.Code { + codeExists, err := s.permissionDAO.GetByCode(permission.Code) + if err != nil { + return fmt.Errorf("检查权限代码失败: %v", err) + } + if codeExists != nil { + return fmt.Errorf("权限代码已存在: %s", permission.Code) + } + } + + return s.permissionDAO.Update(permission) +} + +// DeletePermission 删除权限 +func (s *PermissionService) DeletePermission(id string) error { + return s.permissionDAO.Delete(id) +} + +// GetResources 获取所有资源列表 +func (s *PermissionService) GetResources() ([]string, error) { + return s.permissionDAO.GetResources() +} + diff --git a/internal/admin_auth/service/role_service.go b/internal/admin_auth/service/role_service.go new file mode 100644 index 0000000..f537396 --- /dev/null +++ b/internal/admin_auth/service/role_service.go @@ -0,0 +1,135 @@ +package service + +import ( + "fmt" + "regexp" + "strings" + + "dd_fiber_api/internal/admin_auth" + "dd_fiber_api/internal/admin_auth/dao" + "dd_fiber_api/pkg/snowflake" +) + +// RoleService 角色服务 +type RoleService struct { + roleDAO *dao.RoleDAO +} + +// NewRoleService 创建角色服务 +func NewRoleService(roleDAO *dao.RoleDAO) *RoleService { + return &RoleService{ + roleDAO: roleDAO, + } +} + +// ListRoles 列出角色 +func (s *RoleService) ListRoles(keyword string, page, pageSize int) ([]*admin_auth.AdminRole, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + return s.roleDAO.List(keyword, page, pageSize) +} + +// GetRole 获取角色详情 +func (s *RoleService) GetRole(id string) (*admin_auth.AdminRole, error) { + return s.roleDAO.GetByID(id) +} + +// generateRoleCode 根据角色名称生成角色代码 +func (s *RoleService) generateRoleCode(name string) string { + // 转换为小写 + code := strings.ToLower(name) + + // 移除所有非字母数字字符,用下划线替换空格和特殊字符 + reg := regexp.MustCompile(`[^a-z0-9]+`) + code = reg.ReplaceAllString(code, "_") + + // 移除开头和结尾的下划线 + code = strings.Trim(code, "_") + + // 如果为空,使用默认值 + if code == "" { + code = "role" + } + + // 确保代码唯一:添加时间戳后缀(使用雪花ID的一部分) + uniqueSuffix := snowflake.GenerateID()[:8] + code = fmt.Sprintf("%s_%s", code, uniqueSuffix) + + return code +} + +// CreateRole 创建角色 +func (s *RoleService) CreateRole(role *admin_auth.AdminRole) error { + // 如果代码为空,自动生成 + if role.Code == "" { + role.Code = s.generateRoleCode(role.Name) + } else { + // 检查代码是否已存在 + existing, err := s.roleDAO.GetByCode(role.Code) + if err != nil { + return fmt.Errorf("检查角色代码失败: %v", err) + } + if existing != nil { + return fmt.Errorf("角色代码已存在: %s", role.Code) + } + } + + // 生成ID + role.ID = snowflake.GenerateID() + + // 设置默认值 + if role.Status == 0 { + role.Status = 1 // 默认启用 + } + + return s.roleDAO.Create(role) +} + +// UpdateRole 更新角色 +func (s *RoleService) UpdateRole(role *admin_auth.AdminRole) error { + // 检查角色是否存在 + existing, err := s.roleDAO.GetByID(role.ID) + if err != nil { + return fmt.Errorf("查询角色失败: %v", err) + } + if existing == nil { + return fmt.Errorf("角色不存在") + } + + // 如果修改了代码,检查新代码是否已存在 + if role.Code != existing.Code { + codeExists, err := s.roleDAO.GetByCode(role.Code) + if err != nil { + return fmt.Errorf("检查角色代码失败: %v", err) + } + if codeExists != nil { + return fmt.Errorf("角色代码已存在: %s", role.Code) + } + } + + return s.roleDAO.Update(role) +} + +// DeleteRole 删除角色 +func (s *RoleService) DeleteRole(id string) error { + return s.roleDAO.Delete(id) +} + +// GetRolePermissions 获取角色的权限列表 +func (s *RoleService) GetRolePermissions(roleID string) ([]string, error) { + return s.roleDAO.GetRolePermissions(roleID) +} + +// SetRolePermissions 设置角色的权限 +func (s *RoleService) SetRolePermissions(roleID string, permissionIDs []string) error { + return s.roleDAO.SetRolePermissions(roleID, permissionIDs) +} + diff --git a/internal/admin_auth/types.go b/internal/admin_auth/types.go new file mode 100644 index 0000000..9df099a --- /dev/null +++ b/internal/admin_auth/types.go @@ -0,0 +1,181 @@ +package admin_auth + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// DateTime 自定义时间类型,JSON 序列化时格式化为 datetime 格式 +type DateTime struct { + time.Time +} + +// MarshalJSON 实现 JSON 序列化,格式化为 "2006-01-02 15:04:05" +func (dt DateTime) MarshalJSON() ([]byte, error) { + if dt.Time.IsZero() { + return []byte("null"), nil + } + return json.Marshal(dt.Time.Format("2006-01-02 15:04:05")) +} + +// UnmarshalJSON 实现 JSON 反序列化 +func (dt *DateTime) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + if str == "" || str == "null" { + dt.Time = time.Time{} + return nil + } + t, err := time.Parse("2006-01-02 15:04:05", str) + if err != nil { + // 尝试 RFC3339 格式 + t, err = time.Parse(time.RFC3339, str) + if err != nil { + return err + } + } + dt.Time = t + return nil +} + +// Scan 实现 sql.Scanner 接口,支持从数据库扫描 +func (dt *DateTime) Scan(value interface{}) error { + if value == nil { + dt.Time = time.Time{} + return nil + } + switch v := value.(type) { + case time.Time: + dt.Time = v + return nil + case []byte: + return dt.parseTimeString(string(v)) + case string: + return dt.parseTimeString(v) + default: + return fmt.Errorf("cannot scan %T into DateTime", value) + } +} + +// parseTimeString 解析时间字符串 +func (dt *DateTime) parseTimeString(s string) error { + if s == "" || s == "null" { + dt.Time = time.Time{} + return nil + } + // 尝试 MySQL datetime 格式 + t, err := time.Parse("2006-01-02 15:04:05", s) + if err != nil { + // 尝试 RFC3339 格式 + t, err = time.Parse(time.RFC3339, s) + if err != nil { + return err + } + } + dt.Time = t + return nil +} + +// Value 实现 driver.Valuer 接口,支持写入数据库 +func (dt DateTime) Value() (driver.Value, error) { + if dt.Time.IsZero() { + return nil, nil + } + return dt.Time, nil +} + +// NewDateTime 创建 DateTime +func NewDateTime(t time.Time) DateTime { + return DateTime{Time: t} +} + +// NewDateTimePtr 创建 DateTime 指针 +func NewDateTimePtr(t time.Time) *DateTime { + if t.IsZero() { + return nil + } + return &DateTime{Time: t} +} + +// AdminUser 管理员用户 +type AdminUser struct { + ID string `json:"id" db:"id"` + Username string `json:"username" db:"username"` + Phone string `json:"phone" db:"phone"` + Password string `json:"-" db:"password"` // 不返回给前端 + Nickname string `json:"nickname" db:"nickname"` + Avatar string `json:"avatar" db:"avatar"` + Status int `json:"status" db:"status"` // 0=禁用,1=启用 + IsSuperAdmin int `json:"is_super_admin" db:"is_super_admin"` // 0=否,1=是 + LastLoginAt *DateTime `json:"last_login_at" db:"last_login_at"` + LastLoginIP string `json:"last_login_ip" db:"last_login_ip"` + CreatedAt DateTime `json:"created_at" db:"created_at"` + UpdatedAt DateTime `json:"updated_at" db:"updated_at"` +} + +// AdminRole 角色 +type AdminRole struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Code string `json:"code" db:"code"` + Description string `json:"description" db:"description"` + Status int `json:"status" db:"status"` + CreatedAt DateTime `json:"created_at" db:"created_at"` + UpdatedAt DateTime `json:"updated_at" db:"updated_at"` +} + +// AdminPermission 权限 +type AdminPermission struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Code string `json:"code" db:"code"` + Resource string `json:"resource" db:"resource"` + Action string `json:"action" db:"action"` + Description string `json:"description" db:"description"` + CreatedAt DateTime `json:"created_at" db:"created_at"` + UpdatedAt DateTime `json:"updated_at" db:"updated_at"` +} + +// LoginRequest 登录请求(支持用户名或手机号登录) +type LoginRequest struct { + Username string `json:"username"` // 用户名(可选) + Phone string `json:"phone"` // 手机号(可选) + Password string `json:"password"` // 密码(必填) +} + +// LoginResponse 登录响应 +type LoginResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Token string `json:"token,omitempty"` + User *AdminUserInfo `json:"user,omitempty"` +} + +// AdminUserInfo 管理员用户信息(返回给前端,不包含敏感信息) +type AdminUserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + Phone string `json:"phone"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + IsSuperAdmin bool `json:"is_super_admin"` + Roles []string `json:"roles"` // 角色代码列表 + Permissions []string `json:"permissions"` // 权限代码列表 +} + +// JWTClaims JWT 声明 +type JWTClaims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Phone string `json:"phone"` + IsSuperAdmin bool `json:"is_super_admin"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + jwt.RegisteredClaims +} diff --git a/internal/api/camp_routes.go b/internal/api/camp_routes.go new file mode 100644 index 0000000..e307a8a --- /dev/null +++ b/internal/api/camp_routes.go @@ -0,0 +1,61 @@ +package api + +import ( + camp_handler "dd_fiber_api/internal/camp/handler" + + "github.com/gofiber/fiber/v2" +) + +// SetupCampRoutes Camp接口 +// 统一使用 GET 和 POST 两种方法,不使用 RESTful 风格 +func SetupCampRoutes(router fiber.Router, campCategoryHandler *camp_handler.CategoryHandler, campHandler *camp_handler.CampHandler, sectionHandler *camp_handler.SectionHandler, taskHandler *camp_handler.TaskHandler, progressHandler *camp_handler.ProgressHandler, userCampHandler *camp_handler.UserCampHandler) { + // 如果所有handler都为空,则不设置路由 + if campCategoryHandler == nil && campHandler == nil && sectionHandler == nil && taskHandler == nil && progressHandler == nil && userCampHandler == nil { + return + } + + camp := router.Group("/camp") + + // ==================== 分类管理 ==================== + if campCategoryHandler != nil { + camp.Get("/categories", campCategoryHandler.ListCategories).Name("列出分类") + } + + // ==================== 打卡营管理 ==================== + if campHandler != nil { + camp.Get("/camps", campHandler.ListCamps).Name("列出打卡营") + camp.Get("/camps/detail", campHandler.GetCamp).Name("获取打卡营详情") + camp.Post("/camp_detail_with_user_status", campHandler.GetCampDetailWithStatus).Name("获取打卡营详情及状态") + camp.Post("/can_unlock_section", campHandler.CanUnlockSection).Name("检查是否可开启小节") + camp.Post("/can_start_task", campHandler.CanStartTask).Name("检查任务是否可开始") + } + + // ==================== 小节管理 ==================== + if sectionHandler != nil { + camp.Get("/sections", sectionHandler.ListSections).Name("列出小节") + camp.Post("/purchase_section", sectionHandler.PurchaseSection).Name("购买小节") + } + + // ==================== 用户进度管理 ==================== + // 注意:必须在任务路由之前注册,避免路由冲突 + if progressHandler != nil { + camp.Get("/progress/info", progressHandler.GetUserProgress).Name("获取用户进度") + camp.Post("/progress/update", progressHandler.UpdateUserProgress).Name("更新用户进度") + camp.Post("/progress/reset", progressHandler.ResetTaskProgress).Name("重置任务进度") + // 兼容旧接口路径 + camp.Post("/reset_task_progress", progressHandler.ResetTaskProgress).Name("重置任务进度(兼容)") + } + + // ==================== 任务管理 ==================== + if taskHandler != nil { + camp.Get("/tasks/detail", taskHandler.GetTask).Name("获取任务详情") + camp.Get("/tasks/list", taskHandler.ListTasks).Name("列出任务") + } + + // ==================== 用户打卡营管理 ==================== + if userCampHandler != nil { + camp.Post("/user_camp/join", userCampHandler.JoinCamp).Name("加入打卡营") + camp.Post("/user_camp/reset_progress", userCampHandler.ResetCampProgress).Name("重置打卡营进度") + camp.Post("/check_user_camp_status", userCampHandler.CheckUserCampStatusPost).Name("检查用户打卡营状态") + } +} diff --git a/internal/api/order_routes.go b/internal/api/order_routes.go new file mode 100644 index 0000000..38b7807 --- /dev/null +++ b/internal/api/order_routes.go @@ -0,0 +1,23 @@ +package api + +import ( + order_handler "dd_fiber_api/internal/order/handler" + + "github.com/gofiber/fiber/v2" +) + +// SetupOrderRoutes 订单管理路由 +func SetupOrderRoutes(router fiber.Router, orderHandler *order_handler.OrderHandler) { + if orderHandler == nil { + return + } + + // 创建订单路由(小程序专用) + router.Post("/order/create", orderHandler.CreateOrder).Name("创建订单") + + // 取消订单路由(小程序专用) + router.Post("/order/cancel", orderHandler.CancelOrder).Name("取消订单") + + // 自动关闭订单路由(定时任务回调,内部使用) + router.Get("/order/auto-cancel", orderHandler.AutoCancelOrder).Name("自动关闭订单") +} diff --git a/internal/api/oss_routes.go b/internal/api/oss_routes.go new file mode 100644 index 0000000..46322ef --- /dev/null +++ b/internal/api/oss_routes.go @@ -0,0 +1,20 @@ +package api + +import ( + "dd_fiber_api/internal/oss" + + "github.com/gofiber/fiber/v2" +) + +// SetupOSSRoutes OSS上传接口 +func SetupOSSRoutes(router fiber.Router, ossHandler *oss.Handler) { + if ossHandler == nil { + return + } + + oss := router.Group("/oss") + + // 获取OSS上传凭证(小程序使用) + oss.Get("/upload/signature", ossHandler.GetPolicyToken).Name("获取OSS上传凭证") +} + diff --git a/internal/api/payment_routes.go b/internal/api/payment_routes.go new file mode 100644 index 0000000..30eaa74 --- /dev/null +++ b/internal/api/payment_routes.go @@ -0,0 +1,21 @@ +package api + +import ( + "dd_fiber_api/internal/payment" + + "github.com/gofiber/fiber/v2" +) + +// SetupPaymentRoutes 支付接口 +func SetupPaymentRoutes(router fiber.Router, paymentHandler *payment.Handler) { + if paymentHandler == nil { + return + } + + payment := router.Group("/payment") + + // 微信支付V3 + wechat := payment.Group("/wechat/v3") + wechat.Post("", paymentHandler.CreateWechatPayV3).Name("创建支付订单") + wechat.Post("/notify", paymentHandler.HandleWechatPayV3Notify).Name("支付通知回调") +} diff --git a/internal/api/question_routes.go b/internal/api/question_routes.go new file mode 100644 index 0000000..e23d85f --- /dev/null +++ b/internal/api/question_routes.go @@ -0,0 +1,46 @@ +package api + +import ( + "dd_fiber_api/internal/question/handler" + + "github.com/gofiber/fiber/v2" +) + +// SetupQuestionRoutes 设置题目相关路由 +func SetupQuestionRoutes(app *fiber.App, questionHandler *handler.QuestionHandler, paperHandler *handler.PaperHandler, answerRecordHandler *handler.AnswerRecordHandler) { + // 题目相关路由 + question := app.Group("/api/v1/question") + { + question.Post("/create", questionHandler.CreateQuestion) // 创建题目 + question.Get("/detail", questionHandler.GetQuestion) // 获取题目详情 + question.Get("/search", questionHandler.SearchQuestions) // 搜索题目 + question.Post("/update", questionHandler.UpdateQuestion) // 更新题目 + question.Post("/delete", questionHandler.DeleteQuestion) // 删除题目 + question.Post("/batch_delete", questionHandler.BatchDeleteQuestions) // 批量删除题目 + } + + // 试卷相关路由 + paper := app.Group("/api/v1/paper") + { + paper.Post("/create", paperHandler.CreatePaper) // 创建试卷 + paper.Get("/detail", paperHandler.GetPaper) // 获取试卷详情 + paper.Get("/result", paperHandler.GetPaperWithAnswers) // 获取试卷详情(包含答案和解析,用于答题结果页面) + paper.Get("/search", paperHandler.SearchPapers) // 搜索试卷 + paper.Post("/update", paperHandler.UpdatePaper) // 更新试卷 + paper.Post("/delete", paperHandler.DeletePaper) // 删除试卷 + paper.Post("/batch_delete", paperHandler.BatchDeletePapers) // 批量删除试卷 + paper.Post("/add_question", paperHandler.AddQuestionToPaper) // 添加题目到试卷 + paper.Post("/remove_question", paperHandler.RemoveQuestionFromPaper) // 从试卷移除题目 + } + + // 答题记录相关路由 + answerRecord := app.Group("/api/v1/answer_record") + { + answerRecord.Post("/create", answerRecordHandler.CreateAnswerRecords) // 批量创建答题记录 + answerRecord.Get("/detail", answerRecordHandler.GetAnswerRecord) // 获取答题记录 + answerRecord.Get("/user", answerRecordHandler.GetUserAnswerRecord) // 获取用户答题记录 + answerRecord.Get("/statistics", answerRecordHandler.GetPaperAnswerStatistics) // 获取试卷答题统计 + answerRecord.Post("/delete", answerRecordHandler.DeleteAnswerRecord) // 删除答题记录 + } +} + diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..a381fc4 --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,39 @@ +package api + +import ( + camp_handler "dd_fiber_api/internal/camp/handler" + order_handler "dd_fiber_api/internal/order/handler" + question_handler "dd_fiber_api/internal/question/handler" + "dd_fiber_api/internal/oss" + "dd_fiber_api/internal/payment" + "dd_fiber_api/internal/scheduler" + + "github.com/gofiber/fiber/v2" +) + +// SetupRoutes 设置API路由(微信小程序接口) +func SetupRoutes(app *fiber.App, ossHandler *oss.Handler, paymentHandler *payment.Handler, schedulerHandler *scheduler.Handler, campCategoryHandler *camp_handler.CategoryHandler, campHandler *camp_handler.CampHandler, sectionHandler *camp_handler.SectionHandler, taskHandler *camp_handler.TaskHandler, progressHandler *camp_handler.ProgressHandler, userCampHandler *camp_handler.UserCampHandler, orderHandler *order_handler.OrderHandler, questionHandler *question_handler.QuestionHandler, paperHandler *question_handler.PaperHandler, answerRecordHandler *question_handler.AnswerRecordHandler) { + // 健康检查 + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "service": "api", + }) + }).Name("health.check") + + // API版本前缀 + v1 := app.Group("/api/v1") + + // 订单路由(必须在其他路由之前注册,避免路由冲突) + SetupOrderRoutes(v1, orderHandler) + // oss路由 + SetupOSSRoutes(v1, ossHandler) + // 支付路由 + SetupPaymentRoutes(v1, paymentHandler) + // 调度器路由 定时任务 调度器 + SetupSchedulerRoutes(v1, schedulerHandler) + // 打卡营路由 + SetupCampRoutes(v1, campCategoryHandler, campHandler, sectionHandler, taskHandler, progressHandler, userCampHandler) + // 题目相关路由 + SetupQuestionRoutes(app, questionHandler, paperHandler, answerRecordHandler) +} diff --git a/internal/api/scheduler_routes.go b/internal/api/scheduler_routes.go new file mode 100644 index 0000000..683d7c3 --- /dev/null +++ b/internal/api/scheduler_routes.go @@ -0,0 +1,23 @@ +package api + +import ( + "dd_fiber_api/internal/scheduler" + + "github.com/gofiber/fiber/v2" +) + +// SetupSchedulerRoutes 调度器接口 +func SetupSchedulerRoutes(router fiber.Router, schedulerHandler *scheduler.Handler) { + if schedulerHandler == nil { + return + } + + sched := router.Group("/scheduler") + + // 任务管理 + sched.Post("/tasks", schedulerHandler.AddTask).Name("添加任务") + sched.Delete("/tasks/:task_id", schedulerHandler.RemoveTask).Name("删除任务") + sched.Get("/tasks/:task_id/status", schedulerHandler.GetTaskStatus).Name("查询任务状态") + sched.Get("/tasks", schedulerHandler.ListTasks).Name("列出所有任务") + sched.Get("/tasks/count", schedulerHandler.GetTaskCount).Name("获取任务数量") +} diff --git a/internal/camp/dao/camp_dao.go b/internal/camp/dao/camp_dao.go new file mode 100644 index 0000000..3271a43 --- /dev/null +++ b/internal/camp/dao/camp_dao.go @@ -0,0 +1,347 @@ +package dao + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/utils" + + "github.com/didi/gendry/builder" +) + +// CampDAO 打卡营数据访问对象 +type CampDAO struct { + client *database.MySQLClient +} + +// NewCampDAO 创建打卡营DAO实例 +func NewCampDAO(client *database.MySQLClient) *CampDAO { + return &CampDAO{ + client: client, + } +} + +// Create 创建打卡营 +func (d *CampDAO) Create(camp *camp.Camp) error { + table := "camp_camps" + data := []map[string]any{ + { + "id": camp.ID, + "category_id": camp.CategoryID, + "title": camp.Title, + "cover_image": camp.CoverImage, + "description": camp.Description, + "intro_type": convertIntroType(camp.IntroType), + "intro_content": camp.IntroContent, + "is_recommended": camp.IsRecommended, + "section_count": camp.SectionCount, + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建打卡营失败: %v", err) + } + return nil +} + +// GetByID 根据ID获取打卡营 +func (d *CampDAO) GetByID(id string) (*camp.Camp, error) { + table := "camp_camps" + where := map[string]any{ + "id": id, + } + selectFields := []string{"id", "category_id", "title", "cover_image", "description", "intro_type", "intro_content", "is_recommended", "section_count", "deleted_at"} + + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, fmt.Errorf("构建查询失败: %v", err) + } + if strings.Contains(cond, "WHERE") { + cond += " AND deleted_at IS NULL" + } else { + cond += " WHERE deleted_at IS NULL" + } + + var campObj camp.Camp + var coverImage, description, introContent sql.NullString + var introTypeStr string + var deletedAt sql.NullTime + + err = d.client.DB.QueryRow(cond, vals...).Scan( + &campObj.ID, + &campObj.CategoryID, + &campObj.Title, + &coverImage, + &description, + &introTypeStr, + &introContent, + &campObj.IsRecommended, + &campObj.SectionCount, + &deletedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("打卡营不存在: %s", id) + } + if err != nil { + return nil, fmt.Errorf("查询打卡营失败: %v", err) + } + + campObj.CoverImage = coverImage.String + campObj.Description = description.String + campObj.IntroType = parseIntroType(introTypeStr) + campObj.IntroContent = introContent.String + campObj.DeletedAt = utils.FormatNullTimeToStd(deletedAt) + + return &campObj, nil +} + +// Update 更新打卡营 +func (d *CampDAO) Update(campObj *camp.Camp) error { + table := "camp_camps" + where := map[string]any{ + "id": campObj.ID, + } + data := map[string]any{ + "category_id": campObj.CategoryID, + "title": campObj.Title, + "cover_image": campObj.CoverImage, + "description": campObj.Description, + "intro_type": convertIntroType(campObj.IntroType), + "intro_content": campObj.IntroContent, + "is_recommended": campObj.IsRecommended, + "section_count": campObj.SectionCount, + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + cond += " AND deleted_at IS NULL" + + result, err := d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新打卡营失败: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("获取影响行数失败: %v", err) + } + if rows == 0 { + return fmt.Errorf("打卡营不存在: %s", campObj.ID) + } + + return nil +} + +// Delete 删除打卡营(软删除) +func (d *CampDAO) Delete(id string) error { + table := "camp_camps" + where := map[string]any{ + "id": id, + } + + data := map[string]any{ + "deleted_at": time.Now(), + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建删除语句失败: %v", err) + } + cond += " AND deleted_at IS NULL" + + result, err := d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("删除打卡营失败: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("获取影响行数失败: %v", err) + } + if rows == 0 { + return fmt.Errorf("打卡营不存在: %s", id) + } + + return nil +} + +// Search 搜索打卡营(支持关键词、分类、推荐状态) +func (d *CampDAO) Search(keyword, categoryID string, isRecommended *bool, page, pageSize int) ([]*camp.Camp, int, error) { + table := "camp_camps" + + // 构建查询条件 + where := map[string]any{} + + if keyword != "" { + where["_or"] = []map[string]any{ + {"title like": "%" + keyword + "%"}, + {"description like": "%" + keyword + "%"}, + } + } + + if categoryID != "" { + where["category_id"] = categoryID + } + + if isRecommended != nil { + where["is_recommended"] = *isRecommended + } + + // 查询总数 + countCond, countVals, err := builder.BuildSelect(table, where, []string{"count(*) as total"}) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + if strings.Contains(countCond, "WHERE") { + countCond += " AND deleted_at IS NULL" + } else { + countCond += " WHERE deleted_at IS NULL" + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询打卡营总数失败: %v", err) + } + + // 查询数据 + selectFields := []string{"id", "category_id", "title", "cover_image", "description", "intro_type", "intro_content", "is_recommended", "section_count", "deleted_at"} + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + if strings.Contains(cond, "WHERE") { + cond += " AND deleted_at IS NULL" + } else { + cond += " WHERE deleted_at IS NULL" + } + + // 添加排序和分页 + offset := (page - 1) * pageSize + cond += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询打卡营列表失败: %v", err) + } + defer rows.Close() + + camps := make([]*camp.Camp, 0) + for rows.Next() { + var campObj camp.Camp + var coverImage, description, introContent sql.NullString + var introTypeStr string + var deletedAt sql.NullTime + + err := rows.Scan( + &campObj.ID, + &campObj.CategoryID, + &campObj.Title, + &coverImage, + &description, + &introTypeStr, + &introContent, + &campObj.IsRecommended, + &campObj.SectionCount, + &deletedAt, + ) + if err != nil { + continue + } + + campObj.CoverImage = coverImage.String + campObj.Description = description.String + campObj.IntroType = parseIntroType(introTypeStr) + campObj.IntroContent = introContent.String + campObj.DeletedAt = utils.FormatNullTimeToStd(deletedAt) + + camps = append(camps, &campObj) + } + + if err = rows.Err(); err != nil { + return nil, 0, fmt.Errorf("遍历打卡营数据失败: %v", err) + } + + return camps, total, nil +} + +// CountByCategoryID 统计指定分类下的打卡营数量(未软删除) +func (d *CampDAO) CountByCategoryID(categoryID string) (int, error) { + query := "SELECT COUNT(*) FROM camp_camps WHERE category_id = ? AND deleted_at IS NULL" + var count int + err := d.client.DB.QueryRow(query, categoryID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("统计分类下打卡营数量失败: %v", err) + } + return count, nil +} + +// UpdateSectionCount 根据实际小节数量更新打卡营的 section_count +func (d *CampDAO) UpdateSectionCount(campID string) error { + // 统计该打卡营的实际小节数量 + countQuery := `SELECT COUNT(*) FROM camp_sections WHERE camp_id = ? AND deleted_at IS NULL` + var actualCount int + err := d.client.DB.QueryRow(countQuery, campID).Scan(&actualCount) + if err != nil { + return fmt.Errorf("统计小节数量失败: %v", err) + } + + // 更新打卡营的 section_count + where := map[string]any{ + "id": campID, + } + data := map[string]any{ + "section_count": actualCount, + } + + cond, vals, err := builder.BuildUpdate("camp_camps", where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新打卡营小节数量失败: %v", err) + } + + return nil +} + +// convertIntroType 将 IntroType 转换为数据库 ENUM 字符串 +func convertIntroType(introType camp.IntroType) string { + switch introType { + case camp.IntroTypeImageText: + return "IMAGE_TEXT" + case camp.IntroTypeVideo: + return "VIDEO" + default: + return "NONE" + } +} + +// parseIntroType 将数据库 ENUM 字符串转换为 IntroType +func parseIntroType(introTypeStr string) camp.IntroType { + switch introTypeStr { + case "IMAGE_TEXT": + return camp.IntroTypeImageText + case "VIDEO": + return camp.IntroTypeVideo + default: + return camp.IntroTypeNone + } +} + diff --git a/internal/camp/dao/category_dao.go b/internal/camp/dao/category_dao.go new file mode 100644 index 0000000..ff25602 --- /dev/null +++ b/internal/camp/dao/category_dao.go @@ -0,0 +1,206 @@ +package dao + +import ( + "database/sql" + "fmt" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/pkg/database" + + "github.com/didi/gendry/builder" +) + +// CategoryDAO 分类数据访问对象 +type CategoryDAO struct { + client *database.MySQLClient +} + +// NewCategoryDAO 创建分类DAO实例 +func NewCategoryDAO(client *database.MySQLClient) *CategoryDAO { + return &CategoryDAO{ + client: client, + } +} + +// Create 创建分类 +func (d *CategoryDAO) Create(category *camp.Category) error { + table := "camp_categories" + data := []map[string]any{ + { + "id": category.ID, + "name": category.Name, + "sort_order": category.SortOrder, + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建分类失败: %v", err) + } + return nil +} + +// GetByID 根据ID获取分类 +func (d *CategoryDAO) GetByID(id string) (*camp.Category, error) { + table := "camp_categories" + where := map[string]any{ + "id": id, + } + selectFields := []string{"id", "name", "sort_order"} + + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, fmt.Errorf("构建查询失败: %v", err) + } + + var category camp.Category + err = d.client.DB.QueryRow(cond, vals...).Scan( + &category.ID, + &category.Name, + &category.SortOrder, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("分类不存在: %s", id) + } + if err != nil { + return nil, fmt.Errorf("查询分类失败: %v", err) + } + + return &category, nil +} + +// Update 更新分类 +func (d *CategoryDAO) Update(category *camp.Category) error { + table := "camp_categories" + where := map[string]any{ + "id": category.ID, + } + data := map[string]any{ + "name": category.Name, + "sort_order": category.SortOrder, + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + result, err := d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新分类失败: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("获取影响行数失败: %v", err) + } + if rows == 0 { + return fmt.Errorf("分类不存在: %s", category.ID) + } + + return nil +} + +// Delete 删除分类 +func (d *CategoryDAO) Delete(id string) error { + table := "camp_categories" + where := map[string]any{ + "id": id, + } + + cond, vals, err := builder.BuildDelete(table, where) + if err != nil { + return fmt.Errorf("构建删除语句失败: %v", err) + } + + result, err := d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("删除分类失败: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("获取影响行数失败: %v", err) + } + if rows == 0 { + return fmt.Errorf("分类不存在: %s", id) + } + + return nil +} + +// List 列出分类(支持关键词搜索和分页) +func (d *CategoryDAO) List(keyword string, page, pageSize int) ([]*camp.Category, int, error) { + table := "camp_categories" + + // 构建查询条件 + where := map[string]any{} + + if keyword != "" { + // gendry 支持 LIKE 查询 + where["_or"] = []map[string]any{ + {"name like": "%" + keyword + "%"}, + {"id like": "%" + keyword + "%"}, + } + } + + // 查询总数 + countFields := []string{"COUNT(*) as total"} + countCond, countVals, err := builder.BuildSelect(table, where, countFields) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询分类总数失败: %v", err) + } + + // 查询数据 + selectFields := []string{"id", "name", "sort_order"} + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + + // 添加排序和分页 + offset := (page - 1) * pageSize + cond += " ORDER BY sort_order DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询分类列表失败: %v", err) + } + defer rows.Close() + + categories := make([]*camp.Category, 0) + for rows.Next() { + var category camp.Category + + err := rows.Scan( + &category.ID, + &category.Name, + &category.SortOrder, + ) + if err != nil { + continue + } + + categories = append(categories, &category) + } + + if err = rows.Err(); err != nil { + return nil, 0, fmt.Errorf("遍历分类数据失败: %v", err) + } + + return categories, total, nil +} + diff --git a/internal/camp/dao/progress_dao.go b/internal/camp/dao/progress_dao.go new file mode 100644 index 0000000..ff157c0 --- /dev/null +++ b/internal/camp/dao/progress_dao.go @@ -0,0 +1,917 @@ +package dao + +import ( + "database/sql" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/utils" + + "github.com/didi/gendry/builder" +) + +// ProgressDAO 用户进度数据访问对象 +type ProgressDAO struct { + client *database.MySQLClient +} + +// NewProgressDAO 创建进度DAO实例 +func NewProgressDAO(client *database.MySQLClient) *ProgressDAO { + return &ProgressDAO{ + client: client, + } +} + +// marshalAnswerImages 将进度答案序列化为 answer_images JSON:申论题为 [][]string,否则 []string +func marshalAnswerImages(progress *camp.UserProgress) ([]byte, error) { + if len(progress.EssayAnswerImages) > 0 { + return json.Marshal(progress.EssayAnswerImages) + } + return json.Marshal(progress.AnswerImages) +} + +// unmarshalAnswerImages 从 answer_images JSON 反序列化:若为二维数组则填 EssayAnswerImages,否则填 AnswerImages +func unmarshalAnswerImages(raw string, progress *camp.UserProgress) { + if raw == "" { + return + } + // 先尝试按申论格式 [["url"],["url"]] 解析 + var essay [][]string + if err := json.Unmarshal([]byte(raw), &essay); err == nil && len(essay) > 0 { + progress.EssayAnswerImages = essay + return + } + // 再按扁平格式 ["url","url"] 解析 + var flat []string + if err := json.Unmarshal([]byte(raw), &flat); err == nil { + for _, img := range flat { + if strings.TrimSpace(img) != "" { + progress.AnswerImages = append(progress.AnswerImages, img) + } + } + } +} + +// marshalEssayReviewStatuses 将申论每题审核状态序列化为 JSON 数组字符串 +func marshalEssayReviewStatuses(st []string) (string, error) { + if len(st) == 0 { + return "", nil + } + b, err := json.Marshal(st) + if err != nil { + return "", err + } + return string(b), nil +} + +// unmarshalEssayReviewStatuses 从 essay_review_statuses JSON 反序列化 +func unmarshalEssayReviewStatuses(raw string) []string { + if raw == "" { + return nil + } + var st []string + if err := json.Unmarshal([]byte(raw), &st); err != nil { + return nil + } + return st +} + +// Create 创建用户进度 +func (d *ProgressDAO) Create(progress *camp.UserProgress) error { + table := "camp_user_progress" + + // 通过 task_id 查询 camp_id、section_id + campID, sectionID, needReview, err := d.getTaskInfo(progress.TaskID) + if err != nil { + return fmt.Errorf("获取任务信息失败: %v", err) + } + progress.CampID = campID + progress.NeedReview = needReview + + // 序列化审核图片数组 + reviewImagesJSON, err := json.Marshal(progress.ReviewImages) + if err != nil { + return fmt.Errorf("序列化审核图片失败: %v", err) + } + answerImagesJSON, err := marshalAnswerImages(progress) + if err != nil { + return fmt.Errorf("序列化答案图片失败: %v", err) + } + essayReviewStatusesJSON, _ := marshalEssayReviewStatuses(progress.EssayReviewStatuses) + + data := []map[string]any{ + { + "id": progress.ID, + "user_id": progress.UserID, + "task_id": progress.TaskID, + "camp_id": campID, + "section_id": sectionID, + "is_completed": progress.IsCompleted, + "completed_at": normalizeCompletedAt(progress.CompletedAt), + "review_status": convertReviewStatus(progress.ReviewStatus), + "review_comment": progress.ReviewComment, + "review_images": string(reviewImagesJSON), + "answer_images": string(answerImagesJSON), + "essay_review_statuses": essayReviewStatusesJSON, + "objective_best_correct_count": progress.ObjectiveBestCorrectCount, + "objective_best_total_count": progress.ObjectiveBestTotalCount, + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建用户进度失败: %v", err) + } + return nil +} + +// Update 更新用户进度(使用 UPSERT 逻辑) +func (d *ProgressDAO) Update(progress *camp.UserProgress) error { + table := "camp_user_progress" + + // 先检查是否存在 + checkWhere := map[string]any{ + "user_id": progress.UserID, + "task_id": progress.TaskID, + } + checkCond, checkVals, err := builder.BuildSelect(table, checkWhere, []string{"id"}) + if err != nil { + return fmt.Errorf("构建查询语句失败: %v", err) + } + + var existingID string + checkErr := d.client.DB.QueryRow(checkCond, checkVals...).Scan(&existingID) + + // 通过 task_id 查询 camp_id、section_id + campID, sectionID, needReview, err := d.getTaskInfo(progress.TaskID) + if err != nil { + return fmt.Errorf("获取任务信息失败: %v", err) + } + progress.CampID = campID + progress.NeedReview = needReview + + // 序列化审核图片数组 + reviewImagesJSON, err := json.Marshal(progress.ReviewImages) + if err != nil { + return fmt.Errorf("序列化审核图片失败: %v", err) + } + answerImagesJSON, err := marshalAnswerImages(progress) + if err != nil { + return fmt.Errorf("序列化答案图片失败: %v", err) + } + essayReviewStatusesJSON, _ := marshalEssayReviewStatuses(progress.EssayReviewStatuses) + + if checkErr == sql.ErrNoRows { + // 不存在,执行插入 + return d.Create(progress) + } else if checkErr != nil { + // 查询出错 + return fmt.Errorf("检查用户进度是否存在失败: %v", checkErr) + } else { + // 已存在,执行更新 + where := map[string]any{ + "user_id": progress.UserID, + "task_id": progress.TaskID, + } + data := map[string]any{ + "is_completed": progress.IsCompleted, + "completed_at": normalizeCompletedAt(progress.CompletedAt), + "review_status": convertReviewStatus(progress.ReviewStatus), + "review_comment": progress.ReviewComment, + "review_images": string(reviewImagesJSON), + "answer_images": string(answerImagesJSON), + "essay_review_statuses": essayReviewStatusesJSON, + "camp_id": campID, + "section_id": sectionID, + "objective_best_correct_count": progress.ObjectiveBestCorrectCount, + "objective_best_total_count": progress.ObjectiveBestTotalCount, + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新用户进度失败: %v", err) + } + } + + return nil +} + +// GetByUserAndTask 根据用户ID和任务ID获取进度 +func (d *ProgressDAO) GetByUserAndTask(userID, taskID string) (*camp.UserProgress, error) { + table := "camp_user_progress" + where := map[string]any{ + "user_id": userID, + "task_id": taskID, + } + selectFields := []string{"id", "user_id", "task_id", "camp_id", "is_completed", "completed_at", "review_status", "review_comment", "review_images", "answer_images", "essay_review_statuses", "objective_best_correct_count", "objective_best_total_count"} + + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, fmt.Errorf("构建查询失败: %v", err) + } + + var ( + id string + userIDResult string + taskIDResult string + campIDResult string + isCompleted bool + completedAtTs sql.NullTime + reviewStatusStr string + reviewComment sql.NullString + reviewImagesJSON sql.NullString + answerImagesJSON sql.NullString + essayReviewStatusesJSON sql.NullString + objectiveBestCorrectCount sql.NullInt64 + objectiveBestTotalCount sql.NullInt64 + ) + + err = d.client.DB.QueryRow(cond, vals...).Scan( + &id, + &userIDResult, + &taskIDResult, + &campIDResult, + &isCompleted, + &completedAtTs, + &reviewStatusStr, + &reviewComment, + &reviewImagesJSON, + &answerImagesJSON, + &essayReviewStatusesJSON, + &objectiveBestCorrectCount, + &objectiveBestTotalCount, + ) + + if err == sql.ErrNoRows { + // 没有进度记录是正常情况,返回 nil, nil 而不是错误 + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询用户进度失败: %v", err) + } + + progress := &camp.UserProgress{ + ID: id, + UserID: userIDResult, + TaskID: taskIDResult, + CampID: campIDResult, + IsCompleted: isCompleted, + CompletedAt: utils.FormatNullTimeToStd(completedAtTs), + ReviewStatus: parseReviewStatus(reviewStatusStr), + ReviewComment: reviewComment.String, + ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64), + ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64), + } + + // 反序列化审核图片数组(过滤空字符串) + if reviewImagesJSON.Valid && reviewImagesJSON.String != "" { + var reviewImages []string + if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil { + filtered := make([]string, 0, len(reviewImages)) + for _, img := range reviewImages { + if strings.TrimSpace(img) != "" { + filtered = append(filtered, img) + } + } + progress.ReviewImages = filtered + } + } + unmarshalAnswerImages(answerImagesJSON.String, progress) + if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" { + progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String) + } + + if _, _, needReview, err := d.getTaskInfo(taskIDResult); err == nil { + progress.NeedReview = needReview + } + + return progress, nil +} + +// List 列出用户进度(支持按用户ID、用户关键词、任务ID筛选) +// userKeyword 同时支持:用户ID 模糊匹配(user_id LIKE)、手机号匹配(从 users 表解析 phone/mobile 再筛进度) +func (d *ProgressDAO) List(userID, userKeyword, taskID, sectionID, campID, reviewStatus string, page, pageSize int) ([]*camp.UserProgress, int, error) { + table := "camp_user_progress" + + // 构建查询条件 + where := map[string]any{} + + if userKeyword != "" { + // 用户关键词:尝试从 users 表按手机号解析出 user_id 列表(表不存在则忽略) + phoneUserIDs, _ := d.findUserIDsByPhoneKeyword(userKeyword) + if len(phoneUserIDs) > 0 { + return d.listWithUserKeywordOrPhone(table, userKeyword, phoneUserIDs, taskID, sectionID, campID, reviewStatus, page, pageSize) + } + where["user_id like"] = "%" + userKeyword + "%" + } else if userID != "" { + where["user_id"] = userID + } + + if taskID != "" { + where["task_id"] = taskID + } + + if sectionID != "" { + where["section_id"] = sectionID + } + + if campID != "" { + where["camp_id"] = campID + } + + if reviewStatus != "" { + where["review_status"] = reviewStatus + } + + // 查询总数 + countFields := []string{"COUNT(*) as total"} + countCond, countVals, err := builder.BuildSelect(table, where, countFields) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询用户进度总数失败: %v", err) + } + + // 查询数据(含 section_id 供管理端展示所属小节) + selectFields := []string{"id", "user_id", "task_id", "camp_id", "section_id", "is_completed", "completed_at", "review_status", "review_comment", "review_images", "answer_images", "essay_review_statuses", "objective_best_correct_count", "objective_best_total_count"} + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + + // 按时间顺序排序(完成时间或创建时间倒序) + offset := (page - 1) * pageSize + cond += " ORDER BY COALESCE(completed_at, created_at) DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询用户进度列表失败: %v", err) + } + defer rows.Close() + + progressList := make([]*camp.UserProgress, 0) + taskNeedReviewCache := make(map[string]bool) + taskIDs := make(map[string]struct{}) + for rows.Next() { + var ( + id string + userIDResult string + taskIDResult string + campIDResult string + sectionIDResult string + isCompleted bool + completedAtTs sql.NullTime + reviewStatusStr string + reviewComment sql.NullString + reviewImagesJSON sql.NullString + answerImagesJSON sql.NullString + essayReviewStatusesJSON sql.NullString + objectiveBestCorrectCount sql.NullInt64 + objectiveBestTotalCount sql.NullInt64 + ) + + err := rows.Scan( + &id, + &userIDResult, + &taskIDResult, + &campIDResult, + §ionIDResult, + &isCompleted, + &completedAtTs, + &reviewStatusStr, + &reviewComment, + &reviewImagesJSON, + &answerImagesJSON, + &essayReviewStatusesJSON, + &objectiveBestCorrectCount, + &objectiveBestTotalCount, + ) + if err != nil { + continue + } + + progress := &camp.UserProgress{ + ID: id, + UserID: userIDResult, + TaskID: taskIDResult, + CampID: campIDResult, + SectionID: sectionIDResult, + IsCompleted: isCompleted, + CompletedAt: utils.FormatNullTimeToStd(completedAtTs), + ReviewStatus: parseReviewStatus(reviewStatusStr), + ReviewComment: reviewComment.String, + ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64), + ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64), + } + + taskIDs[taskIDResult] = struct{}{} + + // 反序列化审核图片数组(过滤空字符串) + if reviewImagesJSON.Valid && reviewImagesJSON.String != "" { + var reviewImages []string + if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil { + filtered := make([]string, 0, len(reviewImages)) + for _, img := range reviewImages { + if strings.TrimSpace(img) != "" { + filtered = append(filtered, img) + } + } + progress.ReviewImages = filtered + } + } + unmarshalAnswerImages(answerImagesJSON.String, progress) + if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" { + progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String) + } + + progressList = append(progressList, progress) + } + + if err = rows.Err(); err != nil { + return nil, 0, fmt.Errorf("遍历用户进度数据失败: %v", err) + } + + // 批量查询任务是否需要审核 + for taskID := range taskIDs { + _, _, needReview, err := d.getTaskInfo(taskID) + if err == nil { + taskNeedReviewCache[taskID] = needReview + } + } + for _, progress := range progressList { + if val, ok := taskNeedReviewCache[progress.TaskID]; ok { + progress.NeedReview = val + } + } + return progressList, total, nil +} + +// ListByUserIDsAndCamp 按用户 ID 列表与打卡营查询进度(不分页,用于管理端矩阵:一页用户的所有进度) +func (d *ProgressDAO) ListByUserIDsAndCamp(userIDs []string, campID, sectionID, taskID, reviewStatus string) ([]*camp.UserProgress, error) { + if len(userIDs) == 0 { + return nil, nil + } + + table := "camp_user_progress" + placeholders := strings.Repeat("?,", len(userIDs)) + placeholders = placeholders[:len(placeholders)-1] + + query := `SELECT id, user_id, task_id, camp_id, section_id, is_completed, completed_at, review_status, review_comment, review_images, answer_images, essay_review_statuses, objective_best_correct_count, objective_best_total_count FROM ` + table + ` WHERE user_id IN (` + placeholders + `) AND camp_id = ?` + args := make([]any, 0, len(userIDs)+4) + for _, u := range userIDs { + args = append(args, u) + } + args = append(args, campID) + if sectionID != "" { + query += ` AND section_id = ?` + args = append(args, sectionID) + } + if taskID != "" { + query += ` AND task_id = ?` + args = append(args, taskID) + } + if reviewStatus != "" { + query += ` AND review_status = ?` + args = append(args, reviewStatus) + } + query += ` ORDER BY section_id, task_id` + + rows, err := d.client.DB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("查询进度失败: %v", err) + } + defer rows.Close() + + progressList, taskNeedReviewCache, _, err := d.scanProgressRows(rows) + if err != nil { + return nil, err + } + for _, progress := range progressList { + if val, ok := taskNeedReviewCache[progress.TaskID]; ok { + progress.NeedReview = val + } + } + return progressList, nil +} + +// findUserIDsByPhoneKeyword 根据手机号关键词从 users 表查询 user_id 列表(表需含 id、phone 或 mobile 列) +// 若 users 表不存在或查询失败,返回 nil, nil,调用方仅用 user_id LIKE 即可 +func (d *ProgressDAO) findUserIDsByPhoneKeyword(keyword string) ([]string, error) { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return nil, nil + } + // 兼容表名为 users,列名为 phone 或 mobile(常见 C 端用户表) + query := `SELECT id FROM users WHERE (phone LIKE ? OR mobile LIKE ?) LIMIT 500` + rows, err := d.client.DB.Query(query, "%"+keyword+"%", "%"+keyword+"%") + if err != nil { + return nil, nil // 表不存在或列名不同时不报错,让上层只用 user_id LIKE + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + if id != "" { + ids = append(ids, id) + } + } + return ids, nil +} + +// listWithUserKeywordOrPhone 使用 (user_id LIKE ? OR user_id IN (...)) 条件查询进度列表与总数 +func (d *ProgressDAO) listWithUserKeywordOrPhone(table, userKeyword string, phoneUserIDs []string, taskID, sectionID, campID, reviewStatus string, page, pageSize int) ([]*camp.UserProgress, int, error) { + inPlaceholders := strings.Repeat("?,", len(phoneUserIDs)) + if len(inPlaceholders) > 0 { + inPlaceholders = inPlaceholders[:len(inPlaceholders)-1] + } + userCond := "(user_id LIKE ? OR user_id IN (" + inPlaceholders + "))" + var conditions []string + var vals []interface{} + vals = append(vals, "%"+userKeyword+"%") + for _, id := range phoneUserIDs { + vals = append(vals, id) + } + conditions = append(conditions, userCond) + + if taskID != "" { + conditions = append(conditions, "task_id = ?") + vals = append(vals, taskID) + } + if sectionID != "" { + conditions = append(conditions, "section_id = ?") + vals = append(vals, sectionID) + } + if campID != "" { + conditions = append(conditions, "camp_id = ?") + vals = append(vals, campID) + } + if reviewStatus != "" { + conditions = append(conditions, "review_status = ?") + vals = append(vals, reviewStatus) + } + whereSQL := strings.Join(conditions, " AND ") + + countQuery := "SELECT COUNT(*) as total FROM " + table + " WHERE " + whereSQL + var total int + if err := d.client.DB.QueryRow(countQuery, vals...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("查询用户进度总数失败: %v", err) + } + + selectFields := "id, user_id, task_id, camp_id, section_id, is_completed, completed_at, review_status, review_comment, review_images, answer_images, essay_review_statuses, objective_best_correct_count, objective_best_total_count" + offset := (page - 1) * pageSize + listQuery := "SELECT " + selectFields + " FROM " + table + " WHERE " + whereSQL + " ORDER BY COALESCE(completed_at, created_at) DESC LIMIT ? OFFSET ?" + listVals := append(vals, pageSize, offset) + rows, err := d.client.DB.Query(listQuery, listVals...) + if err != nil { + return nil, 0, fmt.Errorf("查询用户进度列表失败: %v", err) + } + defer rows.Close() + + progressList, taskNeedReviewCache, taskIDs, err := d.scanProgressRows(rows) + if err != nil { + return nil, 0, err + } + for taskID := range taskIDs { + _, _, needReview, err := d.getTaskInfo(taskID) + if err != nil { + continue + } + taskNeedReviewCache[taskID] = needReview + } + for _, progress := range progressList { + if val, ok := taskNeedReviewCache[progress.TaskID]; ok { + progress.NeedReview = val + } + } + return progressList, total, nil +} + +// scanProgressRows 将 progress 查询结果扫描为列表,并返回 taskNeedReviewCache、taskIDs +func (d *ProgressDAO) scanProgressRows(rows *sql.Rows) ([]*camp.UserProgress, map[string]bool, map[string]struct{}, error) { + progressList := make([]*camp.UserProgress, 0) + taskNeedReviewCache := make(map[string]bool) + taskIDs := make(map[string]struct{}) + for rows.Next() { + var ( + id string + userIDResult string + taskIDResult string + campIDResult string + sectionIDResult string + isCompleted bool + completedAtTs sql.NullTime + reviewStatusStr string + reviewComment sql.NullString + reviewImagesJSON sql.NullString + answerImagesJSON sql.NullString + essayReviewStatusesJSON sql.NullString + objectiveBestCorrectCount sql.NullInt64 + objectiveBestTotalCount sql.NullInt64 + ) + err := rows.Scan( + &id, + &userIDResult, + &taskIDResult, + &campIDResult, + §ionIDResult, + &isCompleted, + &completedAtTs, + &reviewStatusStr, + &reviewComment, + &reviewImagesJSON, + &answerImagesJSON, + &essayReviewStatusesJSON, + &objectiveBestCorrectCount, + &objectiveBestTotalCount, + ) + if err != nil { + continue + } + progress := &camp.UserProgress{ + ID: id, + UserID: userIDResult, + TaskID: taskIDResult, + CampID: campIDResult, + SectionID: sectionIDResult, + IsCompleted: isCompleted, + CompletedAt: utils.FormatNullTimeToStd(completedAtTs), + ReviewStatus: parseReviewStatus(reviewStatusStr), + ReviewComment: reviewComment.String, + ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64), + ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64), + } + taskIDs[taskIDResult] = struct{}{} + if reviewImagesJSON.Valid && reviewImagesJSON.String != "" { + var reviewImages []string + if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil { + filtered := make([]string, 0, len(reviewImages)) + for _, img := range reviewImages { + if strings.TrimSpace(img) != "" { + filtered = append(filtered, img) + } + } + progress.ReviewImages = filtered + } + } + unmarshalAnswerImages(answerImagesJSON.String, progress) + if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" { + progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String) + } + progressList = append(progressList, progress) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, fmt.Errorf("遍历用户进度数据失败: %v", err) + } + return progressList, taskNeedReviewCache, taskIDs, nil +} + +// DeleteByUserAndCamp 删除用户在某打卡营下的所有进度 +func (d *ProgressDAO) DeleteByUserAndCamp(userID, campID string) (int64, error) { + query := `DELETE FROM camp_user_progress WHERE user_id = ? AND camp_id = ?` + res, err := d.client.DB.Exec(query, userID, campID) + if err != nil { + return 0, fmt.Errorf("删除用户营内进度失败: %v", err) + } + rows, _ := res.RowsAffected() + return rows, nil +} + +// DeleteByUserAndTask 删除用户在某任务下的进度记录 +func (d *ProgressDAO) DeleteByUserAndTask(userID, taskID string) error { + query := `DELETE FROM camp_user_progress WHERE user_id = ? AND task_id = ?` + _, err := d.client.DB.Exec(query, userID, taskID) + if err != nil { + return fmt.Errorf("删除用户任务进度失败: %v", err) + } + return nil +} + +// HasProgressForTask 判断任务是否已有用户进度 +func (d *ProgressDAO) HasProgressForTask(taskID string) (bool, error) { + query := "SELECT 1 FROM camp_user_progress WHERE task_id = ? LIMIT 1" + var dummy int + err := d.client.DB.QueryRow(query, taskID).Scan(&dummy) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("查询任务进度失败: %v", err) + } + return true, nil +} + +// HasProgressForSection 判断小节是否已有用户进度 +func (d *ProgressDAO) HasProgressForSection(sectionID string) (bool, error) { + query := "SELECT 1 FROM camp_user_progress WHERE section_id = ? LIMIT 1" + var dummy int + err := d.client.DB.QueryRow(query, sectionID).Scan(&dummy) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("查询小节进度失败: %v", err) + } + return true, nil +} + +// HasProgressForCamp 判断打卡营是否已有用户进度 +func (d *ProgressDAO) HasProgressForCamp(campID string) (bool, error) { + query := `SELECT 1 FROM camp_user_progress WHERE camp_id = ? LIMIT 1` + var dummy int + err := d.client.DB.QueryRow(query, campID).Scan(&dummy) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("查询打卡营进度失败: %v", err) + } + return true, nil +} + +// CountCompletedBySection 统计用户在小节下已完成的任务数 +func (d *ProgressDAO) CountCompletedBySection(userID, sectionID string) (int, error) { + query := `SELECT COUNT(*) FROM camp_user_progress + WHERE user_id = ? AND section_id = ? AND is_completed = 1` + var count int + err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("统计用户小节完成任务数失败: %v", err) + } + return count, nil +} + +// GetUserSectionCompletedAt 获取用户在某小节「全部任务完成」的时间(该小节下已完成任务的 completed_at 最大值) +// 用于时间间隔解锁的起点:间隔从「上一小节完成时刻」开始计算 +func (d *ProgressDAO) GetUserSectionCompletedAt(userID, sectionID string) (*time.Time, error) { + query := `SELECT MAX(completed_at) FROM camp_user_progress + WHERE user_id = ? AND section_id = ? AND is_completed = 1 AND completed_at IS NOT NULL` + var completedAt sql.NullTime + err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&completedAt) + if err != nil { + return nil, fmt.Errorf("获取小节完成时间失败: %v", err) + } + if !completedAt.Valid { + return nil, nil + } + t := completedAt.Time + return &t, nil +} + +// GetUserSectionStartedAt 获取用户在某小节「首次产生进度」的时间(该小节下任意进度记录的 created_at 最小值) +// 用于时间间隔解锁的起点:间隔从「上一小节开启时」开始计算(用户第一次进入/开始该小节的时间) +func (d *ProgressDAO) GetUserSectionStartedAt(userID, sectionID string) (*time.Time, error) { + query := `SELECT MIN(created_at) FROM camp_user_progress + WHERE user_id = ? AND section_id = ?` + var createdAt sql.NullTime + err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&createdAt) + if err != nil { + return nil, fmt.Errorf("获取小节开启时间失败: %v", err) + } + if !createdAt.Valid { + return nil, nil + } + t := createdAt.Time + return &t, nil +} + +// CountTasksAndCompletedByCamp 统计打卡营下的总任务数和用户已完成的任务数 +// 用于轻量计算打卡营整体完成状态,避免加载全部小节/任务数据 +func (d *ProgressDAO) CountTasksAndCompletedByCamp(userID, campID string) (totalTasks int, completedTasks int, err error) { + // 1. 查询打卡营下的总任务数(关联 camp_sections 确保只统计未删除的小节下的任务) + totalQuery := `SELECT COUNT(*) FROM camp_tasks t + INNER JOIN camp_sections s ON t.section_id = s.id AND (s.deleted_at IS NULL OR s.deleted_at = '0001-01-01 00:00:00') + WHERE t.camp_id = ? AND (t.deleted_at IS NULL OR t.deleted_at = '0001-01-01 00:00:00')` + err = d.client.DB.QueryRow(totalQuery, campID).Scan(&totalTasks) + if err != nil { + return 0, 0, fmt.Errorf("统计打卡营总任务数失败: %v", err) + } + + // 2. 查询用户在该打卡营下已完成的任务数 + completedQuery := `SELECT COUNT(*) FROM camp_user_progress + WHERE user_id = ? AND camp_id = ? AND is_completed = 1` + err = d.client.DB.QueryRow(completedQuery, userID, campID).Scan(&completedTasks) + if err != nil { + return totalTasks, 0, fmt.Errorf("统计用户已完成任务数失败: %v", err) + } + + return totalTasks, completedTasks, nil +} + +// ========== 辅助函数 ========== + +// convertReviewStatus 将 ReviewStatus 转换为数据库 ENUM 字符串 +func convertReviewStatus(status camp.ReviewStatus) string { + switch status { + case camp.ReviewStatusApproved: + return "APPROVED" + case camp.ReviewStatusRejected: + return "REJECTED" + default: + return "PENDING" + } +} + +// parseReviewStatus 将数据库 ENUM 字符串转换为 ReviewStatus +func parseReviewStatus(statusStr string) camp.ReviewStatus { + switch statusStr { + case "APPROVED": + return camp.ReviewStatusApproved + case "REJECTED": + return camp.ReviewStatusRejected + default: + return camp.ReviewStatusPending + } +} + +// getTaskInfo 通过任务ID获取打卡营ID、小节ID与审核标记 +func (d *ProgressDAO) getTaskInfo(taskID string) (string, string, bool, error) { + var ( + campID string + sectionID string + taskTypeStr string + conditionJSON sql.NullString + ) + + query := "SELECT camp_id, section_id, task_type, `condition` FROM camp_tasks WHERE id = ? AND deleted_at IS NULL" + err := d.client.DB.QueryRow(query, taskID).Scan(&campID, §ionID, &taskTypeStr, &conditionJSON) + if err == sql.ErrNoRows { + return "", "", false, fmt.Errorf("任务不存在: %s", taskID) + } + if err != nil { + return "", "", false, err + } + + needReview := false + if conditionJSON.Valid && conditionJSON.String != "" { + var raw map[string]any + if err := json.Unmarshal([]byte(conditionJSON.String), &raw); err == nil { + if val, ok := raw["need_review"]; ok { + if boolVal, ok := val.(bool); ok { + needReview = boolVal + } + } else if subjectiveRaw, ok := raw["subjective"].(map[string]any); ok { + if val, ok := subjectiveRaw["need_review"]; ok { + if boolVal, ok := val.(bool); ok { + needReview = boolVal + } + } + } else if essayRaw, ok := raw["essay"].(map[string]any); ok { + if val, ok := essayRaw["need_review"]; ok { + if boolVal, ok := val.(bool); ok { + needReview = boolVal + } + } + } + } + } + + return campID, sectionID, needReview, nil +} + +// normalizeCompletedAt 将 completed_at 字符串统一转换为数据库可接受的时间值 +func normalizeCompletedAt(s string) any { + if s == "" { + return nil + } + // 尝试 Unix 秒 + if sec, err := strconv.ParseInt(s, 10, 64); err == nil { + return time.Unix(sec, 0).In(time.Local).Format("2006-01-02 15:04:05") + } + // 尝试 RFC3339 / RFC3339Nano + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.In(time.Local).Format("2006-01-02 15:04:05") + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t.In(time.Local).Format("2006-01-02 15:04:05") + } + // 尝试已是标准格式 + if _, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { + return s + } + // 无法解析,回退为 NULL + return nil +} + diff --git a/internal/camp/dao/reset_history_dao.go b/internal/camp/dao/reset_history_dao.go new file mode 100644 index 0000000..5c59b8b --- /dev/null +++ b/internal/camp/dao/reset_history_dao.go @@ -0,0 +1,39 @@ +package dao + +import ( + "fmt" + + "dd_fiber_api/pkg/database" + + "github.com/didi/gendry/builder" +) + +// ResetHistoryDAO 打卡营重置历史 DAO +type ResetHistoryDAO struct { + client *database.MySQLClient +} + +// NewResetHistoryDAO 创建重置历史 DAO +func NewResetHistoryDAO(client *database.MySQLClient) *ResetHistoryDAO { + return &ResetHistoryDAO{client: client} +} + +// Create 写入一条重置历史 +func (d *ResetHistoryDAO) Create(id, userID, campID string, clearedProgressCount int, note string) error { + table := "camp_reset_history" + data := []map[string]any{{ + "id": id, + "user_id": userID, + "camp_id": campID, + "cleared_progress_count": clearedProgressCount, + "note": nullString(note), + }} + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + if _, err := d.client.DB.Exec(cond, vals...); err != nil { + return fmt.Errorf("写入重置历史失败: %v", err) + } + return nil +} diff --git a/internal/camp/dao/section_dao.go b/internal/camp/dao/section_dao.go new file mode 100644 index 0000000..ed7861e --- /dev/null +++ b/internal/camp/dao/section_dao.go @@ -0,0 +1,353 @@ +package dao + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/utils" + + "github.com/didi/gendry/builder" +) + +// SectionDAO 小节数据访问对象 +type SectionDAO struct { + client *database.MySQLClient +} + +// NewSectionDAO 创建小节DAO实例 +func NewSectionDAO(client *database.MySQLClient) *SectionDAO { + return &SectionDAO{ + client: client, + } +} + +// Create 创建小节 +func (d *SectionDAO) Create(section *camp.Section) error { + table := "camp_sections" + data := []map[string]any{ + { + "id": section.ID, + "camp_id": section.CampID, + "title": section.Title, + "section_number": section.SectionNumber, + "price_fen": section.PriceFen, + "require_previous_section": section.RequirePreviousSection, + "time_interval_type": convertTimeIntervalType(section.TimeIntervalType), + "time_interval_value": section.TimeIntervalValue, + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建小节失败: %v", err) + } + return nil +} + +// GetByID 根据ID获取小节 +func (d *SectionDAO) GetByID(id string) (*camp.Section, error) { + table := "camp_sections" + where := map[string]any{ + "id": id, + } + selectFields := []string{"id", "camp_id", "title", "section_number", "price_fen", "require_previous_section", "time_interval_type", "time_interval_value", "deleted_at"} + + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, fmt.Errorf("构建查询失败: %v", err) + } + if strings.Contains(cond, "WHERE") { + cond += " AND deleted_at IS NULL" + } else { + cond += " WHERE deleted_at IS NULL" + } + + var section camp.Section + var timeIntervalTypeStr string + var deletedAt sql.NullTime + + err = d.client.DB.QueryRow(cond, vals...).Scan( + §ion.ID, + §ion.CampID, + §ion.Title, + §ion.SectionNumber, + §ion.PriceFen, + §ion.RequirePreviousSection, + &timeIntervalTypeStr, + §ion.TimeIntervalValue, + &deletedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("小节不存在: %s", id) + } + if err != nil { + return nil, fmt.Errorf("查询小节失败: %v", err) + } + + section.TimeIntervalType = parseTimeIntervalType(timeIntervalTypeStr) + section.DeletedAt = utils.FormatNullTimeToStd(deletedAt) + + return §ion, nil +} + +// Update 更新小节 +func (d *SectionDAO) Update(section *camp.Section) error { + table := "camp_sections" + where := map[string]any{ + "id": section.ID, + } + data := map[string]any{ + "camp_id": section.CampID, + "title": section.Title, + "section_number": section.SectionNumber, + "price_fen": section.PriceFen, + "require_previous_section": section.RequirePreviousSection, + "time_interval_type": convertTimeIntervalType(section.TimeIntervalType), + "time_interval_value": section.TimeIntervalValue, + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + cond += " AND deleted_at IS NULL" + + result, err := d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新小节失败: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("获取影响行数失败: %v", err) + } + if rows == 0 { + return fmt.Errorf("小节不存在: %s", section.ID) + } + + return nil +} + +// Delete 删除小节(软删除) +func (d *SectionDAO) Delete(id string) error { + table := "camp_sections" + where := map[string]any{ + "id": id, + } + + data := map[string]any{ + "deleted_at": time.Now(), + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建删除语句失败: %v", err) + } + cond += " AND deleted_at IS NULL" + + result, err := d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("删除小节失败: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("获取影响行数失败: %v", err) + } + if rows == 0 { + return fmt.Errorf("小节不存在: %s", id) + } + + return nil +} + +// List 列出小节(支持关键词搜索、按打卡营ID筛选) +func (d *SectionDAO) List(keyword, campID string, page, pageSize int) ([]*camp.Section, int, error) { + table := "camp_sections" + + // 构建查询条件 + where := map[string]any{} + + if campID != "" { + where["camp_id"] = campID + } + + if keyword != "" { + where["_or"] = []map[string]any{ + {"title like": "%" + keyword + "%"}, + {"id like": "%" + keyword + "%"}, + } + } + + // 查询总数 + countCond, countVals, err := builder.BuildSelect(table, where, []string{"count(*) as total"}) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + if strings.Contains(countCond, "WHERE") { + countCond += " AND deleted_at IS NULL" + } else { + countCond += " WHERE deleted_at IS NULL" + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询小节总数失败: %v", err) + } + + // 查询数据 + selectFields := []string{"id", "camp_id", "title", "section_number", "price_fen", "require_previous_section", "time_interval_type", "time_interval_value", "deleted_at"} + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + if strings.Contains(cond, "WHERE") { + cond += " AND deleted_at IS NULL" + } else { + cond += " WHERE deleted_at IS NULL" + } + + // 添加排序和分页 + offset := (page - 1) * pageSize + cond += " ORDER BY section_number ASC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询小节列表失败: %v", err) + } + defer rows.Close() + + sections := make([]*camp.Section, 0) + for rows.Next() { + var section camp.Section + var timeIntervalTypeStr string + var deletedAt sql.NullTime + + err := rows.Scan( + §ion.ID, + §ion.CampID, + §ion.Title, + §ion.SectionNumber, + §ion.PriceFen, + §ion.RequirePreviousSection, + &timeIntervalTypeStr, + §ion.TimeIntervalValue, + &deletedAt, + ) + if err != nil { + continue + } + + section.TimeIntervalType = parseTimeIntervalType(timeIntervalTypeStr) + section.DeletedAt = utils.FormatNullTimeToStd(deletedAt) + sections = append(sections, §ion) + } + + if err = rows.Err(); err != nil { + return nil, 0, fmt.Errorf("遍历小节数据失败: %v", err) + } + + return sections, total, nil +} + +// CountActiveByCamp 统计打卡营下未删除的小节数量(与 List 条件一致,兼容 deleted_at 为 NULL 或 0001-01-01) +func (d *SectionDAO) CountActiveByCamp(campID string) (int, error) { + query := "SELECT COUNT(*) FROM camp_sections WHERE camp_id = ? AND (deleted_at IS NULL OR deleted_at = '0001-01-01 00:00:00')" + var count int + err := d.client.DB.QueryRow(query, campID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("统计小节数量失败: %v", err) + } + return count, nil +} + +// CountByCampIDs 批量统计多个打卡营下未删除的小节数量,返回 map[campID]count(用于列表展示时校正 section_count) +func (d *SectionDAO) CountByCampIDs(campIDs []string) (map[string]int, error) { + if len(campIDs) == 0 { + return map[string]int{}, nil + } + placeholders := strings.Repeat("?,", len(campIDs)) + placeholders = placeholders[:len(placeholders)-1] + query := "SELECT camp_id, COUNT(*) FROM camp_sections WHERE (deleted_at IS NULL OR deleted_at = '0001-01-01 00:00:00') AND camp_id IN (" + placeholders + ") GROUP BY camp_id" + args := make([]any, len(campIDs)) + for i, id := range campIDs { + args[i] = id + } + rows, err := d.client.DB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("批量统计小节数量失败: %v", err) + } + defer rows.Close() + result := make(map[string]int) + for _, id := range campIDs { + result[id] = 0 + } + for rows.Next() { + var campID string + var count int + if err := rows.Scan(&campID, &count); err != nil { + continue + } + result[campID] = count + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("遍历小节统计结果失败: %v", err) + } + return result, nil +} + +// GetFirstSectionID 获取打卡营中 section_number 最小的小节ID +func (d *SectionDAO) GetFirstSectionID(campID string) (string, error) { + query := "SELECT id FROM camp_sections WHERE camp_id = ? AND deleted_at IS NULL ORDER BY section_number ASC LIMIT 1" + var sectionID string + err := d.client.DB.QueryRow(query, campID).Scan(§ionID) + if err != nil { + if err == sql.ErrNoRows { + return "", nil // 没有小节,返回空字符串 + } + return "", fmt.Errorf("获取第一个小节ID失败: %v", err) + } + return sectionID, nil +} + +// convertTimeIntervalType 将 TimeIntervalType 转换为数据库字符串 +func convertTimeIntervalType(timeIntervalType camp.TimeIntervalType) string { + switch timeIntervalType { + case camp.TimeIntervalTypeHour: + return "HOUR_INTERVAL" + case camp.TimeIntervalTypeNaturalDay: + return "NATURAL_DAY" + case camp.TimeIntervalTypePaid: + return "PAID" + default: + return "NONE" + } +} + +// parseTimeIntervalType 将数据库字符串转换为 TimeIntervalType(大小写不敏感) +func parseTimeIntervalType(timeIntervalTypeStr string) camp.TimeIntervalType { + s := strings.TrimSpace(strings.ToUpper(timeIntervalTypeStr)) + switch s { + case "HOUR_INTERVAL", "HOUR": + return camp.TimeIntervalTypeHour + case "NATURAL_DAY": + return camp.TimeIntervalTypeNaturalDay + case "PAID": + return camp.TimeIntervalTypePaid + default: + return camp.TimeIntervalTypeNone + } +} + diff --git a/internal/camp/dao/task_dao.go b/internal/camp/dao/task_dao.go new file mode 100644 index 0000000..dcf5b82 --- /dev/null +++ b/internal/camp/dao/task_dao.go @@ -0,0 +1,441 @@ +package dao + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/utils" + + "github.com/didi/gendry/builder" +) + +// TaskDAO 任务数据访问对象 +type TaskDAO struct { + client *database.MySQLClient +} + +// NewTaskDAO 创建任务DAO实例 +func NewTaskDAO(client *database.MySQLClient) *TaskDAO { + return &TaskDAO{ + client: client, + } +} + +// Create 创建任务 +func (d *TaskDAO) Create(task *camp.Task) error { + table := "camp_tasks" + + // Content 和 Condition 已经是 JSON 格式,直接使用 + contentJSON := string(task.Content) + if contentJSON == "" { + contentJSON = "{}" + } + conditionJSON := string(task.Condition) + if conditionJSON == "" { + conditionJSON = "{}" + } + + // 使用反引号包裹 condition 字段名(避免 MySQL 保留字冲突) + data := []map[string]any{ + { + "id": task.ID, + "camp_id": task.CampID, + "section_id": task.SectionID, + "task_type": convertTaskType(task.TaskType), + "title": task.Title, + "content": contentJSON, + "`condition`": conditionJSON, // 反引号包裹保留字 + "prerequisite_task_id": nullString(task.PrerequisiteTaskID), + }, + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("创建任务失败: %v", err) + } + return nil +} + +// GetByID 根据ID获取任务 +func (d *TaskDAO) GetByID(id string) (*camp.Task, error) { + table := "camp_tasks" + where := map[string]any{ + "id": id, + } + selectFields := []string{"id", "camp_id", "section_id", "task_type", "title", "content", "`condition`", "prerequisite_task_id", "deleted_at"} + + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, fmt.Errorf("构建查询失败: %v", err) + } + if strings.Contains(cond, "WHERE") { + cond += " AND deleted_at IS NULL" + } else { + cond += " WHERE deleted_at IS NULL" + } + + var ( + taskID string + campID string + sectionID string + taskTypeStr string + title sql.NullString + contentJSON sql.NullString + conditionJSON sql.NullString + prerequisiteTaskID sql.NullString + deletedAt sql.NullTime + ) + + err = d.client.DB.QueryRow(cond, vals...).Scan( + &taskID, + &campID, + §ionID, + &taskTypeStr, + &title, + &contentJSON, + &conditionJSON, + &prerequisiteTaskID, + &deletedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("任务不存在: %s", id) + } + if err != nil { + return nil, fmt.Errorf("查询任务失败: %v", err) + } + + // 解析任务类型 + taskType := parseTaskType(taskTypeStr) + + // 构建 Task 对象 + task := &camp.Task{ + ID: taskID, + CampID: campID, + SectionID: sectionID, + TaskType: taskType, + Title: title.String, + PrerequisiteTaskID: prerequisiteTaskID.String, + DeletedAt: utils.FormatNullTimeToStd(deletedAt), + } + + // 处理 Content + if contentJSON.Valid && contentJSON.String != "" { + // 验证 JSON 格式 + if json.Valid([]byte(contentJSON.String)) { + task.Content = json.RawMessage(contentJSON.String) + } else { + task.Content = json.RawMessage("{}") + } + } else { + task.Content = json.RawMessage("{}") + } + + // 处理 Condition + if conditionJSON.Valid && conditionJSON.String != "" { + // 验证 JSON 格式 + if json.Valid([]byte(conditionJSON.String)) { + task.Condition = json.RawMessage(conditionJSON.String) + } else { + task.Condition = json.RawMessage("{}") + } + } else { + task.Condition = json.RawMessage("{}") + } + + return task, nil +} + +// Update 更新任务 +func (d *TaskDAO) Update(task *camp.Task) error { + table := "camp_tasks" + + // 更新前检查是否存在 + existsWhere := map[string]any{ + "id": task.ID, + } + existsCond, existsVals, err := builder.BuildSelect(table, existsWhere, []string{"id"}) + if err != nil { + return fmt.Errorf("构建校验查询失败: %v", err) + } + existsCond += " AND deleted_at IS NULL" + + var dummyID string + if err := d.client.DB.QueryRow(existsCond, existsVals...).Scan(&dummyID); err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("任务不存在: %s", task.ID) + } + return fmt.Errorf("查询任务失败: %v", err) + } + + where := map[string]any{ + "id": task.ID, + } + + // Content 和 Condition 已经是 JSON 格式,直接使用 + contentJSON := string(task.Content) + if contentJSON == "" { + contentJSON = "{}" + } + conditionJSON := string(task.Condition) + if conditionJSON == "" { + conditionJSON = "{}" + } + + // 使用反引号包裹 condition 字段名(避免 MySQL 保留字冲突) + data := map[string]any{ + "camp_id": task.CampID, + "section_id": task.SectionID, + "task_type": convertTaskType(task.TaskType), + "title": task.Title, + "content": contentJSON, + "`condition`": conditionJSON, // 反引号包裹保留字 + "prerequisite_task_id": nullString(task.PrerequisiteTaskID), + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + cond += " AND deleted_at IS NULL" + + if _, err := d.client.DB.Exec(cond, vals...); err != nil { + return fmt.Errorf("更新任务失败: %v", err) + } + + return nil +} + +// Delete 删除任务(软删除) +func (d *TaskDAO) Delete(id string) error { + table := "camp_tasks" + where := map[string]any{ + "id": id, + } + + data := map[string]any{ + "deleted_at": time.Now(), + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建删除语句失败: %v", err) + } + cond += " AND deleted_at IS NULL" + + result, err := d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("删除任务失败: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("获取影响行数失败: %v", err) + } + if rows == 0 { + return fmt.Errorf("任务不存在: %s", id) + } + + return nil +} + +// List 列出任务(支持关键词搜索、按打卡营/小节ID和任务类型筛选) +func (d *TaskDAO) List(keyword, campID, sectionID string, taskType camp.TaskType, page, pageSize int) ([]*camp.Task, int, error) { + table := "camp_tasks" + baseWhere := "(deleted_at IS NULL OR deleted_at = '0001-01-01 00:00:00')" + + // 手写构建 WHERE 与参数,确保 camp_id 等筛选生效(不依赖 gendry where map) + var conditions []string + var args []any + conditions = append(conditions, baseWhere) + if campID != "" { + conditions = append(conditions, "camp_id = ?") + args = append(args, campID) + } + if sectionID != "" { + conditions = append(conditions, "section_id = ?") + args = append(args, sectionID) + } + if taskType != camp.TaskTypeUnknown { + conditions = append(conditions, "task_type = ?") + args = append(args, convertTaskType(taskType)) + } + if keyword != "" { + conditions = append(conditions, "id LIKE ?") + args = append(args, "%"+keyword+"%") + } + whereClause := strings.Join(conditions, " AND ") + + // 查询总数 + countQuery := "SELECT COUNT(*) FROM " + table + " WHERE " + whereClause + var total int + err := d.client.DB.QueryRow(countQuery, args...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询任务总数失败: %v", err) + } + + // 查询数据(分页) + offset := (page - 1) * pageSize + dataQuery := "SELECT id, camp_id, section_id, task_type, title, content, `condition`, prerequisite_task_id, deleted_at FROM " + table + " WHERE " + whereClause + " ORDER BY id ASC LIMIT ? OFFSET ?" + dataArgs := append(append([]any{}, args...), pageSize, offset) + + rows, err := d.client.DB.Query(dataQuery, dataArgs...) + if err != nil { + return nil, 0, fmt.Errorf("查询任务列表失败: %v", err) + } + defer rows.Close() + + tasks := make([]*camp.Task, 0) + for rows.Next() { + var ( + taskID string + campID string + sectionID string + taskTypeStr string + title sql.NullString + contentJSON sql.NullString + conditionJSON sql.NullString + prerequisiteTaskID sql.NullString + deletedAt sql.NullTime + ) + + err := rows.Scan( + &taskID, + &campID, + §ionID, + &taskTypeStr, + &title, + &contentJSON, + &conditionJSON, + &prerequisiteTaskID, + &deletedAt, + ) + if err != nil { + continue + } + + taskType := parseTaskType(taskTypeStr) + task := &camp.Task{ + ID: taskID, + CampID: campID, + SectionID: sectionID, + TaskType: taskType, + Title: title.String, + PrerequisiteTaskID: prerequisiteTaskID.String, + DeletedAt: utils.FormatNullTimeToStd(deletedAt), + } + + // 处理 JSON 字段 + if contentJSON.Valid && contentJSON.String != "" && json.Valid([]byte(contentJSON.String)) { + task.Content = json.RawMessage(contentJSON.String) + } else { + task.Content = json.RawMessage("{}") + } + + if conditionJSON.Valid && conditionJSON.String != "" && json.Valid([]byte(conditionJSON.String)) { + task.Condition = json.RawMessage(conditionJSON.String) + } else { + task.Condition = json.RawMessage("{}") + } + + tasks = append(tasks, task) + } + + if err = rows.Err(); err != nil { + return nil, 0, fmt.Errorf("遍历任务数据失败: %v", err) + } + + return tasks, total, nil +} + +// CountActiveBySection 统计小节下未删除的任务数量 +func (d *TaskDAO) CountActiveBySection(sectionID string) (int, error) { + query := "SELECT COUNT(*) FROM camp_tasks WHERE section_id = ? AND deleted_at IS NULL" + var count int + err := d.client.DB.QueryRow(query, sectionID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("统计任务数量失败: %v", err) + } + return count, nil +} + +// MinTaskIDBySection 返回小节内任务 ID 最小的任务 ID(用于判断“第一个任务”) +func (d *TaskDAO) MinTaskIDBySection(sectionID string) (string, error) { + query := "SELECT id FROM camp_tasks WHERE section_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT 1" + var minID string + err := d.client.DB.QueryRow(query, sectionID).Scan(&minID) + if err != nil { + if err == sql.ErrNoRows { + return "", nil // 小节内无任务 + } + return "", fmt.Errorf("查询小节最小任务ID失败: %v", err) + } + return minID, nil +} + +// CountActiveByCamp 统计打卡营下未删除的任务数量 +func (d *TaskDAO) CountActiveByCamp(campID string) (int, error) { + query := "SELECT COUNT(*) FROM camp_tasks WHERE camp_id = ? AND deleted_at IS NULL" + var count int + err := d.client.DB.QueryRow(query, campID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("统计任务数量失败: %v", err) + } + return count, nil +} + +// ========== 类型转换函数 ========== + +// convertTaskType 将 TaskType 转换为数据库 ENUM 字符串 +func convertTaskType(taskType camp.TaskType) string { + switch taskType { + case camp.TaskTypeImageText: + return "IMAGE_TEXT" + case camp.TaskTypeVideo: + return "VIDEO" + case camp.TaskTypeObjective: + return "OBJECTIVE" + case camp.TaskTypeSubjective: + return "SUBJECTIVE" + case camp.TaskTypeEssay: + return "ESSAY" + default: + return "IMAGE_TEXT" + } +} + +// parseTaskType 将数据库 ENUM 字符串转换为 TaskType +func parseTaskType(taskTypeStr string) camp.TaskType { + switch taskTypeStr { + case "IMAGE_TEXT": + return camp.TaskTypeImageText + case "VIDEO": + return camp.TaskTypeVideo + case "OBJECTIVE": + return camp.TaskTypeObjective + case "SUBJECTIVE": + return camp.TaskTypeSubjective + case "ESSAY": + return camp.TaskTypeEssay + default: + return camp.TaskTypeUnknown + } +} + +// nullString 空字符串返回 nil,便于写入 NULL +func nullString(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/internal/camp/dao/user_camp_dao.go b/internal/camp/dao/user_camp_dao.go new file mode 100644 index 0000000..91afdd0 --- /dev/null +++ b/internal/camp/dao/user_camp_dao.go @@ -0,0 +1,244 @@ +package dao + +import ( + "database/sql" + "fmt" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/utils" + + "github.com/didi/gendry/builder" +) + +// UserCampDAO 用户加入打卡营 DAO +type UserCampDAO struct { + client *database.MySQLClient +} + +// NewUserCampDAO 创建用户打卡营DAO实例 +func NewUserCampDAO(client *database.MySQLClient) *UserCampDAO { + return &UserCampDAO{client: client} +} + +// CreateIfNotExists 幂等加入(存在则忽略) +func (d *UserCampDAO) CreateIfNotExists(id, userID, campID string) error { + table := "camp_user_camps" + + // 尝试插入;依赖 uk_user_camp 唯一约束保障幂等 + data := []map[string]any{{ + "id": id, + "user_id": userID, + "camp_id": campID, + "status": "ACTIVE", + // joined_at 由表默认值 CURRENT_TIMESTAMP 自动填充 + }} + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入语句失败: %v", err) + } + + // ON DUPLICATE KEY UPDATE 保持幂等 + cond += " ON DUPLICATE KEY UPDATE status=VALUES(status)" + + if _, err := d.client.DB.Exec(cond, vals...); err != nil { + return fmt.Errorf("加入打卡营失败: %v", err) + } + return nil +} + +// CheckUserCampStatus 检查用户是否加入了指定打卡营 +func (d *UserCampDAO) CheckUserCampStatus(userID, campID string) (bool, sql.NullTime, sql.NullString, error) { + table := "camp_user_camps" + + where := map[string]any{ + "user_id": userID, + "camp_id": campID, + "status": "ACTIVE", + } + + cond, vals, err := builder.BuildSelect(table, where, []string{"status", "joined_at", "current_section_id"}) + if err != nil { + return false, sql.NullTime{}, sql.NullString{}, fmt.Errorf("构建查询语句失败: %v", err) + } + + var status string + var joinedAt sql.NullTime + var currentSectionID sql.NullString + err = d.client.DB.QueryRow(cond, vals...).Scan(&status, &joinedAt, ¤tSectionID) + if err != nil { + if err == sql.ErrNoRows { + return false, sql.NullTime{}, sql.NullString{}, nil // 用户未加入 + } + return false, sql.NullTime{}, sql.NullString{}, fmt.Errorf("查询用户打卡营状态失败: %v", err) + } + + return true, joinedAt, currentSectionID, nil +} + +// UpdateCurrentSection 更新用户在当前打卡营中的当前小节 +func (d *UserCampDAO) UpdateCurrentSection(userID, campID, sectionID string) error { + table := "camp_user_camps" + + where := map[string]any{ + "user_id": userID, + "camp_id": campID, + } + + data := map[string]any{ + "current_section_id": sectionID, + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新当前小节失败: %v", err) + } + + return nil +} + +// GetCurrentSection 获取用户在当前打卡营中的当前小节ID +func (d *UserCampDAO) GetCurrentSection(userID, campID string) (string, error) { + table := "camp_user_camps" + + where := map[string]any{ + "user_id": userID, + "camp_id": campID, + "status": "ACTIVE", + } + + cond, vals, err := builder.BuildSelect(table, where, []string{"current_section_id"}) + if err != nil { + return "", fmt.Errorf("构建查询语句失败: %v", err) + } + + var currentSectionID sql.NullString + err = d.client.DB.QueryRow(cond, vals...).Scan(¤tSectionID) + if err != nil { + if err == sql.ErrNoRows { + return "", nil // 用户未加入 + } + return "", fmt.Errorf("查询当前小节失败: %v", err) + } + + if !currentSectionID.Valid { + return "", nil + } + + return currentSectionID.String, nil +} + +// ListUserJoinedCamps 获取用户已加入的打卡营列表 +func (d *UserCampDAO) ListUserJoinedCamps(userID string, page, pageSize int) ([]*camp.UserJoinedCamp, int, error) { + table := "camp_user_camps" + + // 查询总数 + countCond, countVals, err := builder.BuildSelect(table, map[string]any{ + "user_id": userID, + "status": "ACTIVE", + }, []string{"COUNT(*) as total"}) + if err != nil { + return nil, 0, fmt.Errorf("构建计数查询失败: %v", err) + } + + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询总数失败: %v", err) + } + + // 查询列表 + where := map[string]any{ + "user_id": userID, + "status": "ACTIVE", + } + + cond, vals, err := builder.BuildSelect(table, where, []string{"camp_id", "joined_at", "status"}) + if err != nil { + return nil, 0, fmt.Errorf("构建查询语句失败: %v", err) + } + + // 添加分页 + offset := (page - 1) * pageSize + cond += " ORDER BY joined_at DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询用户打卡营列表失败: %v", err) + } + defer rows.Close() + + var camps []*camp.UserJoinedCamp + for rows.Next() { + var camp camp.UserJoinedCamp + var joinedAt sql.NullTime + err := rows.Scan(&camp.CampID, &joinedAt, &camp.Status) + if err != nil { + return nil, 0, fmt.Errorf("扫描行数据失败: %v", err) + } + + // 格式化时间 + camp.JoinedAt = utils.FormatNullTimeToStd(joinedAt) + + camps = append(camps, &camp) + } + + return camps, total, nil +} + +// ListUserIDsByCamp 分页列出已加入指定打卡营的用户 ID(用于管理端进度矩阵:展示所有开启过该营的用户) +// userKeyword 可选,对 user_id 做 LIKE 模糊匹配 +func (d *UserCampDAO) ListUserIDsByCamp(campID, userKeyword string, page, pageSize int) ([]string, int, error) { + table := "camp_user_camps" + + where := map[string]any{ + "camp_id": campID, + "status": "ACTIVE", + } + if userKeyword != "" { + where["user_id like"] = "%" + userKeyword + "%" + } + + // 总数 + countCond, countVals, err := builder.BuildSelect(table, where, []string{"COUNT(*) as total"}) + if err != nil { + return nil, 0, fmt.Errorf("构建计数查询失败: %v", err) + } + var total int + err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("查询总数失败: %v", err) + } + + cond, vals, err := builder.BuildSelect(table, where, []string{"user_id"}) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + offset := (page - 1) * pageSize + cond += " ORDER BY joined_at DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询用户列表失败: %v", err) + } + defer rows.Close() + + var userIDs []string + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + continue + } + userIDs = append(userIDs, uid) + } + return userIDs, total, nil +} + diff --git a/internal/camp/dao/user_section_access_dao.go b/internal/camp/dao/user_section_access_dao.go new file mode 100644 index 0000000..84e5a97 --- /dev/null +++ b/internal/camp/dao/user_section_access_dao.go @@ -0,0 +1,84 @@ +package dao + +import ( + "database/sql" + "fmt" + + "dd_fiber_api/pkg/database" + + "github.com/didi/gendry/builder" +) + +// AccessSource 访问来源 +type AccessSource string + +const ( + AccessSourcePurchase AccessSource = "PURCHASE" // 购买获得 + AccessSourceGrant AccessSource = "GRANT" // 系统或人工授权 +) + +// UserSectionAccessDAO 用户小节访问记录 DAO +type UserSectionAccessDAO struct { + client *database.MySQLClient +} + +// NewUserSectionAccessDAO 创建用户小节访问记录 DAO +func NewUserSectionAccessDAO(client *database.MySQLClient) *UserSectionAccessDAO { + return &UserSectionAccessDAO{client: client} +} + +// GetByUserAndSection 查询用户是否已拥有某个小节的访问权限 +func (d *UserSectionAccessDAO) GetByUserAndSection(userID, sectionID string) (bool, error) { + table := "camp_user_section_access" + where := map[string]any{ + "user_id": userID, + "section_id": sectionID, + } + cond, vals, err := builder.BuildSelect(table, where, []string{"id"}) + if err != nil { + return false, fmt.Errorf("构建查询失败: %v", err) + } + var id string + err = d.client.DB.QueryRow(cond, vals...).Scan(&id) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("查询访问记录失败: %v", err) + } + return true, nil +} + +// Create 创建访问记录(简化版本,只保留访问权限相关字段) +// 订单相关信息可以从 orders 表查询,不需要冗余存储 +func (d *UserSectionAccessDAO) Create( + id, userID, campID, sectionID string, + paidPriceFen int32, + accessSource AccessSource, +) error { + table := "camp_user_section_access" + + data := []map[string]any{{ + "id": id, + "user_id": userID, + "camp_id": campID, + "section_id": sectionID, + "paid_price_fen": paidPriceFen, + "access_source": string(accessSource), + }} + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入失败: %v", err) + } + + // 使用 ON DUPLICATE KEY UPDATE 实现幂等性 + // 如果已存在,只更新 paid_price_fen 和 access_source(避免重复插入) + cond += " ON DUPLICATE KEY UPDATE paid_price_fen=VALUES(paid_price_fen), access_source=VALUES(access_source)" + + if _, err := d.client.DB.Exec(cond, vals...); err != nil { + return fmt.Errorf("创建访问记录失败: %v", err) + } + return nil +} + diff --git a/internal/camp/handler/camp_handler.go b/internal/camp/handler/camp_handler.go new file mode 100644 index 0000000..0ba593d --- /dev/null +++ b/internal/camp/handler/camp_handler.go @@ -0,0 +1,427 @@ +package handler + +import ( + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/service" + + "github.com/gofiber/fiber/v2" +) + +// CampHandler 打卡营处理器 +type CampHandler struct { + campService *service.CampService + userCampService *service.UserCampService +} + +// NewCampHandler 创建打卡营处理器(userCampService 可选,用于列表接口填充 is_joined) +func NewCampHandler(campService *service.CampService, userCampService *service.UserCampService) *CampHandler { + return &CampHandler{ + campService: campService, + userCampService: userCampService, + } +} + +// CreateCamp 创建打卡营 +func (h *CampHandler) CreateCamp(c *fiber.Ctx) error { + // 先解析为 map 以处理 intro_type 的数字到字符串转换 + var rawReq map[string]interface{} + if err := c.BodyParser(&rawReq); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 转换 intro_type:如果前端传的是数字,转换为字符串 + if introTypeVal, ok := rawReq["intro_type"]; ok { + switch v := introTypeVal.(type) { + case float64: // JSON 解析数字为 float64 + switch int(v) { + case 0: + rawReq["intro_type"] = "none" + case 1: + rawReq["intro_type"] = "image_text" + case 2: + rawReq["intro_type"] = "video" + default: + rawReq["intro_type"] = "none" + } + case int: + switch v { + case 0: + rawReq["intro_type"] = "none" + case 1: + rawReq["intro_type"] = "image_text" + case 2: + rawReq["intro_type"] = "video" + default: + rawReq["intro_type"] = "none" + } + } + } + + // 将转换后的 map 转换为 CreateCampRequest + var req camp.CreateCampRequest + if title, ok := rawReq["title"].(string); ok { + req.Title = title + } + if coverImage, ok := rawReq["cover_image"].(string); ok { + req.CoverImage = coverImage + } + if description, ok := rawReq["description"].(string); ok { + req.Description = description + } + if introTypeStr, ok := rawReq["intro_type"].(string); ok { + req.IntroType = camp.IntroType(introTypeStr) + } + if introContent, ok := rawReq["intro_content"].(string); ok { + req.IntroContent = introContent + } + if categoryID, ok := rawReq["category_id"].(string); ok { + req.CategoryID = categoryID + } + if isRecommended, ok := rawReq["is_recommended"].(bool); ok { + req.IsRecommended = isRecommended + } + + // 验证必填字段 + if req.Title == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营标题不能为空", + }) + } + + // 验证 IntroType + if req.IntroType != camp.IntroTypeNone && req.IntroType != camp.IntroTypeImageText && req.IntroType != camp.IntroTypeVideo { + req.IntroType = camp.IntroTypeNone + } + + resp, err := h.campService.CreateCamp(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建打卡营失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetCamp 获取打卡营 +// 支持两种方式: +// 1. 路径参数:GET /camp/camps/:id +// 2. 查询参数:GET /camp/camps/detail?id=xxx +func (h *CampHandler) GetCamp(c *fiber.Ctx) error { + // 优先从查询参数获取,如果没有则从路径参数获取 + id := c.Query("id") + if id == "" { + id = c.Params("id") + } + + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + + resp, err := h.campService.GetCamp(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取打卡营失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// UpdateCamp 更新打卡营 +func (h *CampHandler) UpdateCamp(c *fiber.Ctx) error { + // 先解析为 map 以处理 intro_type 的数字到字符串转换 + var rawReq map[string]interface{} + if err := c.BodyParser(&rawReq); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 转换 intro_type:如果前端传的是数字,转换为字符串 + if introTypeVal, ok := rawReq["intro_type"]; ok { + switch v := introTypeVal.(type) { + case float64: // JSON 解析数字为 float64 + switch int(v) { + case 0: + rawReq["intro_type"] = "none" + case 1: + rawReq["intro_type"] = "image_text" + case 2: + rawReq["intro_type"] = "video" + default: + rawReq["intro_type"] = "none" + } + case int: + switch v { + case 0: + rawReq["intro_type"] = "none" + case 1: + rawReq["intro_type"] = "image_text" + case 2: + rawReq["intro_type"] = "video" + default: + rawReq["intro_type"] = "none" + } + } + } + + // 将转换后的 map 转换为 UpdateCampRequest + var req camp.UpdateCampRequest + if id, ok := rawReq["id"].(string); ok { + req.ID = id + } + if title, ok := rawReq["title"].(string); ok { + req.Title = title + } + if coverImage, ok := rawReq["cover_image"].(string); ok { + req.CoverImage = coverImage + } + if description, ok := rawReq["description"].(string); ok { + req.Description = description + } + if introTypeStr, ok := rawReq["intro_type"].(string); ok { + req.IntroType = camp.IntroType(introTypeStr) + } + if introContent, ok := rawReq["intro_content"].(string); ok { + req.IntroContent = introContent + } + if categoryID, ok := rawReq["category_id"].(string); ok { + req.CategoryID = categoryID + } + if isRecommended, ok := rawReq["is_recommended"].(bool); ok { + req.IsRecommended = isRecommended + } + + // 从 URL 参数获取 ID(如果请求体中没有) + if req.ID == "" { + req.ID = c.Query("id") + if req.ID == "" { + req.ID = c.Params("id") + } + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + + // 验证 IntroType + if req.IntroType != camp.IntroTypeNone && req.IntroType != camp.IntroTypeImageText && req.IntroType != camp.IntroTypeVideo { + req.IntroType = camp.IntroTypeNone + } + + resp, err := h.campService.UpdateCamp(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新打卡营失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// DeleteCamp 删除打卡营 +func (h *CampHandler) DeleteCamp(c *fiber.Ctx) error { + // 优先从查询参数获取,如果没有则从路径参数获取 + id := c.Query("id") + if id == "" { + id = c.Params("id") + } + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + + resp, err := h.campService.DeleteCamp(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除打卡营失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListCamps 列出打卡营 +func (h *CampHandler) ListCamps(c *fiber.Ctx) error { + var req camp.ListCampsRequest + if err := c.QueryParser(&req); err != nil { + req.Page = 1 + req.PageSize = 10 + } + + // 兼容前端/接口传 is_recommended(true/false)而非 recommend_filter + if req.RecommendFilter == "" || req.RecommendFilter == camp.RecommendFilterAll { + switch c.Query("is_recommended") { + case "true", "1": + req.RecommendFilter = camp.RecommendFilterOnlyTrue + case "false", "0": + req.RecommendFilter = camp.RecommendFilterOnlyFalse + } + } + + // 验证 RecommendFilter + if req.RecommendFilter == "" { + req.RecommendFilter = camp.RecommendFilterAll + } + + resp, err := h.campService.ListCamps(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取打卡营列表失败: " + err.Error(), + }) + } + + // 保证每个营都带上 is_joined:若服务层未填充且提供了 user_id,则在此用 UserCampService 补全 + if req.UserID != "" && len(resp.Camps) > 0 && h.userCampService != nil { + for _, campItem := range resp.Camps { + if campItem.IsJoined != nil { + continue + } + statusResp, _ := h.userCampService.CheckUserCampStatus(req.UserID, campItem.ID) + if statusResp != nil { + joined := statusResp.IsJoined + campItem.IsJoined = &joined + } + } + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetCampDetailWithStatus 获取打卡营详情及状态(聚合接口) +func (h *CampHandler) GetCampDetailWithStatus(c *fiber.Ctx) error { + var req camp.GetCampDetailWithStatusRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证必填字段 + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + + resp, err := h.campService.GetCampDetailWithStatus(c.Context(), &req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取打卡营详情失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// CanUnlockSection 检查是否可开启下一小节(后端查库:上一小节是否完成等) +func (h *CampHandler) CanUnlockSection(c *fiber.Ctx) error { + var req camp.CanUnlockSectionRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + if req.CampID == "" || req.SectionID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID和小节ID不能为空", + }) + } + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + + resp, err := h.campService.CanUnlockSection(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "检查失败: " + err.Error(), + }) + } + return c.Status(fiber.StatusOK).JSON(resp) +} + +// CanStartTask 检查用户是否可以开始指定任务(前置任务已完成) +func (h *CampHandler) CanStartTask(c *fiber.Ctx) error { + var req camp.CanStartTaskRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.TaskID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务ID不能为空", + }) + } + + resp, err := h.campService.CanStartTask(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "检查失败: " + err.Error(), + }) + } + return c.Status(fiber.StatusOK).JSON(resp) +} diff --git a/internal/camp/handler/category_handler.go b/internal/camp/handler/category_handler.go new file mode 100644 index 0000000..d0f2ffd --- /dev/null +++ b/internal/camp/handler/category_handler.go @@ -0,0 +1,182 @@ +package handler + +import ( + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/service" + + "github.com/gofiber/fiber/v2" +) + +// CategoryHandler 分类处理器 +type CategoryHandler struct { + categoryService *service.CategoryService +} + +// NewCategoryHandler 创建分类处理器 +func NewCategoryHandler(categoryService *service.CategoryService) *CategoryHandler { + return &CategoryHandler{ + categoryService: categoryService, + } +} + +// CreateCategory 创建分类 +func (h *CategoryHandler) CreateCategory(c *fiber.Ctx) error { + var req camp.CreateCategoryRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证必填字段 + if req.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "分类名称不能为空", + }) + } + + resp, err := h.categoryService.CreateCategory(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建分类失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetCategory 获取分类 +func (h *CategoryHandler) GetCategory(c *fiber.Ctx) error { + // 优先从查询参数获取,如果没有则从路径参数获取 + id := c.Query("id") + if id == "" { + id = c.Params("id") + } + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "分类ID不能为空", + }) + } + + resp, err := h.categoryService.GetCategory(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取分类失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// UpdateCategory 更新分类 +func (h *CategoryHandler) UpdateCategory(c *fiber.Ctx) error { + var req camp.UpdateCategoryRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 从 URL 参数获取 ID(如果请求体中没有) + if req.ID == "" { + req.ID = c.Query("id") + if req.ID == "" { + req.ID = c.Params("id") + } + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "分类ID不能为空", + }) + } + + resp, err := h.categoryService.UpdateCategory(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新分类失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// DeleteCategory 删除分类 +func (h *CategoryHandler) DeleteCategory(c *fiber.Ctx) error { + var id string + // 兼容前端:POST body 为 {"params":{"id":"xxx"}} + var body struct { + Params struct { + ID string `json:"id"` + } `json:"params"` + } + if err := c.BodyParser(&body); err == nil && body.Params.ID != "" { + id = body.Params.ID + } + if id == "" { + id = c.Query("id") + } + if id == "" { + id = c.Params("id") + } + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "分类ID不能为空", + }) + } + + resp, err := h.categoryService.DeleteCategory(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除分类失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListCategories 列出分类 +func (h *CategoryHandler) ListCategories(c *fiber.Ctx) error { + var req camp.ListCategoriesRequest + if err := c.QueryParser(&req); err != nil { + req.Page = 1 + req.PageSize = 10 + } + + resp, err := h.categoryService.ListCategories(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取分类列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + diff --git a/internal/camp/handler/progress_handler.go b/internal/camp/handler/progress_handler.go new file mode 100644 index 0000000..06261af --- /dev/null +++ b/internal/camp/handler/progress_handler.go @@ -0,0 +1,182 @@ +package handler + +import ( + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/service" + + "github.com/gofiber/fiber/v2" +) + +// ProgressHandler 用户进度处理器 +type ProgressHandler struct { + progressService *service.ProgressService +} + +// NewProgressHandler 创建用户进度处理器 +func NewProgressHandler(progressService *service.ProgressService) *ProgressHandler { + return &ProgressHandler{ + progressService: progressService, + } +} + +// UpdateUserProgress 更新用户进度 +func (h *ProgressHandler) UpdateUserProgress(c *fiber.Ctx) error { + var req camp.UpdateUserProgressRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证必填字段 + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.TaskID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务ID不能为空", + }) + } + + // 验证 ReviewStatus + if req.ReviewStatus == "" { + req.ReviewStatus = camp.ReviewStatusPending + } + + resp, err := h.progressService.UpdateUserProgress(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新用户进度失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetUserProgress 获取用户进度 +func (h *ProgressHandler) GetUserProgress(c *fiber.Ctx) error { + var req camp.GetUserProgressRequest + if err := c.QueryParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.TaskID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务ID不能为空", + }) + } + + resp, err := h.progressService.GetUserProgress(req.UserID, req.TaskID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取用户进度失败: " + err.Error(), + }) + } + + // 如果进度不存在,返回 200 但 success=false,而不是 404 + // 这样可以区分"路由不存在"和"数据不存在" + if !resp.Success { + return c.Status(fiber.StatusOK).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListUserProgress 列出用户进度 +func (h *ProgressHandler) ListUserProgress(c *fiber.Ctx) error { + var req camp.ListUserProgressRequest + if err := c.QueryParser(&req); err != nil { + req.Page = 1 + req.PageSize = 10 + } + + resp, err := h.progressService.ListUserProgress(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取用户进度列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetPendingReviewCount 获取待审核任务数量(用于后台右上角提示) +func (h *ProgressHandler) GetPendingReviewCount(c *fiber.Ctx) error { + req := &camp.ListUserProgressRequest{ + ReviewStatus: string(camp.ReviewStatusPending), + Page: 1, + PageSize: 1, + } + resp, err := h.progressService.ListUserProgress(req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取待审核数量失败: " + err.Error(), + }) + } + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "count": resp.Total, + }) +} + +// ResetTaskProgress 重置任务进度 +func (h *ProgressHandler) ResetTaskProgress(c *fiber.Ctx) error { + var req camp.ResetTaskProgressRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.TaskID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务ID不能为空", + }) + } + + resp, err := h.progressService.ResetTaskProgress(req.UserID, req.TaskID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "重置任务进度失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + diff --git a/internal/camp/handler/section_handler.go b/internal/camp/handler/section_handler.go new file mode 100644 index 0000000..94d30b6 --- /dev/null +++ b/internal/camp/handler/section_handler.go @@ -0,0 +1,473 @@ +package handler + +import ( + "dd_fiber_api/internal/camp" + camp_dao "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/internal/camp/service" + "dd_fiber_api/internal/order" + order_dao "dd_fiber_api/internal/order/dao" + order_service "dd_fiber_api/internal/order/service" + + "github.com/gofiber/fiber/v2" +) + +// SectionHandler 小节处理器 +type SectionHandler struct { + sectionService *service.SectionService + orderService *order_service.OrderService // 订单服务(用于购买小节) + orderDAO *order_dao.OrderDAO // 订单DAO(用于查询是否已拥有) + userCampDAO *camp_dao.UserCampDAO // 用户打卡营 DAO(用于更新当前小节 is_current) +} + +// NewSectionHandler 创建小节处理器 +func NewSectionHandler(sectionService *service.SectionService) *SectionHandler { + return &SectionHandler{ + sectionService: sectionService, + } +} + +// SetOrderService 设置订单服务 +func (h *SectionHandler) SetOrderService(orderService *order_service.OrderService) { + h.orderService = orderService +} + +// SetOrderDAO 设置订单DAO +func (h *SectionHandler) SetOrderDAO(orderDAO *order_dao.OrderDAO) { + h.orderDAO = orderDAO +} + +// SetUserCampDAO 设置用户打卡营 DAO(用于开启小节时更新 current_section_id,保证 is_current 只有最新小节为 true) +func (h *SectionHandler) SetUserCampDAO(userCampDAO *camp_dao.UserCampDAO) { + h.userCampDAO = userCampDAO +} + +// CreateSection 创建小节 +func (h *SectionHandler) CreateSection(c *fiber.Ctx) error { + // 先解析为 map 以处理 time_interval_type 的数字到字符串转换 + var rawReq map[string]interface{} + if err := c.BodyParser(&rawReq); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 转换 time_interval_type:如果前端传的是数字,转换为字符串 + if timeIntervalTypeVal, ok := rawReq["time_interval_type"]; ok { + switch v := timeIntervalTypeVal.(type) { + case float64: // JSON 解析数字为 float64 + switch int(v) { + case 0: + rawReq["time_interval_type"] = "none" + case 1: + rawReq["time_interval_type"] = "hour" + case 2: + rawReq["time_interval_type"] = "natural_day" + case 3: + rawReq["time_interval_type"] = "paid" + default: + rawReq["time_interval_type"] = "none" + } + case int: + switch v { + case 0: + rawReq["time_interval_type"] = "none" + case 1: + rawReq["time_interval_type"] = "hour" + case 2: + rawReq["time_interval_type"] = "natural_day" + case 3: + rawReq["time_interval_type"] = "paid" + default: + rawReq["time_interval_type"] = "none" + } + } + } + + // 将转换后的 map 转换为 CreateSectionRequest + var req camp.CreateSectionRequest + if campID, ok := rawReq["camp_id"].(string); ok { + req.CampID = campID + } + if title, ok := rawReq["title"].(string); ok { + req.Title = title + } + if sectionNumber, ok := rawReq["section_number"].(float64); ok { + req.SectionNumber = int32(sectionNumber) + } + if priceFen, ok := rawReq["price_fen"].(float64); ok { + req.PriceFen = int32(priceFen) + } + // require_previous_section 已废弃,不再从请求中读取,创建时固定为 false + if timeIntervalTypeStr, ok := rawReq["time_interval_type"].(string); ok { + req.TimeIntervalType = camp.TimeIntervalType(timeIntervalTypeStr) + } + if timeIntervalValue, ok := rawReq["time_interval_value"].(float64); ok { + req.TimeIntervalValue = int32(timeIntervalValue) + } + + // 验证必填字段 + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + if req.Title == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节标题不能为空", + }) + } + + // 验证 TimeIntervalType + if req.TimeIntervalType != camp.TimeIntervalTypeNone && req.TimeIntervalType != camp.TimeIntervalTypeHour && req.TimeIntervalType != camp.TimeIntervalTypeNaturalDay && req.TimeIntervalType != camp.TimeIntervalTypePaid { + req.TimeIntervalType = camp.TimeIntervalTypeNone + } + + resp, err := h.sectionService.CreateSection(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建小节失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetSection 获取小节 +func (h *SectionHandler) GetSection(c *fiber.Ctx) error { + // 优先从查询参数获取,如果没有则从路径参数获取 + id := c.Query("id") + if id == "" { + id = c.Params("id") + } + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节ID不能为空", + }) + } + + resp, err := h.sectionService.GetSection(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取小节失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// UpdateSection 更新小节 +func (h *SectionHandler) UpdateSection(c *fiber.Ctx) error { + // 先解析为 map 以处理 time_interval_type 的数字到字符串转换 + var rawReq map[string]interface{} + if err := c.BodyParser(&rawReq); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 转换 time_interval_type:如果前端传的是数字,转换为字符串 + if timeIntervalTypeVal, ok := rawReq["time_interval_type"]; ok { + switch v := timeIntervalTypeVal.(type) { + case float64: // JSON 解析数字为 float64 + switch int(v) { + case 0: + rawReq["time_interval_type"] = "none" + case 1: + rawReq["time_interval_type"] = "hour" + case 2: + rawReq["time_interval_type"] = "natural_day" + case 3: + rawReq["time_interval_type"] = "paid" + default: + rawReq["time_interval_type"] = "none" + } + case int: + switch v { + case 0: + rawReq["time_interval_type"] = "none" + case 1: + rawReq["time_interval_type"] = "hour" + case 2: + rawReq["time_interval_type"] = "natural_day" + case 3: + rawReq["time_interval_type"] = "paid" + default: + rawReq["time_interval_type"] = "none" + } + } + } + + // 将转换后的 map 转换为 UpdateSectionRequest + var req camp.UpdateSectionRequest + if id, ok := rawReq["id"].(string); ok { + req.ID = id + } + if campID, ok := rawReq["camp_id"].(string); ok { + req.CampID = campID + } + if title, ok := rawReq["title"].(string); ok { + req.Title = title + } + if sectionNumber, ok := rawReq["section_number"].(float64); ok { + req.SectionNumber = int32(sectionNumber) + } + if priceFen, ok := rawReq["price_fen"].(float64); ok { + req.PriceFen = int32(priceFen) + } + // require_previous_section 已废弃,不再从请求中读取,更新时保留原值 + if timeIntervalTypeStr, ok := rawReq["time_interval_type"].(string); ok { + req.TimeIntervalType = camp.TimeIntervalType(timeIntervalTypeStr) + } + if timeIntervalValue, ok := rawReq["time_interval_value"].(float64); ok { + req.TimeIntervalValue = int32(timeIntervalValue) + } + + // 从 URL 参数获取 ID(如果请求体中没有) + if req.ID == "" { + req.ID = c.Query("id") + if req.ID == "" { + req.ID = c.Params("id") + } + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节ID不能为空", + }) + } + + // 验证 TimeIntervalType + if req.TimeIntervalType != camp.TimeIntervalTypeNone && req.TimeIntervalType != camp.TimeIntervalTypeHour && req.TimeIntervalType != camp.TimeIntervalTypeNaturalDay && req.TimeIntervalType != camp.TimeIntervalTypePaid { + req.TimeIntervalType = camp.TimeIntervalTypeNone + } + + resp, err := h.sectionService.UpdateSection(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新小节失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// DeleteSection 删除小节 +func (h *SectionHandler) DeleteSection(c *fiber.Ctx) error { + // 优先从查询参数获取,如果没有则从路径参数获取 + id := c.Query("id") + if id == "" { + id = c.Params("id") + } + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节ID不能为空", + }) + } + + resp, err := h.sectionService.DeleteSection(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除小节失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListSections 列出小节 +func (h *SectionHandler) ListSections(c *fiber.Ctx) error { + var req camp.ListSectionsRequest + if err := c.QueryParser(&req); err != nil { + req.Page = 1 + req.PageSize = 10 + } + + // user_id 是可选参数,如果前端传递了则使用,否则为空字符串 + // 这样 service 层可以根据 user_id 获取用户相关的状态信息 + + resp, err := h.sectionService.ListSections(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取小节列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// PurchaseSection 购买小节(用于开启打卡营后的第一小节等场景) +func (h *SectionHandler) PurchaseSection(c *fiber.Ctx) error { + var req camp.PurchaseSectionRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证必填字段 + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + if req.SectionID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节ID不能为空", + }) + } + + // 检查订单服务是否已设置 + if h.orderService == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "订单服务未初始化", + }) + } + + // 获取小节信息,检查价格 + sectionResp, err := h.sectionService.GetSection(req.SectionID) + if err != nil || !sectionResp.Success { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "获取小节信息失败", + }) + } + + section := sectionResp.Section + if section == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节不存在", + }) + } + + // 验证小节是否属于指定的打卡营 + if section.CampID != req.CampID { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节不属于指定的打卡营", + }) + } + + // 检查是否已拥有该小节(参考 gRPC 版本的逻辑) + if h.orderDAO != nil { + alreadyOwned, err := h.orderDAO.CheckUserHasSection(req.UserID, req.SectionID) + if err == nil && alreadyOwned { + // 已拥有:幂等返回,同时将当前小节更新为该节,保证 is_current 只有最新开启的小节为 true + if h.userCampDAO != nil { + _ = h.userCampDAO.UpdateCurrentSection(req.UserID, req.CampID, req.SectionID) + } + return c.Status(fiber.StatusOK).JSON(camp.PurchaseSectionResponse{ + Success: true, + Message: "已拥有", + AlreadyOwned: true, + }) + } + } + + // 无论免费还是付费,都创建订单(参考 gRPC 版本的逻辑) + // 如果小节价格为0(免费),直接创建已支付的订单 + if section.PriceFen == 0 { + // 创建免费订单 + orderReq := &order.CreateOrderRequest{ + UserID: req.UserID, + CampID: req.CampID, + SectionID: req.SectionID, + PaymentMethod: order.PaymentMethodUnknown, // 免费订单不需要支付方式 + } + + orderResp, err := h.orderService.CreateOrder(c.Context(), orderReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建订单失败: " + err.Error(), + }) + } + + if !orderResp.Success { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": orderResp.Message, + }) + } + + return c.Status(fiber.StatusOK).JSON(camp.PurchaseSectionResponse{ + Success: true, + Message: "购买成功(免费)", + OrderID: orderResp.OrderID, + OrderStatus: string(orderResp.OrderStatus), + ActualAmount: orderResp.ActualAmount, + IsFree: true, + }) + } + + // 如果需要付费,也创建订单(参考 gRPC 版本的逻辑) + // 创建待支付订单 + orderReq := &order.CreateOrderRequest{ + UserID: req.UserID, + CampID: req.CampID, + SectionID: req.SectionID, + PaymentMethod: order.PaymentMethodUnknown, // 待支付订单,后续需要调用支付接口 + } + + orderResp, err := h.orderService.CreateOrder(c.Context(), orderReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建订单失败: " + err.Error(), + }) + } + + if !orderResp.Success { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": orderResp.Message, + }) + } + + return c.Status(fiber.StatusOK).JSON(camp.PurchaseSectionResponse{ + Success: true, + Message: "订单创建成功,请完成支付", + OrderID: orderResp.OrderID, + OrderStatus: string(orderResp.OrderStatus), + ActualAmount: orderResp.ActualAmount, + IsFree: false, + }) +} diff --git a/internal/camp/handler/task_handler.go b/internal/camp/handler/task_handler.go new file mode 100644 index 0000000..66d0564 --- /dev/null +++ b/internal/camp/handler/task_handler.go @@ -0,0 +1,422 @@ +package handler + +import ( + "encoding/json" + "strconv" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/service" + + "github.com/gofiber/fiber/v2" +) + +// TaskHandler 任务处理器 +type TaskHandler struct { + taskService *service.TaskService +} + +// NewTaskHandler 创建任务处理器 +func NewTaskHandler(taskService *service.TaskService) *TaskHandler { + return &TaskHandler{ + taskService: taskService, + } +} + +// CreateTask 创建任务 +func (h *TaskHandler) CreateTask(c *fiber.Ctx) error { + // 先解析为 map 以处理 task_type 的数字到字符串转换 + var rawReq map[string]interface{} + if err := c.BodyParser(&rawReq); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 转换 task_type:如果前端传的是数字,转换为字符串 + if taskTypeVal, ok := rawReq["task_type"]; ok { + switch v := taskTypeVal.(type) { + case float64: // JSON 解析数字为 float64 + switch int(v) { + case 0: + rawReq["task_type"] = "unknown" + case 1: + rawReq["task_type"] = "image_text" + case 2: + rawReq["task_type"] = "video" + case 3: + rawReq["task_type"] = "subjective" + case 4: + rawReq["task_type"] = "objective" + case 5: + rawReq["task_type"] = "essay" + default: + rawReq["task_type"] = "unknown" + } + case int: + switch v { + case 0: + rawReq["task_type"] = "unknown" + case 1: + rawReq["task_type"] = "image_text" + case 2: + rawReq["task_type"] = "video" + case 3: + rawReq["task_type"] = "subjective" + case 4: + rawReq["task_type"] = "objective" + case 5: + rawReq["task_type"] = "essay" + default: + rawReq["task_type"] = "unknown" + } + } + } + + // 将转换后的 map 转换为 CreateTaskRequest + var req camp.CreateTaskRequest + if campID, ok := rawReq["camp_id"].(string); ok { + req.CampID = campID + } + if sectionID, ok := rawReq["section_id"].(string); ok { + req.SectionID = sectionID + } + if taskTypeStr, ok := rawReq["task_type"].(string); ok { + req.TaskType = camp.TaskType(taskTypeStr) + } + if title, ok := rawReq["title"].(string); ok { + req.Title = title + } + // Content 和 Condition 需要特殊处理,因为它们可能是 JSON 对象 + if content, ok := rawReq["content"]; ok { + contentBytes, err := json.Marshal(content) + if err == nil { + req.Content = contentBytes + } else { + req.Content = []byte("{}") + } + } + if condition, ok := rawReq["condition"]; ok { + conditionBytes, err := json.Marshal(condition) + if err == nil { + req.Condition = conditionBytes + } else { + req.Condition = []byte("{}") + } + } + if pid, ok := rawReq["prerequisite_task_id"].(string); ok { + req.PrerequisiteTaskID = pid + } + + // 验证必填字段 + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + if req.SectionID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节ID不能为空", + }) + } + if req.TaskType == "" || req.TaskType == camp.TaskTypeUnknown { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务类型不能为空", + }) + } + + // 验证 Content 和 Condition 是否为有效的 JSON + if len(req.Content) == 0 { + req.Content = []byte("{}") + } + if len(req.Condition) == 0 { + req.Condition = []byte("{}") + } + + resp, err := h.taskService.CreateTask(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建任务失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetTask 获取任务 +// 支持两种方式: +// 1. 路径参数:GET /api/v1/camp/tasks/:id +// 2. 查询参数:GET /api/v1/camp/tasks/detail?id=xxx +func (h *TaskHandler) GetTask(c *fiber.Ctx) error { + // 优先从查询参数获取,如果没有则从路径参数获取 + id := c.Query("id") + if id == "" { + id = c.Params("id") + } + + // 如果路径参数是 "detail",说明是查询参数方式,需要从查询参数获取 + if id == "detail" { + id = c.Query("id") + } + + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务ID不能为空", + }) + } + + // 获取 user_id(可选) + userID := c.Query("user_id") + + resp, err := h.taskService.GetTask(id, userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取任务失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// UpdateTask 更新任务 +func (h *TaskHandler) UpdateTask(c *fiber.Ctx) error { + // 先解析为 map 以处理 task_type 的数字到字符串转换 + var rawReq map[string]interface{} + if err := c.BodyParser(&rawReq); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 转换 task_type:如果前端传的是数字,转换为字符串 + if taskTypeVal, ok := rawReq["task_type"]; ok { + switch v := taskTypeVal.(type) { + case float64: // JSON 解析数字为 float64 + switch int(v) { + case 0: + rawReq["task_type"] = "unknown" + case 1: + rawReq["task_type"] = "image_text" + case 2: + rawReq["task_type"] = "video" + case 3: + rawReq["task_type"] = "subjective" + case 4: + rawReq["task_type"] = "objective" + case 5: + rawReq["task_type"] = "essay" + default: + rawReq["task_type"] = "unknown" + } + case int: + switch v { + case 0: + rawReq["task_type"] = "unknown" + case 1: + rawReq["task_type"] = "image_text" + case 2: + rawReq["task_type"] = "video" + case 3: + rawReq["task_type"] = "subjective" + case 4: + rawReq["task_type"] = "objective" + case 5: + rawReq["task_type"] = "essay" + default: + rawReq["task_type"] = "unknown" + } + } + } + + // 将转换后的 map 转换为 UpdateTaskRequest + var req camp.UpdateTaskRequest + if id, ok := rawReq["id"].(string); ok { + req.ID = id + } + if campID, ok := rawReq["camp_id"].(string); ok { + req.CampID = campID + } + if sectionID, ok := rawReq["section_id"].(string); ok { + req.SectionID = sectionID + } + if taskTypeStr, ok := rawReq["task_type"].(string); ok { + req.TaskType = camp.TaskType(taskTypeStr) + } + if title, ok := rawReq["title"].(string); ok { + req.Title = title + } + // Content 和 Condition 需要特殊处理,因为它们可能是 JSON 对象 + if content, ok := rawReq["content"]; ok { + contentBytes, err := json.Marshal(content) + if err == nil { + req.Content = contentBytes + } else { + req.Content = []byte("{}") + } + } + if condition, ok := rawReq["condition"]; ok { + conditionBytes, err := json.Marshal(condition) + if err == nil { + req.Condition = conditionBytes + } else { + req.Condition = []byte("{}") + } + } + if pid, ok := rawReq["prerequisite_task_id"].(string); ok { + req.PrerequisiteTaskID = pid + } + + // 从 URL 参数获取 ID(如果请求体中没有) + if req.ID == "" { + req.ID = c.Query("id") + if req.ID == "" { + req.ID = c.Params("id") + } + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务ID不能为空", + }) + } + + // 验证 Content 和 Condition 是否为有效的 JSON + if len(req.Content) == 0 { + req.Content = []byte("{}") + } + if len(req.Condition) == 0 { + req.Condition = []byte("{}") + } + + resp, err := h.taskService.UpdateTask(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新任务失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// DeleteTask 删除任务 +func (h *TaskHandler) DeleteTask(c *fiber.Ctx) error { + var id string + // 支持从 JSON body 获取(前端可能传 {"id":"xxx"} 或 {"params":{"id":"xxx"}}) + if c.Get("Content-Type") != "" && len(c.Body()) > 0 { + var body struct { + ID string `json:"id"` + Params struct { + ID string `json:"id"` + } `json:"params"` + } + if err := c.BodyParser(&body); err == nil { + if body.ID != "" { + id = body.ID + } else if body.Params.ID != "" { + id = body.Params.ID + } + } + } + if id == "" { + id = c.Query("id") + } + if id == "" { + id = c.Params("id") + } + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务ID不能为空", + }) + } + + resp, err := h.taskService.DeleteTask(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除任务失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListTasks 列出任务 +func (h *TaskHandler) ListTasks(c *fiber.Ctx) error { + // 显式从 Query 读取所有参数,确保 GET 请求的 camp_id、section_id 等筛选被正确接收(admin 与 client 通用) + page, _ := strconv.Atoi(c.Query("page", "1")) + pageSize, _ := strconv.Atoi(c.Query("page_size", "10")) + req := camp.ListTasksRequest{ + Keyword: c.Query("keyword"), + CampID: c.Query("camp_id"), + SectionID: c.Query("section_id"), + Page: page, + PageSize: pageSize, + } + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + if s := c.Query("task_type"); s != "" { + // 支持前端传数字(0=unknown,1=image_text,2=video,3=subjective,4=objective,5=essay)或字符串 + switch s { + case "0": + req.TaskType = camp.TaskTypeUnknown + case "1": + req.TaskType = camp.TaskTypeImageText + case "2": + req.TaskType = camp.TaskTypeVideo + case "3": + req.TaskType = camp.TaskTypeSubjective + case "4": + req.TaskType = camp.TaskTypeObjective + case "5": + req.TaskType = camp.TaskTypeEssay + default: + req.TaskType = camp.TaskType(s) + } + } + if req.TaskType == "" { + req.TaskType = camp.TaskTypeUnknown + } + + resp, err := h.taskService.ListTasks(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取任务列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} diff --git a/internal/camp/handler/user_camp_handler.go b/internal/camp/handler/user_camp_handler.go new file mode 100644 index 0000000..f233e57 --- /dev/null +++ b/internal/camp/handler/user_camp_handler.go @@ -0,0 +1,192 @@ +package handler + +import ( + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/service" + + "github.com/gofiber/fiber/v2" +) + +// UserCampHandler 用户打卡营处理器 +type UserCampHandler struct { + userCampService *service.UserCampService +} + +// NewUserCampHandler 创建用户打卡营处理器 +func NewUserCampHandler(userCampService *service.UserCampService) *UserCampHandler { + return &UserCampHandler{ + userCampService: userCampService, + } +} + +// JoinCamp 用户加入打卡营 +func (h *UserCampHandler) JoinCamp(c *fiber.Ctx) error { + var req camp.JoinCampRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + + resp, err := h.userCampService.JoinCamp(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "加入打卡营失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// CheckUserCampStatus 检查用户打卡营状态 +func (h *UserCampHandler) CheckUserCampStatus(c *fiber.Ctx) error { + var req camp.CheckUserCampStatusRequest + if err := c.QueryParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + + resp, err := h.userCampService.CheckUserCampStatus(req.UserID, req.CampID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "查询用户打卡营状态失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// CheckUserCampStatusPost 检查用户打卡营状态(POST 方式,供小程序客户端调用) +func (h *UserCampHandler) CheckUserCampStatusPost(c *fiber.Ctx) error { + var req camp.CheckUserCampStatusRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + // 尝试从本地存储获取的 user_id(小程序端可能放在 body 中) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + + resp, err := h.userCampService.CheckUserCampStatus(req.UserID, req.CampID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "查询用户打卡营状态失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListUserCamps 获取用户已加入的打卡营列表 +func (h *UserCampHandler) ListUserCamps(c *fiber.Ctx) error { + var req camp.ListUserCampsRequest + if err := c.QueryParser(&req); err != nil { + req.Page = 1 + req.PageSize = 10 + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + + resp, err := h.userCampService.ListUserCamps(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取用户打卡营列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ResetCampProgress 重置打卡营进度 +func (h *UserCampHandler) ResetCampProgress(c *fiber.Ctx) error { + var req camp.ResetCampProgressRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + + resp, err := h.userCampService.ResetCampProgress(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "重置打卡营进度失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + diff --git a/internal/camp/service/camp_service.go b/internal/camp/service/camp_service.go new file mode 100644 index 0000000..8f86743 --- /dev/null +++ b/internal/camp/service/camp_service.go @@ -0,0 +1,1290 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/internal/order" + order_dao "dd_fiber_api/internal/order/dao" + order_service "dd_fiber_api/internal/order/service" + question_service "dd_fiber_api/internal/question/service" + "dd_fiber_api/pkg/snowflake" + "dd_fiber_api/pkg/utils" +) + +// CampService 打卡营服务 +type CampService struct { + campDAO *dao.CampDAO + sectionDAO *dao.SectionDAO + taskDAO *dao.TaskDAO + progressDAO *dao.ProgressDAO + userCampDAO *dao.UserCampDAO + orderDAO *order_dao.OrderDAO + orderService *order_service.OrderService + answerRecordService *question_service.AnswerRecordService + paperService *question_service.PaperService +} + +// NewCampService 创建打卡营服务 +func NewCampService(campDAO *dao.CampDAO) *CampService { + return &CampService{ + campDAO: campDAO, + } +} + +// SetDependencies 设置依赖(用于聚合接口) +func (s *CampService) SetDependencies(sectionDAO *dao.SectionDAO, taskDAO *dao.TaskDAO, progressDAO *dao.ProgressDAO, userCampDAO *dao.UserCampDAO, orderDAO *order_dao.OrderDAO) { + s.sectionDAO = sectionDAO + s.taskDAO = taskDAO + s.progressDAO = progressDAO + s.userCampDAO = userCampDAO + s.orderDAO = orderDAO +} + +// SetOrderService 设置订单服务(用于任务完成后自动开启下一小节时创建 0 元订单) +func (s *CampService) SetOrderService(svc *order_service.OrderService) { + s.orderService = svc +} + +// SetAnswerRecordService 设置答题记录服务(用于客观题完成状态校验) +func (s *CampService) SetAnswerRecordService(svc *question_service.AnswerRecordService) { + s.answerRecordService = svc +} + +// SetPaperService 设置试卷服务(用于客观题完成状态:需答完所有题才算完成) +func (s *CampService) SetPaperService(svc *question_service.PaperService) { + s.paperService = svc +} + +// CreateCamp 创建打卡营 +func (s *CampService) CreateCamp(req *camp.CreateCampRequest) (*camp.CreateCampResponse, error) { + campID := snowflake.GetInstance().NextIDString() + + campObj := &camp.Camp{ + ID: campID, + Title: req.Title, + CoverImage: req.CoverImage, + Description: req.Description, + IntroType: req.IntroType, + IntroContent: req.IntroContent, + CategoryID: req.CategoryID, + IsRecommended: req.IsRecommended, + SectionCount: 0, + } + + err := s.campDAO.Create(campObj) + if err != nil { + return &camp.CreateCampResponse{ + Success: false, + Message: fmt.Sprintf("创建打卡营失败: %v", err), + }, nil + } + + return &camp.CreateCampResponse{ + ID: campID, + Success: true, + Message: "创建打卡营成功", + }, nil +} + +// GetCamp 获取打卡营 +func (s *CampService) GetCamp(id string) (*camp.GetCampResponse, error) { + campObj, err := s.campDAO.GetByID(id) + if err != nil { + return &camp.GetCampResponse{ + Success: false, + Message: fmt.Sprintf("获取打卡营失败: %v", err), + }, nil + } + + // 如果提供了 user_id,可以在这里获取用户相关的状态信息 + // 例如:是否已加入、当前进度等 + // 目前先返回基础信息,后续可以根据需要扩展 + + return &camp.GetCampResponse{ + Camp: campObj, + Success: true, + Message: "获取打卡营成功", + }, nil +} + +// UpdateCamp 更新打卡营 +func (s *CampService) UpdateCamp(req *camp.UpdateCampRequest) (*camp.UpdateCampResponse, error) { + campObj := &camp.Camp{ + ID: req.ID, + Title: req.Title, + CoverImage: req.CoverImage, + Description: req.Description, + IntroType: req.IntroType, + IntroContent: req.IntroContent, + CategoryID: req.CategoryID, + IsRecommended: req.IsRecommended, + SectionCount: 0, // 不更新 section_count,由系统自动维护 + } + + err := s.campDAO.Update(campObj) + if err != nil { + return &camp.UpdateCampResponse{ + Success: false, + Message: fmt.Sprintf("更新打卡营失败: %v", err), + }, nil + } + + return &camp.UpdateCampResponse{ + Success: true, + Message: "更新打卡营成功", + }, nil +} + +// DeleteCamp 删除打卡营 +func (s *CampService) DeleteCamp(id string) (*camp.DeleteCampResponse, error) { + // TODO: 检查是否存在未删除的小节、任务和用户进度 + // 暂时先允许删除,后续可以添加检查逻辑 + + err := s.campDAO.Delete(id) + if err != nil { + return &camp.DeleteCampResponse{ + Success: false, + Message: fmt.Sprintf("删除打卡营失败: %v", err), + }, nil + } + + return &camp.DeleteCampResponse{ + Success: true, + Message: "删除打卡营成功", + }, nil +} + +// ListCamps 列出打卡营(支持搜索和筛选;joined_only=1 时仅返回用户已加入的营) +func (s *CampService) ListCamps(req *camp.ListCampsRequest) (*camp.ListCampsResponse, error) { + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + + // 仅已加入:根据 user_id 查用户已加入的营,再补全营详情 + if req.JoinedOnly == 1 && req.UserID != "" && s.userCampDAO != nil { + joined, total, err := s.userCampDAO.ListUserJoinedCamps(req.UserID, req.Page, req.PageSize) + if err != nil { + return &camp.ListCampsResponse{ + Success: false, + Message: fmt.Sprintf("获取已加入打卡营列表失败: %v", err), + }, nil + } + camps := make([]*camp.Camp, 0, len(joined)) + trueVal := true + for _, uc := range joined { + c, err := s.campDAO.GetByID(uc.CampID) + if err != nil || c == nil { + continue + } + c.IsJoined = &trueVal + camps = append(camps, c) + } + // 按 camp_sections 实时统计小节数 + if len(camps) > 0 && s.sectionDAO != nil { + campIDs := make([]string, 0, len(camps)) + for _, c := range camps { + campIDs = append(campIDs, c.ID) + } + if countMap, err := s.sectionDAO.CountByCampIDs(campIDs); err == nil { + for _, c := range camps { + if n, ok := countMap[c.ID]; ok { + c.SectionCount = int32(n) + } + } + } + } + return &camp.ListCampsResponse{ + Camps: camps, + Total: total, + Success: true, + Message: "获取打卡营列表成功", + }, nil + } + + var isRecommended *bool + switch req.RecommendFilter { + case camp.RecommendFilterOnlyTrue: + trueVal := true + isRecommended = &trueVal + case camp.RecommendFilterOnlyFalse: + falseVal := false + isRecommended = &falseVal + case camp.RecommendFilterAll: + isRecommended = nil + } + + camps, total, err := s.campDAO.Search(req.Keyword, req.CategoryID, isRecommended, req.Page, req.PageSize) + if err != nil { + return &camp.ListCampsResponse{ + Success: false, + Message: fmt.Sprintf("获取打卡营列表失败: %v", err), + }, nil + } + + // 按 camp_sections 实时统计小节数,覆盖表中的 section_count,保证列表展示正确 + if len(camps) > 0 && s.sectionDAO != nil { + campIDs := make([]string, 0, len(camps)) + for _, c := range camps { + campIDs = append(campIDs, c.ID) + } + if countMap, err := s.sectionDAO.CountByCampIDs(campIDs); err == nil { + for _, c := range camps { + if n, ok := countMap[c.ID]; ok { + c.SectionCount = int32(n) + } + } + } + } + + // 当请求带了 user_id 时,为每个营填充是否已加入 + if req.UserID != "" && s.userCampDAO != nil && len(camps) > 0 { + for _, c := range camps { + isJoined, _, _, _ := s.userCampDAO.CheckUserCampStatus(req.UserID, c.ID) + c.IsJoined = &isJoined + } + } + + return &camp.ListCampsResponse{ + Camps: camps, + Total: total, + Success: true, + Message: "获取打卡营列表成功", + }, nil +} + +// GetCampDetailWithStatus 获取打卡营详情及状态(聚合多个数据源) +func (s *CampService) GetCampDetailWithStatus(ctx context.Context, req *camp.GetCampDetailWithStatusRequest) (*camp.GetCampDetailWithStatusResponse, error) { + if req.CampID == "" || req.UserID == "" { + return &camp.GetCampDetailWithStatusResponse{ + Success: false, + Message: "参数缺失", + }, nil + } + + // 检查依赖是否已设置 + if s.sectionDAO == nil || s.taskDAO == nil || s.progressDAO == nil || s.userCampDAO == nil || s.orderDAO == nil { + return &camp.GetCampDetailWithStatusResponse{ + Success: false, + Message: "服务依赖未初始化", + }, nil + } + + // 使用 WaitGroup 进行并发控制(Go 1.25 语法) + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + + // 定义结果变量 + var campObj *camp.Camp + var sections []*camp.Section + var isJoined bool + var joinedAt string + var currentSectionID string + var purchasedSectionIDs map[string]bool + var progressList []*camp.UserProgress + + // 1. 并发获取基础数据 + // 获取打卡营详情 + wg.Go(func() { + campResp, err := s.GetCamp(req.CampID) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("获取打卡营详情失败: %v", err) + } + return + } + if !campResp.Success { + if firstErr == nil { + firstErr = fmt.Errorf("获取打卡营详情失败: %s", campResp.Message) + } + return + } + campObj = campResp.Camp + }) + + // 获取小节列表 + wg.Go(func() { + sectionListReq := &camp.ListSectionsRequest{ + CampID: req.CampID, + Page: 1, + PageSize: 1000, + } + sectionListResp, err := s.listSections(sectionListReq) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("获取小节列表失败: %v", err) + } + return + } + if !sectionListResp.Success { + if firstErr == nil { + firstErr = fmt.Errorf("获取小节列表失败: %s", sectionListResp.Message) + } + return + } + sections = sectionListResp.Sections + }) + + // 检查用户状态 + wg.Go(func() { + statusResp, err := s.checkUserCampStatus(req.UserID, req.CampID) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("检查用户状态失败: %v", err) + } + return + } + if !statusResp.Success { + if firstErr == nil { + firstErr = fmt.Errorf("检查用户状态失败: %s", statusResp.Message) + } + return + } + isJoined = statusResp.IsJoined + joinedAt = statusResp.JoinedAt + currentSectionID = statusResp.CurrentSectionID + }) + + // 获取已购买的小节列表(通过订单查询) + wg.Go(func() { + orders, _, err := s.orderDAO.ListOrders(req.UserID, req.CampID, "", order.OrderStatusPaid, order.PaymentMethodUnknown, 1, 1000) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("获取订单列表失败: %v", err) + } + return + } + purchasedSectionIDs = make(map[string]bool) + for _, ord := range orders { + // 新的 Order 结构体使用 Status 字段,类型为 OrderStatus (string) + if ord.Status == order.OrderStatusPaid { + purchasedSectionIDs[ord.SectionID] = true + } + } + }) + + // 获取进度列表(不在这里计算小节进度,等 sections 获取完成后再计算) + wg.Go(func() { + list, _, err := s.progressDAO.List(req.UserID, "", "", "", req.CampID, "", 1, 1000) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("获取进度列表失败: %v", err) + } + return + } + progressList = list + }) + + // 等待所有基础数据获取完成 + wg.Wait() + + // 检查是否有错误 + mu.Lock() + err := firstErr + mu.Unlock() + if err != nil { + return &camp.GetCampDetailWithStatusResponse{ + Success: false, + Message: err.Error(), + }, nil + } + + // 检查必要数据是否存在 + if campObj == nil || sections == nil { + return &camp.GetCampDetailWithStatusResponse{ + Success: false, + Message: "获取数据不完整", + }, nil + } + + // 初始化映射 + if purchasedSectionIDs == nil { + purchasedSectionIDs = make(map[string]bool) + } + + // 构建进度映射,方便快速查找任务进度 + progressMap := make(map[string]*camp.UserProgress) + for _, prog := range progressList { + progressMap[prog.TaskID] = prog + } + + // 处理每个小节,获取任务列表和进度 + aggregatedSections := make([]*camp.AggregatedSectionDetail, 0, len(sections)) + for _, section := range sections { + sectionID := section.ID + + // 判断是否已购买 + isPurchased := purchasedSectionIDs[sectionID] + + // 获取任务列表(循环获取所有任务,避免分页限制) + // 先获取准确的任务总数 + totalCount, err := s.taskDAO.CountActiveBySection(sectionID) + if err != nil { + totalCount = 0 + } + + allTasks := make([]*camp.Task, 0) + page := 1 + pageSize := 1000 + for { + tasks, _, err := s.taskDAO.List("", "", sectionID, camp.TaskTypeUnknown, page, pageSize) + if err != nil { + // 查询出错,退出循环 + break + } + + // 添加任务到列表 + allTasks = append(allTasks, tasks...) + + // 如果已经获取了所有任务,退出循环 + if len(allTasks) >= totalCount || len(tasks) < pageSize { + break + } + page++ + } + tasks := allTasks + + // 处理任务列表,获取每个任务的进度,同时统计完成情况 + aggregatedTasks := make([]*camp.AggregatedTaskDetail, 0, len(tasks)) + totalTasks := int32(len(tasks)) + completedTasks := int32(0) + + for _, task := range tasks { + // 判断是否需要审核(主观题和申论题需要审核) + needReview := task.TaskType == camp.TaskTypeSubjective || task.TaskType == camp.TaskTypeEssay + + // 获取任务进度(优先从进度映射中获取,避免重复查询) + var taskProgress *camp.UserProgress + if prog, ok := progressMap[task.ID]; ok { + taskProgress = prog + } else { + taskProgress, _ = s.progressDAO.GetByUserAndTask(req.UserID, task.ID) + // 缓存到映射中 + if taskProgress != nil { + progressMap[task.ID] = taskProgress + } + } + + // 确定任务状态 + status := "NotStarted" + reviewStatus := "" + isTaskCompleted := false + if taskProgress != nil { + if taskProgress.IsCompleted { + // 客观题:进度表已按“正确率达标即完成、达标后永久完成”维护,直接信任 is_completed + if task.TaskType == camp.TaskTypeObjective { + status = "Completed" + isTaskCompleted = true + } else if needReview { + reviewStatus = string(taskProgress.ReviewStatus) + switch taskProgress.ReviewStatus { + case camp.ReviewStatusApproved: + status = "Completed" + isTaskCompleted = true + case camp.ReviewStatusRejected: + status = "Rejected" + default: + status = "Reviewing" + } + } else { + status = "Completed" + isTaskCompleted = true + } + } else { + status = "InProgress" + } + } + + // 统计已完成任务数 + if isTaskCompleted { + completedTasks++ + } + + // 生成任务标题 + taskTitle := s.getTaskTitle(task) + + allowNextWhileReviewing := parseAllowNextWhileReviewing(task.Condition) + prereqID := task.PrerequisiteTaskID + prerequisites := []string{} + if prereqID != "" { + prerequisites = []string{prereqID} + } + aggregatedTask := &camp.AggregatedTaskDetail{ + ID: task.ID, + TaskType: task.TaskType, + Title: taskTitle, + Status: status, + NeedReview: needReview, + AllowNextWhileReviewing: allowNextWhileReviewing, + ReviewStatus: reviewStatus, + Progress: taskProgress, + PrerequisiteTaskID: prereqID, + Prerequisites: prerequisites, + } + aggregatedTasks = append(aggregatedTasks, aggregatedTask) + } + + // 按解锁关系计算 CanStart:仅看后台配置的前置任务;未配置 prerequisite_task_id 时默认都可开始(不按 1→2→3 顺序) + taskIDToAgg := make(map[string]*camp.AggregatedTaskDetail) + for _, at := range aggregatedTasks { + taskIDToAgg[at.ID] = at + } + for i, at := range aggregatedTasks { + task := tasks[i] + if task.PrerequisiteTaskID == "" { + // 未配置前置任务:默认可直接做,不按列表顺序 + at.CanStart = true + } else { + // 配置了前置任务:必须前置任务完成(且需审核时已通过)才可开始 + prereq, ok := taskIDToAgg[task.PrerequisiteTaskID] + if !ok { + at.CanStart = false + continue + } + if prereq.Status == "Completed" { + at.CanStart = true + } else if prereq.Status == "Reviewing" && prereq.NeedReview && prereq.AllowNextWhileReviewing { + at.CanStart = true + } else { + at.CanStart = false + } + } + } + + // 计算小节进度 + isCompleted := totalTasks > 0 && completedTasks == totalTasks + // 已开始:如果有已完成的任务,或者已购买(表示用户已经开始使用) + isStarted := completedTasks > 0 || isPurchased + sectionProgress := &camp.UserSectionProgress{ + SectionID: sectionID, + TotalTasks: totalTasks, + CompletedTasks: completedTasks, + IsCompleted: isCompleted, + } + + aggregatedSection := &camp.AggregatedSectionDetail{ + ID: sectionID, + Title: section.Title, + SectionNumber: section.SectionNumber, + PriceFen: section.PriceFen, + IsPurchased: isPurchased, + IsStarted: isStarted, + IsCompleted: isCompleted, + IsCurrent: sectionID == currentSectionID, + RequirePreviousSection: section.RequirePreviousSection, + TimeIntervalType: section.TimeIntervalType, + TimeIntervalValue: section.TimeIntervalValue, + Tasks: aggregatedTasks, + SectionProgress: sectionProgress, + } + aggregatedSections = append(aggregatedSections, aggregatedSection) + } + + // 计算打卡营状态 + campStatus := camp.CampStatusNotStarted + if isJoined { + allSectionsCompleted := true + hasStartedTask := false + hasAnyTask := false + + for _, section := range aggregatedSections { + if section.SectionProgress != nil { + totalTasks := section.SectionProgress.TotalTasks + completedTasks := section.SectionProgress.CompletedTasks + + if totalTasks > 0 { + hasAnyTask = true + if completedTasks > 0 { + hasStartedTask = true + } + if completedTasks != totalTasks { + allSectionsCompleted = false + } + } + } + } + + if hasAnyTask { + if allSectionsCompleted { + campStatus = camp.CampStatusCompleted + } else if hasStartedTask { + campStatus = camp.CampStatusInProgress + } + } else { + campStatus = camp.CampStatusCompleted + } + } + + // 复制 Camp 对象,不返回 deleted_at + campCopy := &camp.Camp{ + ID: campObj.ID, + Title: campObj.Title, + CoverImage: campObj.CoverImage, + Description: campObj.Description, + IntroType: campObj.IntroType, + IntroContent: campObj.IntroContent, + CategoryID: campObj.CategoryID, + IsRecommended: campObj.IsRecommended, + SectionCount: campObj.SectionCount, + DeletedAt: "", // 不返回 deleted_at + } + + // 构建用户与打卡营的关系 + userCamp := &camp.UserCamp{ + IsJoined: isJoined, + JoinedAt: joinedAt, + CurrentSectionID: currentSectionID, + CampStatus: campStatus, + } + + // 构建打卡营详情 + campDetail := &camp.CampDetail{ + Camp: campCopy, + UserCamp: userCamp, + } + + return &camp.GetCampDetailWithStatusResponse{ + CampDetail: campDetail, + Sections: aggregatedSections, + Success: true, + Message: "获取打卡营详情成功", + }, nil +} + +// 辅助方法:获取小节列表(内部使用) +func (s *CampService) listSections(req *camp.ListSectionsRequest) (*camp.ListSectionsResponse, error) { + if s.sectionDAO == nil { + return &camp.ListSectionsResponse{ + Success: false, + Message: "sectionDAO 未初始化", + }, nil + } + // 设置默认值 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + if req.PageSize > 1000 { + req.PageSize = 1000 + } + + sections, total, err := s.sectionDAO.List(req.Keyword, req.CampID, req.Page, req.PageSize) + if err != nil { + return &camp.ListSectionsResponse{ + Success: false, + Message: fmt.Sprintf("获取小节列表失败: %v", err), + }, nil + } + + return &camp.ListSectionsResponse{ + Sections: sections, + Total: total, + Success: true, + Message: "获取小节列表成功", + }, nil +} + +// 辅助方法:检查用户打卡营状态 +func (s *CampService) checkUserCampStatus(userID, campID string) (*camp.CheckUserCampStatusResponse, error) { + if s.userCampDAO == nil { + return &camp.CheckUserCampStatusResponse{ + Success: false, + Message: "userCampDAO 未初始化", + }, nil + } + isJoined, joinedAt, currentSectionID, err := s.userCampDAO.CheckUserCampStatus(userID, campID) + if err != nil { + return &camp.CheckUserCampStatusResponse{ + Success: false, + Message: fmt.Sprintf("查询失败: %v", err), + IsJoined: false, + }, nil + } + + // 格式化时间 + formattedJoinedAt := utils.FormatNullTimeToStd(joinedAt) + + var currentSectionIDStr string + if currentSectionID.Valid { + currentSectionIDStr = currentSectionID.String + } + + return &camp.CheckUserCampStatusResponse{ + Success: true, + Message: "查询成功", + IsJoined: isJoined, + JoinedAt: formattedJoinedAt, + CurrentSectionID: currentSectionIDStr, + }, nil +} + +// CanUnlockSection 检查用户是否可开启指定小节(查询数据库:上一小节是否完成、是否已拥有等) +func (s *CampService) CanUnlockSection(req *camp.CanUnlockSectionRequest) (*camp.CanUnlockSectionResponse, error) { + if req.CampID == "" || req.SectionID == "" || req.UserID == "" { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "参数不完整", + }, nil + } + if s.sectionDAO == nil { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "服务未就绪", + }, nil + } + + section, err := s.sectionDAO.GetByID(req.SectionID) + if err != nil || section == nil { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "小节不存在", + }, nil + } + if section.CampID != req.CampID { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "小节不属于该打卡营", + }, nil + } + + // 已拥有该小节时,仍须校验时间间隔(未到间隔则不可视为“可进入”,不推进 current_section_id) + owned := false + if s.orderDAO != nil { + owned, _ = s.orderDAO.CheckUserHasSection(req.UserID, req.SectionID) + } + + // 判断是否需要完成上一小节 + sections, _, err := s.sectionDAO.List("", req.CampID, 1, 1000) + if err != nil || len(sections) == 0 { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: true, + Message: "", + }, nil + } + + var prevSection *camp.Section + for i, sec := range sections { + if sec.ID == req.SectionID { + if i > 0 { + prevSection = sections[i-1] + } + break + } + } + + // 时间间隔限制:只要本节配置了间隔且存在上一节,就必须校验「上一节已完成 + 间隔已过」,与 require_previous_section 无关(收费类型不校验时间,由支付逻辑控制) + if section.TimeIntervalType != camp.TimeIntervalTypeNone && section.TimeIntervalType != camp.TimeIntervalTypePaid && section.TimeIntervalValue > 0 && prevSection != nil { + totalTasks := 0 + if s.taskDAO != nil { + totalTasks, _ = s.taskDAO.CountActiveBySection(prevSection.ID) + } + if totalTasks > 0 { + completedTasks := 0 + if s.progressDAO != nil { + completedTasks, _ = s.progressDAO.CountCompletedBySection(req.UserID, prevSection.ID) + } + if completedTasks < totalTasks { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "请先完成上一小节", + }, nil + } + } + if s.progressDAO == nil { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "无法获取上一小节开启时间,请稍后再试", + }, nil + } + // 时间间隔起点:从上一小节「开启时」算起(首次产生进度的 created_at),若无则用完成时间兜底 + intervalStart, _ := s.progressDAO.GetUserSectionStartedAt(req.UserID, prevSection.ID) + if intervalStart == nil { + intervalStart, _ = s.progressDAO.GetUserSectionCompletedAt(req.UserID, prevSection.ID) + } + if intervalStart == nil { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "无法获取上一小节开启时间,请稍后再试", + }, nil + } + now := time.Now() + switch section.TimeIntervalType { + case camp.TimeIntervalTypeHour: + unlockAt := intervalStart.Add(time.Duration(section.TimeIntervalValue) * time.Hour) + if now.Before(unlockAt) { + msg := fmt.Sprintf("需在上一小节开启后 %d 小时才能解锁本小节", section.TimeIntervalValue) + hours := int(time.Until(unlockAt).Hours()) + mins := int(time.Until(unlockAt).Minutes()) % 60 + if hours > 0 || mins > 0 { + msg = fmt.Sprintf("%s,请 %d 小时 %d 分钟后再试", msg, hours, mins) + } + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: msg, + UnlockAt: unlockAt.Unix(), + }, nil + } + case camp.TimeIntervalTypeNaturalDay: + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = intervalStart.Location() + } + prevInLoc := intervalStart.In(loc) + prevDay := time.Date(prevInLoc.Year(), prevInLoc.Month(), prevInLoc.Day(), 0, 0, 0, 0, loc) + unlockDay := prevDay.AddDate(0, 0, int(section.TimeIntervalValue)) + nowInLoc := now.In(loc) + if nowInLoc.Before(unlockDay) { + msg := fmt.Sprintf("需在上一小节开启后 %d 个自然日才能解锁本小节", section.TimeIntervalValue) + if section.TimeIntervalValue == 1 { + msg = "需在上一小节开启的次日才能解锁本小节" + } + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: msg, + UnlockAt: unlockDay.Unix(), + }, nil + } + } + // 时间间隔已通过,若已拥有则直接返回可解锁 + if owned { + return &camp.CanUnlockSectionResponse{Success: true, CanUnlock: true, Message: "已拥有"}, nil + } + } + + // 无时间间隔或时间间隔已通过:若不需要完成上一小节,则允许解锁 + needPrev := section.RequirePreviousSection + if !needPrev || prevSection == nil { + if owned { + return &camp.CanUnlockSectionResponse{Success: true, CanUnlock: true, Message: "已拥有"}, nil + } + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: true, + Message: "", + }, nil + } + + // 上一小节任务总数与已完成数 + totalTasks := 0 + if s.taskDAO != nil { + totalTasks, err = s.taskDAO.CountActiveBySection(prevSection.ID) + if err != nil { + totalTasks = 0 + } + } + if totalTasks == 0 { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: true, + Message: "", + }, nil + } + + completedTasks := 0 + if s.progressDAO != nil { + completedTasks, err = s.progressDAO.CountCompletedBySection(req.UserID, prevSection.ID) + if err != nil { + completedTasks = 0 + } + } + + if completedTasks < totalTasks { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "请先完成上一小节", + }, nil + } + + // 时间/自然天限制:起点从上一节「开启时」算起(首次产生进度的 created_at),若无则用完成时间兜底(收费类型不校验时间) + if section.TimeIntervalType != camp.TimeIntervalTypeNone && section.TimeIntervalType != camp.TimeIntervalTypePaid && section.TimeIntervalValue > 0 { + var intervalStart *time.Time + if s.progressDAO != nil { + intervalStart, _ = s.progressDAO.GetUserSectionStartedAt(req.UserID, prevSection.ID) + if intervalStart == nil { + intervalStart, _ = s.progressDAO.GetUserSectionCompletedAt(req.UserID, prevSection.ID) + } + } + if intervalStart == nil { + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: "无法获取上一小节开启时间,请稍后再试", + }, nil + } + now := time.Now() + switch section.TimeIntervalType { + case camp.TimeIntervalTypeHour: + unlockAt := intervalStart.Add(time.Duration(section.TimeIntervalValue) * time.Hour) + if now.Before(unlockAt) { + left := time.Until(unlockAt) + hours := int(left.Hours()) + mins := int(left.Minutes()) % 60 + msg := fmt.Sprintf("需在上一小节开启后 %d 小时才能解锁本小节", section.TimeIntervalValue) + if hours > 0 || mins > 0 { + msg = fmt.Sprintf("%s,请 %d 小时 %d 分钟后再试", msg, hours, mins) + } + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: msg, + UnlockAt: unlockAt.Unix(), + }, nil + } + case camp.TimeIntervalTypeNaturalDay: + // 自然日按中国时区计算,使「次日」为北京时间次日 0 点 + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = intervalStart.Location() + } + prevOpenInLoc := intervalStart.In(loc) + prevDay := time.Date(prevOpenInLoc.Year(), prevOpenInLoc.Month(), prevOpenInLoc.Day(), 0, 0, 0, 0, loc) + unlockDay := prevDay.AddDate(0, 0, int(section.TimeIntervalValue)) + nowInLoc := now.In(loc) + if nowInLoc.Before(unlockDay) { + msg := fmt.Sprintf("需在上一小节开启后 %d 个自然日才能解锁本小节", section.TimeIntervalValue) + if section.TimeIntervalValue == 1 { + msg = "需在上一小节开启的次日才能解锁本小节" + } + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: false, + Message: msg, + UnlockAt: unlockDay.Unix(), + }, nil + } + } + } + + return &camp.CanUnlockSectionResponse{ + Success: true, + CanUnlock: true, + Message: "", + }, nil +} + +// TryAutoOpenNextSectionIfEligible 在「当前小节全部任务已完成」时,若下一小节无限制且免费则自动开启(创建 0 元订单并更新 current_section_id) +// 供 ProgressService 在任务完成时调用,无需前端再请求 can_unlock_section + purchase_section +func (s *CampService) TryAutoOpenNextSectionIfEligible(userID, campID, completedSectionID string) { + if userID == "" || campID == "" || completedSectionID == "" { + return + } + if s.sectionDAO == nil || s.taskDAO == nil || s.progressDAO == nil || s.orderDAO == nil { + return + } + + section, err := s.sectionDAO.GetByID(completedSectionID) + if err != nil || section == nil || section.CampID != campID { + return + } + + totalTasks, err := s.taskDAO.CountActiveBySection(completedSectionID) + if err != nil || totalTasks == 0 { + return + } + completedTasks, err := s.progressDAO.CountCompletedBySection(userID, completedSectionID) + if err != nil || completedTasks < totalTasks { + return + } + + sections, _, err := s.sectionDAO.List("", campID, 1, 1000) + if err != nil || len(sections) == 0 { + return + } + var nextSection *camp.Section + for i, sec := range sections { + if sec.ID == completedSectionID { + if i+1 < len(sections) { + nextSection = sections[i+1] + } + break + } + } + if nextSection == nil { + return + } + + // 硬性时间间隔校验:下一节若配置了间隔,必须「上一节开启时间 + 间隔」已过才允许自动开启;收费类型不自动开启,需用户主动购买 + if nextSection.TimeIntervalType == camp.TimeIntervalTypePaid { + return + } + if nextSection.TimeIntervalType != camp.TimeIntervalTypeNone && nextSection.TimeIntervalValue > 0 { + intervalStart, err := s.progressDAO.GetUserSectionStartedAt(userID, completedSectionID) + if err != nil || intervalStart == nil { + intervalStart, err = s.progressDAO.GetUserSectionCompletedAt(userID, completedSectionID) + } + if err != nil || intervalStart == nil { + return // 拿不到开启/完成时间则不自动开启 + } + now := time.Now() + switch nextSection.TimeIntervalType { + case camp.TimeIntervalTypeHour: + unlockAt := intervalStart.Add(time.Duration(nextSection.TimeIntervalValue) * time.Hour) + if now.Before(unlockAt) { + return // 未到解锁时间,不创建订单、不更新 current_section_id + } + case camp.TimeIntervalTypeNaturalDay: + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = intervalStart.Location() + } + prevDay := time.Date(intervalStart.In(loc).Year(), intervalStart.In(loc).Month(), intervalStart.In(loc).Day(), 0, 0, 0, 0, loc) + unlockDay := prevDay.AddDate(0, 0, int(nextSection.TimeIntervalValue)) + if now.In(loc).Before(unlockDay) { + return + } + default: + // 其它类型不自动开启 + return + } + } + + owned, err := s.orderDAO.CheckUserHasSection(userID, nextSection.ID) + if err == nil && owned { + // 下一小节已拥有(如重启后):仍须通过 CanUnlockSection 校验(时间/自然日等限制),通过后再推进 current_section_id + resp, err := s.CanUnlockSection(&camp.CanUnlockSectionRequest{ + UserID: userID, + CampID: campID, + SectionID: nextSection.ID, + }) + if err == nil && resp != nil && resp.CanUnlock && s.userCampDAO != nil { + _ = s.userCampDAO.UpdateCurrentSection(userID, campID, nextSection.ID) + } + return + } + if nextSection.PriceFen > 0 { + return + } + + resp, err := s.CanUnlockSection(&camp.CanUnlockSectionRequest{ + UserID: userID, + CampID: campID, + SectionID: nextSection.ID, + }) + if err != nil || resp == nil || !resp.CanUnlock { + return + } + + if s.orderService == nil { + return + } + _, _ = s.orderService.CreateOrder(context.Background(), &order.CreateOrderRequest{ + UserID: userID, + CampID: campID, + SectionID: nextSection.ID, + }) +} + +// parseAllowNextWhileReviewing 从任务 condition JSON 中解析 allow_next_while_reviewing(审核中是否允许开启下一任务),默认 true。 +// 主观题已从管理端去掉该选项,不再下发该字段,缺省视为 true;申论题仍可配置。 +func parseAllowNextWhileReviewing(condition json.RawMessage) bool { + if len(condition) == 0 { + return true + } + var raw map[string]any + if err := json.Unmarshal(condition, &raw); err != nil { + return true + } + // 根级别 + if val, ok := raw["allow_next_while_reviewing"]; ok { + if b, ok := val.(bool); ok { + return b + } + } + // subjective + if sub, ok := raw["subjective"].(map[string]any); ok { + if val, ok := sub["allow_next_while_reviewing"]; ok { + if b, ok := val.(bool); ok { + return b + } + } + } + // essay + if essay, ok := raw["essay"].(map[string]any); ok { + if val, ok := essay["allow_next_while_reviewing"]; ok { + if b, ok := val.(bool); ok { + return b + } + } + } + return true +} + +// getTaskTitle 获取任务标题(优先使用任务标题字段,否则按类型生成) +func (s *CampService) getTaskTitle(task *camp.Task) string { + if task != nil && strings.TrimSpace(task.Title) != "" { + return strings.TrimSpace(task.Title) + } + if task == nil { + return "未知任务" + } + switch task.TaskType { + case camp.TaskTypeImageText: + return "图文任务" + case camp.TaskTypeVideo: + return "视频任务" + case camp.TaskTypeSubjective: + return "主观题任务" + case camp.TaskTypeObjective: + return "客观题任务" + case camp.TaskTypeEssay: + return "申论题任务" + default: + return "未知任务" + } +} + +// CanStartTask 检查用户是否可以开始指定任务(前置任务是否已完成) +func (s *CampService) CanStartTask(req *camp.CanStartTaskRequest) (*camp.CanStartTaskResponse, error) { + if req.UserID == "" || req.TaskID == "" { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: false, + Reason: "参数不完整", + Message: "参数不完整", + }, nil + } + + // 获取目标任务信息 + task, err := s.taskDAO.GetByID(req.TaskID) + if err != nil || task == nil { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: false, + Reason: "任务不存在", + Message: "任务不存在", + }, nil + } + + // 获取同一小节下所有任务(按 id 升序) + allTasks, _, err := s.taskDAO.List("", "", task.SectionID, camp.TaskTypeUnknown, 1, 1000) + if err != nil { + return &camp.CanStartTaskResponse{ + Success: false, + Message: fmt.Sprintf("获取任务列表失败: %v", err), + }, nil + } + + // 找到目标任务在列表中的位置 + targetIndex := -1 + for i, t := range allTasks { + if t.ID == req.TaskID { + targetIndex = i + break + } + } + + if targetIndex == -1 { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: false, + Reason: "任务不在当前小节中", + Message: "任务不在当前小节中", + }, nil + } + + // 第一个任务或未设置前置任务时,按“上一任务”顺序检查 + if targetIndex == 0 { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: true, + Message: "第一个任务,可以开始", + }, nil + } + + // 若配置了前置任务(解锁关系),则必须完成该前置任务后才能开始 + if task.PrerequisiteTaskID != "" { + prevProgress, _ := s.progressDAO.GetByUserAndTask(req.UserID, task.PrerequisiteTaskID) + if prevProgress == nil { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: false, + Reason: "请先完成前置任务", + Message: "请先完成前置任务", + }, nil + } + if !prevProgress.IsCompleted { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: false, + Reason: "请先完成前置任务", + Message: "请先完成前置任务", + }, nil + } + // 若前置任务需要审核,则需审核通过后才算“完成” + prevTask, _ := s.taskDAO.GetByID(task.PrerequisiteTaskID) + if prevTask != nil && (prevTask.TaskType == camp.TaskTypeSubjective || prevTask.TaskType == camp.TaskTypeEssay) { + if prevProgress.ReviewStatus != camp.ReviewStatusApproved && prevProgress.ReviewStatus != camp.ReviewStatusRejected { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: false, + Reason: "前置任务审核中,请等待审核完成后继续", + Message: "前置任务审核中,请等待审核完成后继续", + }, nil + } + if prevProgress.ReviewStatus == camp.ReviewStatusRejected { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: false, + Reason: "请先完成前置任务", + Message: "请先完成前置任务", + }, nil + } + } + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: true, + Message: "可以开始", + }, nil + } + + // 未配置前置任务:默认可直接做,不按列表顺序要求上一任务 + if task.PrerequisiteTaskID == "" { + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: true, + Message: "可以开始", + }, nil + } + + return &camp.CanStartTaskResponse{ + Success: true, + CanStart: true, + Message: "可以开始", + }, nil +} diff --git a/internal/camp/service/category_service.go b/internal/camp/service/category_service.go new file mode 100644 index 0000000..b758545 --- /dev/null +++ b/internal/camp/service/category_service.go @@ -0,0 +1,150 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/pkg/snowflake" +) + +// CategoryService 分类服务 +type CategoryService struct { + categoryDAO *dao.CategoryDAO + campDAO *dao.CampDAO +} + +// NewCategoryService 创建分类服务 +func NewCategoryService(categoryDAO *dao.CategoryDAO, campDAO *dao.CampDAO) *CategoryService { + return &CategoryService{ + categoryDAO: categoryDAO, + campDAO: campDAO, + } +} + +// CreateCategory 创建分类 +func (s *CategoryService) CreateCategory(req *camp.CreateCategoryRequest) (*camp.CreateCategoryResponse, error) { + categoryID := snowflake.GetInstance().NextIDString() + + category := &camp.Category{ + ID: categoryID, + Name: req.Name, + SortOrder: req.SortOrder, + } + + err := s.categoryDAO.Create(category) + if err != nil { + return &camp.CreateCategoryResponse{ + Success: false, + Message: fmt.Sprintf("创建分类失败: %v", err), + }, nil + } + + return &camp.CreateCategoryResponse{ + ID: categoryID, + Success: true, + Message: "创建分类成功", + }, nil +} + +// GetCategory 获取分类 +func (s *CategoryService) GetCategory(id string) (*camp.GetCategoryResponse, error) { + category, err := s.categoryDAO.GetByID(id) + if err != nil { + return &camp.GetCategoryResponse{ + Success: false, + Message: fmt.Sprintf("获取分类失败: %v", err), + }, nil + } + + return &camp.GetCategoryResponse{ + Category: category, + Success: true, + Message: "获取分类成功", + }, nil +} + +// UpdateCategory 更新分类 +func (s *CategoryService) UpdateCategory(req *camp.UpdateCategoryRequest) (*camp.UpdateCategoryResponse, error) { + category := &camp.Category{ + ID: req.ID, + Name: req.Name, + SortOrder: req.SortOrder, + } + + err := s.categoryDAO.Update(category) + if err != nil { + return &camp.UpdateCategoryResponse{ + Success: false, + Message: fmt.Sprintf("更新分类失败: %v", err), + }, nil + } + + return &camp.UpdateCategoryResponse{ + Success: true, + Message: "更新分类成功", + }, nil +} + +// DeleteCategory 删除分类 +func (s *CategoryService) DeleteCategory(id string) (*camp.DeleteCategoryResponse, error) { + // 先检查该分类下是否有打卡营,有则不允许删除 + if s.campDAO != nil { + count, err := s.campDAO.CountByCategoryID(id) + if err != nil { + return &camp.DeleteCategoryResponse{ + Success: false, + Message: fmt.Sprintf("检查分类下打卡营失败: %v", err), + }, nil + } + if count > 0 { + return &camp.DeleteCategoryResponse{ + Success: false, + Message: "该分类下存在打卡营,无法删除;请先将打卡营移出该分类或删除后再试", + }, nil + } + } + + err := s.categoryDAO.Delete(id) + if err != nil { + return &camp.DeleteCategoryResponse{ + Success: false, + Message: fmt.Sprintf("删除分类失败: %v", err), + }, nil + } + + return &camp.DeleteCategoryResponse{ + Success: true, + Message: "删除分类成功", + }, nil +} + +// ListCategories 列出分类(支持关键词搜索) +func (s *CategoryService) ListCategories(req *camp.ListCategoriesRequest) (*camp.ListCategoriesResponse, error) { + // 设置默认值 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + + categories, total, err := s.categoryDAO.List(req.Keyword, req.Page, req.PageSize) + if err != nil { + return &camp.ListCategoriesResponse{ + Success: false, + Message: fmt.Sprintf("获取分类列表失败: %v", err), + }, nil + } + + return &camp.ListCategoriesResponse{ + Categories: categories, + Total: total, + Success: true, + Message: "获取分类列表成功", + }, nil +} + diff --git a/internal/camp/service/progress_service.go b/internal/camp/service/progress_service.go new file mode 100644 index 0000000..0ce67f7 --- /dev/null +++ b/internal/camp/service/progress_service.go @@ -0,0 +1,511 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/dao" + question_service "dd_fiber_api/internal/question/service" + "dd_fiber_api/pkg/snowflake" +) + +// ProgressService 用户进度服务 +type ProgressService struct { + progressDAO *dao.ProgressDAO + taskDAO *dao.TaskDAO + userCampDAO *dao.UserCampDAO + answerRecordService *question_service.AnswerRecordService + campService *CampService +} + +// NewProgressService 创建用户进度服务 +func NewProgressService(progressDAO *dao.ProgressDAO, taskDAO *dao.TaskDAO, userCampDAO *dao.UserCampDAO, answerRecordService *question_service.AnswerRecordService, campService *CampService) *ProgressService { + return &ProgressService{ + progressDAO: progressDAO, + taskDAO: taskDAO, + userCampDAO: userCampDAO, + answerRecordService: answerRecordService, + campService: campService, + } +} + +// UpdateUserProgress 更新用户进度 +func (s *ProgressService) UpdateUserProgress(req *camp.UpdateUserProgressRequest) (*camp.UpdateUserProgressResponse, error) { + // 读取现有记录用于字段合并,避免未提供字段被清空 + var existing *camp.UserProgress + if prev, err := s.progressDAO.GetByUserAndTask(req.UserID, req.TaskID); err == nil { + existing = prev + } + + // 获取任务信息(用于后续判断完成状态) + task, err := s.taskDAO.GetByID(req.TaskID) + if err != nil { + return &camp.UpdateUserProgressResponse{ + Success: false, + Message: fmt.Sprintf("获取任务信息失败: %v", err), + }, nil + } + + // 申论题:支持「暂存」——允许部分题目提交,与已有进度合并;仅当全部题目都有答案时才允许完成 + var essayMerged [][]string + var essayAllFilled bool + if task.TaskType == camp.TaskTypeEssay && len(req.EssayAnswerImages) > 0 { + expectCount := req.EssayQuestionCount + if expectCount <= 0 { + expectCount = len(req.EssayAnswerImages) + } + if expectCount <= 0 && existing != nil && len(existing.EssayAnswerImages) > 0 { + expectCount = len(existing.EssayAnswerImages) + } + if expectCount <= 0 { + expectCount = len(req.EssayAnswerImages) + } + merged := make([][]string, expectCount) + for i := 0; i < expectCount; i++ { + if i < len(req.EssayAnswerImages) && len(req.EssayAnswerImages[i]) > 0 { + merged[i] = req.EssayAnswerImages[i] + } else if existing != nil && i < len(existing.EssayAnswerImages) && len(existing.EssayAnswerImages[i]) > 0 { + merged[i] = existing.EssayAnswerImages[i] + } else { + merged[i] = nil + } + } + essayMerged = merged + essayAllFilled = true + for _, imgs := range merged { + if len(imgs) == 0 { + essayAllFilled = false + break + } + } + } + + // 判断是否完成 + isCompleted := req.IsCompleted + reviewStatus := req.ReviewStatus + var objectiveBestCorrectCount, objectiveBestTotalCount int + if existing != nil { + objectiveBestCorrectCount = existing.ObjectiveBestCorrectCount + objectiveBestTotalCount = existing.ObjectiveBestTotalCount + } + + // 申论题:若传了每题审核状态 essay_review_statuses,则据此计算整体 review_status(任务最终状态由所有题目状态决定) + if task.TaskType == camp.TaskTypeEssay && len(req.EssayReviewStatuses) > 0 { + reviewStatus = computeEssayOverallReviewStatus(req.EssayReviewStatuses) + } + + // 根据任务类型判断完成状态 + switch task.TaskType { + case camp.TaskTypeSubjective, camp.TaskTypeEssay: + // 主观题和申论题:需要检查是否需要审核 + needReview := false + if len(task.Condition) > 0 { + var conditionMap map[string]any + if err := json.Unmarshal(task.Condition, &conditionMap); err == nil { + // 检查 need_review 字段(可能在根级别或 subjective/essay 子对象中) + if val, ok := conditionMap["need_review"]; ok { + if boolVal, ok := val.(bool); ok { + needReview = boolVal + } + } else if task.TaskType == camp.TaskTypeSubjective { + if subjectiveRaw, ok := conditionMap["subjective"].(map[string]any); ok { + if val, ok := subjectiveRaw["need_review"]; ok { + if boolVal, ok := val.(bool); ok { + needReview = boolVal + } + } + } + } else if task.TaskType == camp.TaskTypeEssay { + if essayRaw, ok := conditionMap["essay"].(map[string]any); ok { + if val, ok := essayRaw["need_review"]; ok { + if boolVal, ok := val.(bool); ok { + needReview = boolVal + } + } + } + } + } + } + + if needReview { + // 需要审核:只有审核通过才算完成 + isCompleted = (reviewStatus == camp.ReviewStatusApproved) + if task.TaskType == camp.TaskTypeEssay && len(essayMerged) > 0 { + isCompleted = essayAllFilled && isCompleted + } + } else { + // 不需要审核:提交后直接完成,自动设置为审核通过 + hasSubmission := len(req.AnswerImages) > 0 || len(req.EssayAnswerImages) > 0 || req.IsCompleted + if hasSubmission { + if task.TaskType == camp.TaskTypeEssay && len(essayMerged) > 0 { + isCompleted = essayAllFilled + } else { + isCompleted = true + } + if isCompleted { + reviewStatus = camp.ReviewStatusApproved + } + } + } + case camp.TaskTypeImageText, camp.TaskTypeVideo: + // 图文和视频任务:直接使用 req.IsCompleted + isCompleted = req.IsCompleted + case camp.TaskTypeObjective: + // 客观题:按正确率(正确数/总题数)达标即完成;达标后只保留最高正确率,后续未达标不覆盖已完成状态 + if req.ObjectiveTotalCount > 0 { + correct := req.ObjectiveCorrectCount + total := req.ObjectiveTotalCount + if correct < 0 { + correct = 0 + } + passRate := getObjectivePassRateFromCondition(task.Condition) // 默认 60 + // 正确率 = 正确数/总题数,用整数比较:correct*100/total >= passRate + qualified := total > 0 && (correct*100)/total >= passRate + + if existing != nil && existing.IsCompleted { + // 已达标过:永不再改为未完成;仅当本次正确率更高时更新最佳 + isCompleted = true + bestCorrect, bestTotal := existing.ObjectiveBestCorrectCount, existing.ObjectiveBestTotalCount + if bestTotal <= 0 { + bestCorrect, bestTotal = correct, total + } else if total > 0 && (correct*bestTotal > bestCorrect*total) { + bestCorrect, bestTotal = correct, total + } + objectiveBestCorrectCount, objectiveBestTotalCount = bestCorrect, bestTotal + } else if existing != nil { + // 此前未达标 + isCompleted = qualified + bestCorrect, bestTotal := existing.ObjectiveBestCorrectCount, existing.ObjectiveBestTotalCount + if bestTotal <= 0 { + bestCorrect, bestTotal = correct, total + } else if qualified && total > 0 && (correct*bestTotal > bestCorrect*total) { + bestCorrect, bestTotal = correct, total + } else if qualified { + bestCorrect, bestTotal = correct, total + } + objectiveBestCorrectCount, objectiveBestTotalCount = bestCorrect, bestTotal + } else { + // 无历史进度 + isCompleted = qualified + objectiveBestCorrectCount, objectiveBestTotalCount = correct, total + } + } else { + // 未传本次客观题数据(如 0 题提交):若已有达标记录则保持完成,否则按请求 + if existing != nil && existing.IsCompleted { + isCompleted = true + } else { + isCompleted = req.IsCompleted + } + if existing != nil { + objectiveBestCorrectCount = existing.ObjectiveBestCorrectCount + objectiveBestTotalCount = existing.ObjectiveBestTotalCount + } + } + default: + // 其他类型:直接使用 req.IsCompleted + isCompleted = req.IsCompleted + } + + // 合并逻辑:仅当请求未提供字段(nil / 空字符串)时才保留原值 + // 注意:对于切片字段,区分 nil(字段未提供)和 [](显式清空) + // JSON 反序列化:字段不存在 → nil,"review_images": [] → 非nil空切片 + reviewComment := req.ReviewComment + if reviewComment == "" && existing != nil { + reviewComment = existing.ReviewComment + } + + reviewImages := req.ReviewImages + if reviewImages == nil && existing != nil { + // 字段未提供,保留原值 + reviewImages = existing.ReviewImages + } + // 如果 reviewImages 非 nil 但 len==0(显式传了空数组),则清空图片 + + answerImages := req.AnswerImages + essayAnswerImages := req.EssayAnswerImages + if len(essayMerged) > 0 { + // 申论题:使用合并后的数据(支持暂存时与已有进度合并) + essayAnswerImages = essayMerged + var flat []string + for _, imgs := range essayMerged { + flat = append(flat, imgs...) + } + answerImages = flat + } else if len(req.EssayAnswerImages) > 0 { + // 兼容:未走 merge 时仍用请求数据 + essayAnswerImages = req.EssayAnswerImages + var flat []string + for _, imgs := range req.EssayAnswerImages { + flat = append(flat, imgs...) + } + answerImages = flat + } else { + if answerImages == nil && existing != nil { + answerImages = existing.AnswerImages + } + if essayAnswerImages == nil && existing != nil { + essayAnswerImages = existing.EssayAnswerImages + } + } + + campID := req.CampID + if campID == "" && existing != nil { + campID = existing.CampID + } + + completedAt := req.CompletedAt + if completedAt == "" && existing != nil { + completedAt = existing.CompletedAt + } + + progress := &camp.UserProgress{ + ID: snowflake.GetInstance().NextIDString(), + UserID: req.UserID, + TaskID: req.TaskID, + CampID: campID, + IsCompleted: isCompleted, + CompletedAt: completedAt, + ReviewStatus: reviewStatus, // 使用处理后的 reviewStatus(不需要审核时自动设置为 approved) + ReviewComment: reviewComment, + ReviewImages: reviewImages, + AnswerImages: answerImages, + EssayAnswerImages: essayAnswerImages, + ObjectiveBestCorrectCount: objectiveBestCorrectCount, + ObjectiveBestTotalCount: objectiveBestTotalCount, + } + // 申论题:若请求带了每题审核状态,则写入 + if task.TaskType == camp.TaskTypeEssay && len(req.EssayReviewStatuses) > 0 { + progress.EssayReviewStatuses = normalizeEssayReviewStatuses(req.EssayReviewStatuses) + } else if existing != nil && len(existing.EssayReviewStatuses) > 0 { + progress.EssayReviewStatuses = existing.EssayReviewStatuses + } + + // 客观题:写入前再次确认,若库中已是完成则绝不改为未完成(防并发或其它路径覆盖) + if task.TaskType == camp.TaskTypeObjective && !progress.IsCompleted { + recheck, _ := s.progressDAO.GetByUserAndTask(req.UserID, req.TaskID) + if recheck != nil && recheck.IsCompleted { + progress.IsCompleted = true + if progress.CompletedAt == "" && recheck.CompletedAt != "" { + progress.CompletedAt = recheck.CompletedAt + } + // 保留库中已有的最佳成绩,避免被本次未达标数据覆盖 + if recheck.ObjectiveBestTotalCount > 0 { + progress.ObjectiveBestCorrectCount = recheck.ObjectiveBestCorrectCount + progress.ObjectiveBestTotalCount = recheck.ObjectiveBestTotalCount + } + } + } + + if err = s.progressDAO.Update(progress); err != nil { + return &camp.UpdateUserProgressResponse{ + Success: false, + Message: fmt.Sprintf("更新用户进度失败: %v", err), + }, nil + } + + if isCompleted && task.CampID != "" && task.SectionID != "" && s.campService != nil { + s.campService.TryAutoOpenNextSectionIfEligible(req.UserID, task.CampID, task.SectionID) + } + + return &camp.UpdateUserProgressResponse{ + Success: true, + Message: "更新用户进度成功", + }, nil +} + +// GetUserProgress 获取用户进度 +func (s *ProgressService) GetUserProgress(userID, taskID string) (*camp.GetUserProgressResponse, error) { + progress, err := s.progressDAO.GetByUserAndTask(userID, taskID) + if err != nil { + return &camp.GetUserProgressResponse{ + Success: false, + Message: fmt.Sprintf("获取用户进度失败: %v", err), + }, nil + } + + // 如果没有进度记录,返回 success=false 但不报错,这是正常情况 + if progress == nil { + return &camp.GetUserProgressResponse{ + Progress: nil, + Success: false, + Message: "暂无进度", + }, nil + } + + return &camp.GetUserProgressResponse{ + Progress: progress, + Success: true, + Message: "获取用户进度成功", + }, nil +} + +// ListUserProgress 列出用户进度 +func (s *ProgressService) ListUserProgress(req *camp.ListUserProgressRequest) (*camp.ListUserProgressResponse, error) { + // 设置默认值 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + + reviewStatus := strings.TrimSpace(req.ReviewStatus) + userKeyword := strings.TrimSpace(req.UserKeyword) + + // 当指定了 camp_id 时:按「已加入该营的用户」分页,展示所有开启过该营的用户(含无进度的) + if req.CampID != "" && s.userCampDAO != nil { + userIDs, total, err := s.userCampDAO.ListUserIDsByCamp(req.CampID, userKeyword, req.Page, req.PageSize) + if err != nil { + return &camp.ListUserProgressResponse{ + Success: false, + Message: fmt.Sprintf("获取打卡营用户列表失败: %v", err), + }, nil + } + var progressList []*camp.UserProgress + if len(userIDs) > 0 { + progressList, err = s.progressDAO.ListByUserIDsAndCamp(userIDs, req.CampID, req.SectionID, req.TaskID, reviewStatus) + if err != nil { + return &camp.ListUserProgressResponse{ + Success: false, + Message: fmt.Sprintf("获取用户进度列表失败: %v", err), + }, nil + } + } + return &camp.ListUserProgressResponse{ + ProgressList: progressList, + UserIDs: userIDs, + Total: total, + Success: true, + Message: "获取用户进度列表成功", + }, nil + } + + // 未指定 camp_id 或无 userCampDAO:沿用原逻辑(按进度记录分页) + progressList, total, err := s.progressDAO.List(req.UserID, userKeyword, req.TaskID, req.SectionID, req.CampID, reviewStatus, req.Page, req.PageSize) + if err != nil { + return &camp.ListUserProgressResponse{ + Success: false, + Message: fmt.Sprintf("获取用户进度列表失败: %v", err), + }, nil + } + + return &camp.ListUserProgressResponse{ + ProgressList: progressList, + Total: total, + Success: true, + Message: "获取用户进度列表成功", + }, nil +} + +// ResetTaskProgress 重新答题(删除指定任务下的用户进度记录和答题记录) +// 客观题:只清空答题记录,不删除进度,完成状态永久保留 +func (s *ProgressService) ResetTaskProgress(userID, taskID string) (*camp.ResetTaskProgressResponse, error) { + if userID == "" || taskID == "" { + return &camp.ResetTaskProgressResponse{ + Success: false, + Message: "参数缺失:user_id 和 task_id 不能为空", + }, nil + } + + // 获取任务信息,用于判断任务类型和获取试卷ID + task, err := s.taskDAO.GetByID(taskID) + if err != nil { + return &camp.ResetTaskProgressResponse{ + Success: false, + Message: fmt.Sprintf("获取任务信息失败: %v", err), + }, nil + } + + // 客观题:不删除进度记录,只清空答题记录,便于再次作答且不影响完成状态 + if task.TaskType == camp.TaskTypeObjective { + paperID := ExtractPaperIDFromTaskContent(task.Content) + if paperID != "" && s.answerRecordService != nil { + deletedCount, err := s.answerRecordService.DeleteAnswerRecordByUserAndPaper(userID, paperID, taskID) + if err != nil { + fmt.Printf("[ResetTaskProgress] 客观题删除答题记录失败 user_id=%s paper_id=%s err=%v\n", userID, paperID, err) + } else { + fmt.Printf("[ResetTaskProgress] 客观题已删除 %d 条答题记录 user_id=%s paper_id=%s(进度保留)\n", deletedCount, userID, paperID) + } + } + return &camp.ResetTaskProgressResponse{ + Success: true, + Message: "已清空答题记录,可重新作答;完成状态已保留", + }, nil + } + + // 非客观题:删除用户任务进度记录 + err = s.progressDAO.DeleteByUserAndTask(userID, taskID) + if err != nil { + return &camp.ResetTaskProgressResponse{ + Success: false, + Message: fmt.Sprintf("删除用户进度失败: %v", err), + }, nil + } + + return &camp.ResetTaskProgressResponse{ + Success: true, + Message: "重新答题成功,进度记录和答题记录已删除", + }, nil +} + +// getObjectivePassRateFromCondition 从任务 condition 中解析客观题达标正确率(百分比),默认 60 +func getObjectivePassRateFromCondition(condition json.RawMessage) int { + if len(condition) == 0 { + return 60 + } + var raw map[string]any + if err := json.Unmarshal(condition, &raw); err != nil { + return 60 + } + obj, ok := raw["objective"].(map[string]any) + if !ok { + return 60 + } + switch v := obj["pass_rate"].(type) { + case float64: + if v >= 0 && v <= 100 { + return int(v) + } + case int: + if v >= 0 && v <= 100 { + return v + } + } + return 60 +} + +// computeEssayOverallReviewStatus 根据申论题每题审核状态计算任务整体状态:所有题目通过才是通过,有一道题未审核/待审核/驳回则最终为驳回 +func computeEssayOverallReviewStatus(perQuestion []string) camp.ReviewStatus { + if len(perQuestion) == 0 { + return camp.ReviewStatusRejected + } + for _, s := range perQuestion { + upper := strings.ToUpper(strings.TrimSpace(s)) + if upper != "APPROVED" { + return camp.ReviewStatusRejected + } + } + return camp.ReviewStatusApproved +} + +// normalizeEssayReviewStatuses 将前端传来的 pending/approved/rejected 规范为大写 PENDING/APPROVED/REJECTED 存储 +func normalizeEssayReviewStatuses(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + upper := strings.ToUpper(strings.TrimSpace(s)) + switch upper { + case "APPROVED", "REJECTED": + out = append(out, upper) + default: + out = append(out, "PENDING") + } + } + return out +} + + diff --git a/internal/camp/service/section_service.go b/internal/camp/service/section_service.go new file mode 100644 index 0000000..79ebdcb --- /dev/null +++ b/internal/camp/service/section_service.go @@ -0,0 +1,202 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/pkg/snowflake" +) + +// SectionService 小节服务 +type SectionService struct { + sectionDAO *dao.SectionDAO + campDAO *dao.CampDAO +} + +// NewSectionService 创建小节服务 +func NewSectionService(sectionDAO *dao.SectionDAO, campDAO *dao.CampDAO) *SectionService { + return &SectionService{ + sectionDAO: sectionDAO, + campDAO: campDAO, + } +} + +// CreateSection 创建小节 +func (s *SectionService) CreateSection(req *camp.CreateSectionRequest) (*camp.CreateSectionResponse, error) { + sectionID := snowflake.GetInstance().NextIDString() + + section := &camp.Section{ + ID: sectionID, + CampID: req.CampID, + Title: req.Title, + SectionNumber: req.SectionNumber, + PriceFen: req.PriceFen, + RequirePreviousSection: req.RequirePreviousSection, + TimeIntervalType: req.TimeIntervalType, + TimeIntervalValue: req.TimeIntervalValue, + } + + err := s.sectionDAO.Create(section) + if err != nil { + return &camp.CreateSectionResponse{ + Success: false, + Message: fmt.Sprintf("创建小节失败: %v", err), + }, nil + } + + // 更新打卡营的小节数量 + if err := s.campDAO.UpdateSectionCount(req.CampID); err != nil { + return &camp.CreateSectionResponse{ + Success: false, + Message: fmt.Sprintf("创建小节成功,但更新打卡营小节数量失败: %v", err), + }, nil + } + + return &camp.CreateSectionResponse{ + ID: sectionID, + Success: true, + Message: "创建小节成功", + }, nil +} + +// GetSection 获取小节 +func (s *SectionService) GetSection(id string) (*camp.GetSectionResponse, error) { + section, err := s.sectionDAO.GetByID(id) + if err != nil { + return &camp.GetSectionResponse{ + Success: false, + Message: fmt.Sprintf("获取小节失败: %v", err), + }, nil + } + + return &camp.GetSectionResponse{ + Section: section, + Success: true, + Message: "获取小节成功", + }, nil +} + +// UpdateSection 更新小节 +func (s *SectionService) UpdateSection(req *camp.UpdateSectionRequest) (*camp.UpdateSectionResponse, error) { + // 先获取原有小节信息,检查 camp_id 是否变更,并保留 require_previous_section 原值(该字段已从管理端移除) + var oldCampID string + var existingRequirePreviousSection bool + if existingSection, err := s.sectionDAO.GetByID(req.ID); err == nil { + oldCampID = existingSection.CampID + existingRequirePreviousSection = existingSection.RequirePreviousSection + } + + section := &camp.Section{ + ID: req.ID, + CampID: req.CampID, + Title: req.Title, + SectionNumber: req.SectionNumber, + PriceFen: req.PriceFen, + RequirePreviousSection: existingRequirePreviousSection, // 保留原值,不再接受请求体 + TimeIntervalType: req.TimeIntervalType, + TimeIntervalValue: req.TimeIntervalValue, + } + + err := s.sectionDAO.Update(section) + if err != nil { + return &camp.UpdateSectionResponse{ + Success: false, + Message: fmt.Sprintf("更新小节失败: %v", err), + }, nil + } + + // 更新打卡营的小节数量 + // 如果 camp_id 变更,需要更新两个打卡营的数量 + if oldCampID != "" && oldCampID != req.CampID { + // 更新原打卡营的小节数量 + if err := s.campDAO.UpdateSectionCount(oldCampID); err != nil { + return &camp.UpdateSectionResponse{ + Success: false, + Message: fmt.Sprintf("更新小节成功,但更新原打卡营小节数量失败: %v", err), + }, nil + } + } + + // 更新新打卡营的小节数量 + if err := s.campDAO.UpdateSectionCount(req.CampID); err != nil { + return &camp.UpdateSectionResponse{ + Success: false, + Message: fmt.Sprintf("更新小节成功,但更新打卡营小节数量失败: %v", err), + }, nil + } + + return &camp.UpdateSectionResponse{ + Success: true, + Message: "更新小节成功", + }, nil +} + +// DeleteSection 删除小节 +func (s *SectionService) DeleteSection(id string) (*camp.DeleteSectionResponse, error) { + // 先获取小节信息,以便知道属于哪个打卡营 + section, err := s.sectionDAO.GetByID(id) + if err != nil { + return &camp.DeleteSectionResponse{ + Success: false, + Message: fmt.Sprintf("获取小节信息失败: %v", err), + }, nil + } + + campID := section.CampID + + // TODO: 检查是否存在未删除的任务和用户进度 + // 暂时先允许删除,后续可以添加检查逻辑 + + // 删除小节 + err = s.sectionDAO.Delete(id) + if err != nil { + return &camp.DeleteSectionResponse{ + Success: false, + Message: fmt.Sprintf("删除小节失败: %v", err), + }, nil + } + + // 更新打卡营的小节数量 + if err := s.campDAO.UpdateSectionCount(campID); err != nil { + return &camp.DeleteSectionResponse{ + Success: false, + Message: fmt.Sprintf("删除小节成功,但更新打卡营小节数量失败: %v", err), + }, nil + } + + return &camp.DeleteSectionResponse{ + Success: true, + Message: "删除小节成功", + }, nil +} + +// ListSections 列出小节(支持关键词搜索和筛选) +func (s *SectionService) ListSections(req *camp.ListSectionsRequest) (*camp.ListSectionsResponse, error) { + // 设置默认值 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + + sections, total, err := s.sectionDAO.List(req.Keyword, req.CampID, req.Page, req.PageSize) + if err != nil { + return &camp.ListSectionsResponse{ + Success: false, + Message: fmt.Sprintf("获取小节列表失败: %v", err), + }, nil + } + + return &camp.ListSectionsResponse{ + Sections: sections, + Total: total, + Success: true, + Message: "获取小节列表成功", + }, nil +} + diff --git a/internal/camp/service/task_content.go b/internal/camp/service/task_content.go new file mode 100644 index 0000000..6bf099c --- /dev/null +++ b/internal/camp/service/task_content.go @@ -0,0 +1,50 @@ +package service + +import ( + "encoding/json" + "fmt" +) + +// ExtractPaperIDFromTaskContent 从任务 content JSON 中解析试卷 ID(支持 exam_id/paper_id/examId/paperId,含数字) +func ExtractPaperIDFromTaskContent(content json.RawMessage) string { + if len(content) == 0 { + return "" + } + var m map[string]interface{} + if err := json.Unmarshal(content, &m); err != nil { + return "" + } + paperID := stringFromMap(m, "exam_id", "paper_id", "examId", "paperId") + if paperID != "" { + return paperID + } + if obj, ok := m["objective"].(map[string]interface{}); ok { + paperID = stringFromMap(obj, "exam_id", "paper_id", "examId", "paperId") + } + return paperID +} + +func stringFromMap(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + v, ok := m[k] + if !ok || v == nil { + continue + } + switch val := v.(type) { + case string: + if val != "" { + return val + } + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%.0f", val) + case int: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + } + } + return "" +} diff --git a/internal/camp/service/task_service.go b/internal/camp/service/task_service.go new file mode 100644 index 0000000..3f1c0c2 --- /dev/null +++ b/internal/camp/service/task_service.go @@ -0,0 +1,215 @@ +package service + +import ( + "fmt" + "strings" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/pkg/snowflake" +) + +// TaskService 任务服务 +type TaskService struct { + taskDAO *dao.TaskDAO +} + +// NewTaskService 创建任务服务 +func NewTaskService(taskDAO *dao.TaskDAO) *TaskService { + return &TaskService{ + taskDAO: taskDAO, + } +} + +// CreateTask 创建任务 +func (s *TaskService) CreateTask(req *camp.CreateTaskRequest) (*camp.CreateTaskResponse, error) { + // 若设置了前置任务:仅同小节内有效,且本节内已有任务时才能设置(第一个任务不能设前置) + if req.PrerequisiteTaskID != "" { + prereq, err := s.taskDAO.GetByID(req.PrerequisiteTaskID) + if err != nil { + return &camp.CreateTaskResponse{ + Success: false, + Message: "前置任务不存在或已删除", + }, nil + } + if prereq.SectionID != req.SectionID { + return &camp.CreateTaskResponse{ + Success: false, + Message: "前置任务必须属于同一个小节", + }, nil + } + count, err := s.taskDAO.CountActiveBySection(req.SectionID) + if err != nil { + return &camp.CreateTaskResponse{ + Success: false, + Message: fmt.Sprintf("校验失败: %v", err), + }, nil + } + if count == 0 { + return &camp.CreateTaskResponse{ + Success: false, + Message: "本节第一个任务不能设置前置任务", + }, nil + } + } + + taskID := snowflake.GetInstance().NextIDString() + + task := &camp.Task{ + ID: taskID, + CampID: req.CampID, + SectionID: req.SectionID, + TaskType: req.TaskType, + Title: req.Title, + Content: req.Content, + Condition: req.Condition, + PrerequisiteTaskID: req.PrerequisiteTaskID, + } + + err := s.taskDAO.Create(task) + if err != nil { + return &camp.CreateTaskResponse{ + Success: false, + Message: fmt.Sprintf("创建任务失败: %v", err), + }, nil + } + + return &camp.CreateTaskResponse{ + ID: taskID, + Success: true, + Message: "创建任务成功", + }, nil +} + +// GetTask 获取任务 +func (s *TaskService) GetTask(id string, userID string) (*camp.GetTaskResponse, error) { + task, err := s.taskDAO.GetByID(id) + if err != nil { + return &camp.GetTaskResponse{ + Success: false, + Message: fmt.Sprintf("获取任务失败: %v", err), + }, nil + } + + // 如果提供了 user_id,可以在这里获取用户相关的状态信息 + // 例如:是否已完成、当前进度等 + // 目前先返回基础信息,后续可以根据需要扩展 + + return &camp.GetTaskResponse{ + Task: task, + Success: true, + Message: "获取任务成功", + }, nil +} + +// UpdateTask 更新任务 +func (s *TaskService) UpdateTask(req *camp.UpdateTaskRequest) (*camp.UpdateTaskResponse, error) { + // 若设置了前置任务:必须同小节;当前任务不能是本节第一个任务(ID 最小);前置任务 ID 必须小于当前任务 ID + if req.PrerequisiteTaskID != "" { + prereq, err := s.taskDAO.GetByID(req.PrerequisiteTaskID) + if err != nil { + return &camp.UpdateTaskResponse{ + Success: false, + Message: "前置任务不存在或已删除", + }, nil + } + if prereq.SectionID != req.SectionID { + return &camp.UpdateTaskResponse{ + Success: false, + Message: "前置任务必须属于同一个小节", + }, nil + } + minID, err := s.taskDAO.MinTaskIDBySection(req.SectionID) + if err != nil { + return &camp.UpdateTaskResponse{ + Success: false, + Message: fmt.Sprintf("校验失败: %v", err), + }, nil + } + if minID == req.ID { + return &camp.UpdateTaskResponse{ + Success: false, + Message: "本节第一个任务(ID 最小)不能设置前置任务", + }, nil + } + if strings.Compare(req.PrerequisiteTaskID, req.ID) >= 0 { + return &camp.UpdateTaskResponse{ + Success: false, + Message: "前置任务必须是本节内 ID 更小的任务", + }, nil + } + } + + task := &camp.Task{ + ID: req.ID, + CampID: req.CampID, + SectionID: req.SectionID, + TaskType: req.TaskType, + Title: req.Title, + Content: req.Content, + Condition: req.Condition, + PrerequisiteTaskID: req.PrerequisiteTaskID, + } + + err := s.taskDAO.Update(task) + if err != nil { + return &camp.UpdateTaskResponse{ + Success: false, + Message: fmt.Sprintf("更新任务失败: %v", err), + }, nil + } + + return &camp.UpdateTaskResponse{ + Success: true, + Message: "更新任务成功", + }, nil +} + +// DeleteTask 删除任务 +func (s *TaskService) DeleteTask(id string) (*camp.DeleteTaskResponse, error) { + // TODO: 检查是否存在用户进度 + // 暂时先允许删除,后续可以添加检查逻辑 + + err := s.taskDAO.Delete(id) + if err != nil { + return &camp.DeleteTaskResponse{ + Success: false, + Message: fmt.Sprintf("删除任务失败: %v", err), + }, nil + } + + return &camp.DeleteTaskResponse{ + Success: true, + Message: "删除任务成功", + }, nil +} + +// ListTasks 列出任务(支持关键词搜索和筛选) +func (s *TaskService) ListTasks(req *camp.ListTasksRequest) (*camp.ListTasksResponse, error) { + // 设置默认值 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + + tasks, total, err := s.taskDAO.List(req.Keyword, req.CampID, req.SectionID, req.TaskType, req.Page, req.PageSize) + if err != nil { + return &camp.ListTasksResponse{ + Success: false, + Message: fmt.Sprintf("获取任务列表失败: %v", err), + }, nil + } + + return &camp.ListTasksResponse{ + Tasks: tasks, + Total: total, + Success: true, + Message: "获取任务列表成功", + }, nil +} + diff --git a/internal/camp/service/user_camp_service.go b/internal/camp/service/user_camp_service.go new file mode 100644 index 0000000..184c69c --- /dev/null +++ b/internal/camp/service/user_camp_service.go @@ -0,0 +1,223 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/camp" + "dd_fiber_api/internal/camp/dao" + question_dao "dd_fiber_api/internal/question/dao" + "dd_fiber_api/pkg/snowflake" + "dd_fiber_api/pkg/utils" +) + +// UserCampService 用户打卡营服务 +type UserCampService struct { + userCampDAO *dao.UserCampDAO + sectionDAO *dao.SectionDAO + progressDAO *dao.ProgressDAO + taskDAO *dao.TaskDAO + resetHistoryDAO *dao.ResetHistoryDAO + answerRecordDAO question_dao.AnswerRecordDAOInterface +} + +// NewUserCampService 创建用户打卡营服务 +func NewUserCampService( + userCampDAO *dao.UserCampDAO, + sectionDAO *dao.SectionDAO, + progressDAO *dao.ProgressDAO, + taskDAO *dao.TaskDAO, + resetHistoryDAO *dao.ResetHistoryDAO, + answerRecordDAO question_dao.AnswerRecordDAOInterface, +) *UserCampService { + return &UserCampService{ + userCampDAO: userCampDAO, + sectionDAO: sectionDAO, + progressDAO: progressDAO, + taskDAO: taskDAO, + resetHistoryDAO: resetHistoryDAO, + answerRecordDAO: answerRecordDAO, + } +} + +// JoinCamp 用户加入打卡营(幂等) +func (s *UserCampService) JoinCamp(req *camp.JoinCampRequest) (*camp.JoinCampResponse, error) { + if req.UserID == "" || req.CampID == "" { + return &camp.JoinCampResponse{Success: false, Message: "参数缺失"}, nil + } + id := snowflake.GetInstance().NextIDString() + if err := s.userCampDAO.CreateIfNotExists(id, req.UserID, req.CampID); err != nil { + return &camp.JoinCampResponse{Success: false, Message: fmt.Sprintf("加入失败: %v", err)}, nil + } + + // 获取打卡营中 section_number 最小的小节ID + firstSectionID, err := s.sectionDAO.GetFirstSectionID(req.CampID) + if err != nil { + return &camp.JoinCampResponse{Success: false, Message: fmt.Sprintf("获取第一个小节失败: %v", err)}, nil + } + + // 如果找到了第一个小节,且当前小节ID为空,则设置为第一个小节 + if firstSectionID != "" { + currentSectionID, err := s.userCampDAO.GetCurrentSection(req.UserID, req.CampID) + if err != nil { + return &camp.JoinCampResponse{Success: false, Message: fmt.Sprintf("查询当前小节失败: %v", err)}, nil + } + // 如果当前小节ID为空,则设置为第一个小节 + if currentSectionID == "" { + if err := s.userCampDAO.UpdateCurrentSection(req.UserID, req.CampID, firstSectionID); err != nil { + return &camp.JoinCampResponse{Success: false, Message: fmt.Sprintf("设置当前小节失败: %v", err)}, nil + } + } + } + + return &camp.JoinCampResponse{Success: true, Message: "加入成功"}, nil +} + +// CheckUserCampStatus 检查用户是否加入了打卡营,并返回打卡营整体状态 +func (s *UserCampService) CheckUserCampStatus(userID, campID string) (*camp.CheckUserCampStatusResponse, error) { + if userID == "" || campID == "" { + return &camp.CheckUserCampStatusResponse{ + Success: false, + Message: "参数缺失", + IsJoined: false, + CampStatus: camp.CampStatusNotStarted, + }, nil + } + + isJoined, joinedAt, currentSectionID, err := s.userCampDAO.CheckUserCampStatus(userID, campID) + if err != nil { + return &camp.CheckUserCampStatusResponse{ + Success: false, + Message: fmt.Sprintf("查询失败: %v", err), + IsJoined: false, + CampStatus: camp.CampStatusNotStarted, + }, nil + } + + // 格式化时间 + formattedJoinedAt := utils.FormatNullTimeToStd(joinedAt) + + // 获取当前小节ID + var currentSectionIDStr string + if currentSectionID.Valid { + currentSectionIDStr = currentSectionID.String + } + + // 计算打卡营整体状态 + campStatus := camp.CampStatusNotStarted + if isJoined { + // 查询总任务数和已完成任务数 + totalTasks, completedTasks, err := s.progressDAO.CountTasksAndCompletedByCamp(userID, campID) + if err != nil { + // 查询失败不影响主流程,默认进行中 + campStatus = camp.CampStatusInProgress + } else if totalTasks == 0 { + // 没有任务,视为已完成(空营) + campStatus = camp.CampStatusCompleted + } else if completedTasks >= totalTasks { + campStatus = camp.CampStatusCompleted + } else if completedTasks > 0 { + campStatus = camp.CampStatusInProgress + } else { + // 已加入但没有完成任何任务,仍视为进行中(已报名) + campStatus = camp.CampStatusInProgress + } + } + + return &camp.CheckUserCampStatusResponse{ + Success: true, + Message: "查询成功", + IsJoined: isJoined, + JoinedAt: formattedJoinedAt, + CurrentSectionID: currentSectionIDStr, + CampStatus: campStatus, + }, nil +} + +// ListUserCamps 获取用户已加入的打卡营列表 +func (s *UserCampService) ListUserCamps(req *camp.ListUserCampsRequest) (*camp.ListUserCampsResponse, error) { + if req.UserID == "" { + return &camp.ListUserCampsResponse{ + Success: false, + Message: "用户ID不能为空", + }, nil + } + + // 设置默认分页参数 + page := req.Page + if page <= 0 { + page = 1 + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 // 限制最大页面大小 + } + + joinedCamps, total, err := s.userCampDAO.ListUserJoinedCamps(req.UserID, page, pageSize) + if err != nil { + return &camp.ListUserCampsResponse{ + Success: false, + Message: fmt.Sprintf("获取用户打卡营列表失败: %v", err), + }, nil + } + + return &camp.ListUserCampsResponse{ + Success: true, + Message: "获取成功", + Camps: joinedCamps, + Total: total, + }, nil +} + +// ResetCampProgress 清空用户在营内的任务进度(已购访问不变),并写入重置历史、删除该营下客观题答题记录 +func (s *UserCampService) ResetCampProgress(req *camp.ResetCampProgressRequest) (*camp.ResetCampProgressResponse, error) { + if req.UserID == "" || req.CampID == "" { + return &camp.ResetCampProgressResponse{Success: false, Message: "参数缺失"}, nil + } + if !req.Confirm { + return &camp.ResetCampProgressResponse{Success: false, Message: "需要确认confirm=true"}, nil + } + + // 1. 获取该营下所有任务 ID,删除用户在这些任务下的客观题答题记录 + var taskIDs []string + if s.taskDAO != nil && s.answerRecordDAO != nil { + tasks, _, err := s.taskDAO.List("", req.CampID, "", camp.TaskTypeUnknown, 1, 10000) + if err == nil { + for _, t := range tasks { + taskIDs = append(taskIDs, t.ID) + } + if len(taskIDs) > 0 { + _, _ = s.answerRecordDAO.DeleteByUserAndTaskIDs(req.UserID, taskIDs) + } + } + } + + // 2. 删除进度 + cleared, err := s.progressDAO.DeleteByUserAndCamp(req.UserID, req.CampID) + if err != nil { + return &camp.ResetCampProgressResponse{Success: false, Message: fmt.Sprintf("清空进度失败: %v", err)}, nil + } + + // 3. 写入重置历史 + if s.resetHistoryDAO != nil { + id := snowflake.GetInstance().NextIDString() + note := req.Note + if err := s.resetHistoryDAO.Create(id, req.UserID, req.CampID, int(cleared), note); err != nil { + // 记录失败不阻断重置成功,仅日志或后续可告警 + _ = err + } + } + + // 4. 将 camp_user_camps.current_section_id 重置为该营第一小节 ID + if s.sectionDAO != nil && s.userCampDAO != nil { + firstSectionID, err := s.sectionDAO.GetFirstSectionID(req.CampID) + if err == nil && firstSectionID != "" { + _ = s.userCampDAO.UpdateCurrentSection(req.UserID, req.CampID, firstSectionID) + } + } + + return &camp.ResetCampProgressResponse{Success: true, Message: fmt.Sprintf("重置成功,已清空 %d 条进度记录", cleared)}, nil +} + diff --git a/internal/camp/types.go b/internal/camp/types.go new file mode 100644 index 0000000..9100906 --- /dev/null +++ b/internal/camp/types.go @@ -0,0 +1,636 @@ +package camp + +import "encoding/json" + +// Category 分类 +type Category struct { + ID string `json:"id"` + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` +} + +// CreateCategoryRequest 创建分类请求 +type CreateCategoryRequest struct { + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` +} + +// CreateCategoryResponse 创建分类响应 +type CreateCategoryResponse struct { + ID string `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetCategoryResponse 获取分类响应 +type GetCategoryResponse struct { + Category *Category `json:"category"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// ListCategoriesRequest 列出分类请求 +type ListCategoriesRequest struct { + Keyword string `json:"keyword" query:"keyword"` // 关键词搜索(可选) + Page int `json:"page" query:"page"` // 页码(从1开始) + PageSize int `json:"page_size" query:"page_size"` // 每页数量 +} + +// ListCategoriesResponse 列出分类响应 +type ListCategoriesResponse struct { + Categories []*Category `json:"categories"` + Total int `json:"total"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// UpdateCategoryRequest 更新分类请求 +type UpdateCategoryRequest struct { + ID string `json:"id"` + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` +} + +// UpdateCategoryResponse 更新分类响应 +type UpdateCategoryResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// DeleteCategoryResponse 删除分类响应 +type DeleteCategoryResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// IntroType 简介类型 +type IntroType string + +const ( + IntroTypeNone IntroType = "none" // 没有简介 + IntroTypeImageText IntroType = "image_text" // 图文简介 + IntroTypeVideo IntroType = "video" // 视频简介 +) + +// RecommendFilter 推荐状态筛选 +type RecommendFilter string + +const ( + RecommendFilterAll RecommendFilter = "all" // 不筛选,返回所有 + RecommendFilterOnlyTrue RecommendFilter = "only_true" // 只返回推荐的 + RecommendFilterOnlyFalse RecommendFilter = "only_false" // 只返回非推荐的 +) + +// Camp 打卡营 +type Camp struct { + ID string `json:"id"` + Title string `json:"title"` + CoverImage string `json:"cover_image,omitempty"` + Description string `json:"description,omitempty"` + IntroType IntroType `json:"intro_type"` + IntroContent string `json:"intro_content,omitempty"` + CategoryID string `json:"category_id"` + IsRecommended bool `json:"is_recommended"` + SectionCount int32 `json:"section_count"` + DeletedAt string `json:"deleted_at,omitempty"` + // 当请求带 user_id 时返回:当前用户是否已加入该打卡营 + IsJoined *bool `json:"is_joined,omitempty"` +} + +// CreateCampRequest 创建打卡营请求 +type CreateCampRequest struct { + Title string `json:"title"` + CoverImage string `json:"cover_image,omitempty"` + Description string `json:"description,omitempty"` + IntroType IntroType `json:"intro_type"` + IntroContent string `json:"intro_content,omitempty"` + CategoryID string `json:"category_id"` + IsRecommended bool `json:"is_recommended"` +} + +// CreateCampResponse 创建打卡营响应 +type CreateCampResponse struct { + ID string `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetCampResponse 获取打卡营响应 +type GetCampResponse struct { + Camp *Camp `json:"camp"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// ListCampsRequest 列出打卡营请求 +type ListCampsRequest struct { + Keyword string `json:"keyword" query:"keyword"` // 关键词搜索(可选) + CategoryID string `json:"category_id" query:"category_id"` // 分类筛选(可选) + RecommendFilter RecommendFilter `json:"recommend_filter" query:"recommend_filter"` // 推荐状态筛选 + JoinedOnly int `json:"joined_only" query:"joined_only"` // 仅已加入:1=是,0或未传=否,需配合 user_id + UserID string `json:"user_id" query:"user_id"` // 用户ID(可选,用于获取用户相关状态) + Page int `json:"page" query:"page"` // 页码(从1开始) + PageSize int `json:"page_size" query:"page_size"` // 每页数量 +} + +// ListCampsResponse 列出打卡营响应 +type ListCampsResponse struct { + Camps []*Camp `json:"camps"` + Total int `json:"total"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// UpdateCampRequest 更新打卡营请求 +type UpdateCampRequest struct { + ID string `json:"id"` + Title string `json:"title"` + CoverImage string `json:"cover_image,omitempty"` + Description string `json:"description,omitempty"` + IntroType IntroType `json:"intro_type"` + IntroContent string `json:"intro_content,omitempty"` + CategoryID string `json:"category_id"` + IsRecommended bool `json:"is_recommended"` +} + +// UpdateCampResponse 更新打卡营响应 +type UpdateCampResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// DeleteCampResponse 删除打卡营响应 +type DeleteCampResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// TimeIntervalType 时间间隔类型 +type TimeIntervalType string + +const ( + TimeIntervalTypeNone TimeIntervalType = "none" // 无时间限制 + TimeIntervalTypeHour TimeIntervalType = "hour" // 小时间隔 + TimeIntervalTypeNaturalDay TimeIntervalType = "natural_day" // 自然天 + TimeIntervalTypePaid TimeIntervalType = "paid" // 收费(解锁需支付,价格见 price_fen) +) + +// Section 小节 +type Section struct { + ID string `json:"id"` + CampID string `json:"camp_id"` + Title string `json:"title"` + SectionNumber int32 `json:"section_number"` + PriceFen int32 `json:"price_fen"` + RequirePreviousSection bool `json:"require_previous_section"` + TimeIntervalType TimeIntervalType `json:"time_interval_type"` + TimeIntervalValue int32 `json:"time_interval_value"` + DeletedAt string `json:"deleted_at,omitempty"` +} + +// CreateSectionRequest 创建小节请求 +type CreateSectionRequest struct { + CampID string `json:"camp_id"` + Title string `json:"title"` + SectionNumber int32 `json:"section_number"` + PriceFen int32 `json:"price_fen"` + RequirePreviousSection bool `json:"require_previous_section"` + TimeIntervalType TimeIntervalType `json:"time_interval_type"` + TimeIntervalValue int32 `json:"time_interval_value"` +} + +// CreateSectionResponse 创建小节响应 +type CreateSectionResponse struct { + ID string `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetSectionResponse 获取小节响应 +type GetSectionResponse struct { + Section *Section `json:"section"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// ListSectionsRequest 列出小节请求 +type ListSectionsRequest struct { + Keyword string `json:"keyword" query:"keyword"` // 关键词搜索(可选) + CampID string `json:"camp_id" query:"camp_id"` // 所属打卡营筛选(可选) + UserID string `json:"user_id" query:"user_id"` // 用户ID(可选,用于获取用户相关状态) + Page int `json:"page" query:"page"` // 页码(从1开始) + PageSize int `json:"page_size" query:"page_size"` // 每页数量 +} + +// ListSectionsResponse 列出小节响应 +type ListSectionsResponse struct { + Sections []*Section `json:"sections"` + Total int `json:"total"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// UpdateSectionRequest 更新小节请求 +type UpdateSectionRequest struct { + ID string `json:"id"` + CampID string `json:"camp_id"` + Title string `json:"title"` + SectionNumber int32 `json:"section_number"` + PriceFen int32 `json:"price_fen"` + RequirePreviousSection bool `json:"require_previous_section"` + TimeIntervalType TimeIntervalType `json:"time_interval_type"` + TimeIntervalValue int32 `json:"time_interval_value"` +} + +// UpdateSectionResponse 更新小节响应 +type UpdateSectionResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// DeleteSectionResponse 删除小节响应 +type DeleteSectionResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// PurchaseSectionRequest 购买小节请求 +type PurchaseSectionRequest struct { + UserID string `json:"user_id"` // 用户ID + CampID string `json:"camp_id"` // 打卡营ID + SectionID string `json:"section_id"` // 小节ID +} + +// PurchaseSectionResponse 购买小节响应 +type PurchaseSectionResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + OrderID string `json:"order_id,omitempty"` // 订单ID(如果创建了订单) + OrderStatus string `json:"order_status,omitempty"` // 订单状态 + ActualAmount int32 `json:"actual_amount,omitempty"` // 实际支付金额(分) + IsFree bool `json:"is_free"` // 是否免费(价格为0) + AlreadyOwned bool `json:"already_owned,omitempty"` // 是否已拥有(幂等返回) +} + +// CanUnlockSectionRequest 检查是否可开启小节请求 +type CanUnlockSectionRequest struct { + CampID string `json:"camp_id"` // 打卡营ID + SectionID string `json:"section_id"` // 小节ID + UserID string `json:"user_id"` // 用户ID +} + +// CanUnlockSectionResponse 检查是否可开启小节响应 +type CanUnlockSectionResponse struct { + Success bool `json:"success"` // 接口是否成功 + CanUnlock bool `json:"can_unlock"` // 是否允许开启该小节 + Message string `json:"message"` // 提示信息(如:请先完成上一小节) + UnlockAt int64 `json:"unlock_at,omitempty"` // 可解锁时间(Unix 秒),仅当因时间间隔限制不可解锁时有值,用于前端倒计时 +} + +// TaskType 任务类型 +type TaskType string + +const ( + TaskTypeUnknown TaskType = "unknown" // 未知类型 + TaskTypeImageText TaskType = "image_text" // 图文任务 + TaskTypeVideo TaskType = "video" // 视频任务 + TaskTypeSubjective TaskType = "subjective" // 主观题任务 + TaskTypeObjective TaskType = "objective" // 客观题任务 + TaskTypeEssay TaskType = "essay" // 申论题任务 +) + +// ReviewStatus 审核状态 +type ReviewStatus string + +const ( + ReviewStatusPending ReviewStatus = "pending" // 待审核 + ReviewStatusApproved ReviewStatus = "approved" // 审核通过 + ReviewStatusRejected ReviewStatus = "rejected" // 审核拒绝 +) + +// Task 任务(Content 和 Condition 使用 JSON 存储) +// 主观题 content 结构:{ "subjective": { "pdf_url": "可选", "description": "必填" } } +type Task struct { + ID string `json:"id"` + CampID string `json:"camp_id"` + SectionID string `json:"section_id"` + TaskType TaskType `json:"task_type"` + Title string `json:"title,omitempty"` // 任务标题(可选,用于展示) + Content json.RawMessage `json:"content"` // JSON 格式的任务内容 + Condition json.RawMessage `json:"condition"` // JSON 格式的完成条件 + PrerequisiteTaskID string `json:"prerequisite_task_id,omitempty"` // 前置任务ID,完成后才能开启本任务(递进关系) + DeletedAt string `json:"deleted_at,omitempty"` +} + +// CreateTaskRequest 创建任务请求 +type CreateTaskRequest struct { + CampID string `json:"camp_id"` + SectionID string `json:"section_id"` + TaskType TaskType `json:"task_type"` + Title string `json:"title,omitempty"` + Content json.RawMessage `json:"content"` // JSON 格式的任务内容 + Condition json.RawMessage `json:"condition"` // JSON 格式的完成条件 + PrerequisiteTaskID string `json:"prerequisite_task_id,omitempty"` // 前置任务ID(可选) +} + +// CreateTaskResponse 创建任务响应 +type CreateTaskResponse struct { + ID string `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetTaskResponse 获取任务响应 +type GetTaskResponse struct { + Task *Task `json:"task"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// ListTasksRequest 列出任务请求 +type ListTasksRequest struct { + Keyword string `json:"keyword" query:"keyword"` // 关键词搜索(可选) + CampID string `json:"camp_id" query:"camp_id"` // 所属打卡营筛选(可选) + SectionID string `json:"section_id" query:"section_id"` // 所属小节筛选(可选) + TaskType TaskType `json:"task_type" query:"task_type"` // 任务类型筛选(可选) + Page int `json:"page" query:"page"` // 页码(从1开始) + PageSize int `json:"page_size" query:"page_size"` // 每页数量 +} + +// ListTasksResponse 列出任务响应 +type ListTasksResponse struct { + Tasks []*Task `json:"tasks"` + Total int `json:"total"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// UpdateTaskRequest 更新任务请求 +type UpdateTaskRequest struct { + ID string `json:"id"` + CampID string `json:"camp_id"` + SectionID string `json:"section_id"` + TaskType TaskType `json:"task_type"` + Title string `json:"title,omitempty"` + Content json.RawMessage `json:"content"` // JSON 格式的任务内容 + Condition json.RawMessage `json:"condition"` // JSON 格式的完成条件 + PrerequisiteTaskID string `json:"prerequisite_task_id,omitempty"` // 前置任务ID(可选) +} + +// UpdateTaskResponse 更新任务响应 +type UpdateTaskResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// DeleteTaskResponse 删除任务响应 +type DeleteTaskResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// UserProgress 用户进度 +type UserProgress struct { + ID string `json:"id"` + UserID string `json:"user_id"` + TaskID string `json:"task_id"` + IsCompleted bool `json:"is_completed"` + CompletedAt string `json:"completed_at,omitempty"` + ReviewStatus ReviewStatus `json:"review_status"` + ReviewComment string `json:"review_comment,omitempty"` + ReviewImages []string `json:"review_images,omitempty"` + AnswerImages []string `json:"answer_images,omitempty"` + EssayAnswerImages [][]string `json:"essay_answer_images,omitempty"` + EssayReviewStatuses []string `json:"essay_review_statuses,omitempty"` // 申论题每题审核状态,如 ["PENDING","APPROVED"] + CampID string `json:"camp_id,omitempty"` + SectionID string `json:"section_id,omitempty"` + NeedReview bool `json:"need_review"` + ObjectiveBestCorrectCount int `json:"objective_best_correct_count,omitempty"` // 客观题历史最高正确数 + ObjectiveBestTotalCount int `json:"objective_best_total_count,omitempty"` // 客观题历史最高对应的总题数 +} + +// UpdateUserProgressRequest 更新用户进度请求 +type UpdateUserProgressRequest struct { + UserID string `json:"user_id"` + TaskID string `json:"task_id"` + IsCompleted bool `json:"is_completed"` + CompletedAt string `json:"completed_at,omitempty"` + ReviewStatus ReviewStatus `json:"review_status"` + ReviewComment string `json:"review_comment,omitempty"` + ReviewImages []string `json:"review_images,omitempty"` + AnswerImages []string `json:"answer_images,omitempty"` + EssayAnswerImages [][]string `json:"essay_answer_images,omitempty"` + EssayQuestionCount int `json:"essay_question_count,omitempty"` + EssayReviewStatuses []string `json:"essay_review_statuses,omitempty"` // 申论题每题审核状态,如 ["pending","approved","rejected"] + CampID string `json:"camp_id,omitempty"` + ObjectiveCorrectCount int `json:"objective_correct_count,omitempty"` // 本次客观题正确数 + ObjectiveTotalCount int `json:"objective_total_count,omitempty"` // 本次客观题总题数(试卷题目总数) +} + +// UpdateUserProgressResponse 更新用户进度响应 +type UpdateUserProgressResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetUserProgressRequest 获取用户进度请求 +type GetUserProgressRequest struct { + UserID string `json:"user_id" query:"user_id"` + TaskID string `json:"task_id" query:"task_id"` +} + +// GetUserProgressResponse 获取用户进度响应 +type GetUserProgressResponse struct { + Progress *UserProgress `json:"progress"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// ListUserProgressRequest 列出用户进度请求 +type ListUserProgressRequest struct { + UserID string `json:"user_id" query:"user_id"` // 用户ID(可选,精确匹配) + UserKeyword string `json:"user_keyword" query:"user_keyword"` // 用户关键词:按用户ID模糊 或 手机号匹配(可选) + TaskID string `json:"task_id" query:"task_id"` // 任务ID(可选) + SectionID string `json:"section_id" query:"section_id"` // 小节ID(可选) + CampID string `json:"camp_id" query:"camp_id"` // 打卡营ID(可选) + ReviewStatus string `json:"review_status" query:"review_status"` // 审核状态筛选:pending/approved/rejected(可选) + Page int `json:"page" query:"page"` // 页码(从1开始) + PageSize int `json:"page_size" query:"page_size"` // 每页数量 +} + +// ListUserProgressResponse 列出用户进度响应 +type ListUserProgressResponse struct { + ProgressList []*UserProgress `json:"progress_list"` + UserIDs []string `json:"user_ids,omitempty"` // 当按 camp_id 查询时返回本页用户 ID 列表(含未产生进度的用户) + Total int `json:"total"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// ResetTaskProgressRequest 重置任务进度请求 +type ResetTaskProgressRequest struct { + UserID string `json:"user_id"` + TaskID string `json:"task_id"` +} + +// ResetTaskProgressResponse 重置任务进度响应 +type ResetTaskProgressResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// JoinCampRequest 用户加入打卡营请求 +type JoinCampRequest struct { + UserID string `json:"user_id"` + CampID string `json:"camp_id"` +} + +// JoinCampResponse 用户加入打卡营响应 +type JoinCampResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// CheckUserCampStatusRequest 检查用户打卡营状态请求 +type CheckUserCampStatusRequest struct { + UserID string `json:"user_id" query:"user_id"` + CampID string `json:"camp_id" query:"camp_id"` +} + +// CheckUserCampStatusResponse 检查用户打卡营状态响应 +type CheckUserCampStatusResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + IsJoined bool `json:"is_joined"` + JoinedAt string `json:"joined_at,omitempty"` + CurrentSectionID string `json:"current_section_id,omitempty"` + CampStatus CampStatus `json:"camp_status"` // 打卡营整体状态: not_started / in_progress / completed +} + +// UserJoinedCamp 用户加入的打卡营信息 +type UserJoinedCamp struct { + CampID string `json:"camp_id"` + JoinedAt string `json:"joined_at"` + Status string `json:"status"` +} + +// ListUserCampsRequest 获取用户已加入的打卡营列表请求 +type ListUserCampsRequest struct { + UserID string `json:"user_id" query:"user_id"` + Page int `json:"page" query:"page"` + PageSize int `json:"page_size" query:"page_size"` +} + +// ListUserCampsResponse 获取用户已加入的打卡营列表响应 +type ListUserCampsResponse struct { + Camps []*UserJoinedCamp `json:"camps"` + Total int `json:"total"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// ResetCampProgressRequest 重置打卡营进度请求 +type ResetCampProgressRequest struct { + UserID string `json:"user_id"` + CampID string `json:"camp_id"` + Confirm bool `json:"confirm"` // 需要显式确认 + Note string `json:"note"` // 可选,写入重置历史备注(操作原因、操作者等) +} + +// ResetCampProgressResponse 重置打卡营进度响应 +type ResetCampProgressResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// CampStatus 打卡营状态枚举 +type CampStatus string + +const ( + CampStatusUnknown CampStatus = "unknown" // 未知状态 + CampStatusNotStarted CampStatus = "not_started" // 未开始 + CampStatusInProgress CampStatus = "in_progress" // 进行中 + CampStatusCompleted CampStatus = "completed" // 已完成 +) + +// UserCamp 用户与打卡营的关系 +type UserCamp struct { + IsJoined bool `json:"is_joined"` // 是否已加入 + JoinedAt string `json:"joined_at,omitempty"` // 加入时间(如果已加入) + CurrentSectionID string `json:"current_section_id,omitempty"` // 当前学习的小节ID(如果已加入) + CampStatus CampStatus `json:"camp_status"` // 打卡营整体状态 +} + +// CampDetail 打卡营详情(聚合了打卡营基本信息和用户关系) +type CampDetail struct { + Camp *Camp `json:"camp"` // 打卡营基本信息 + UserCamp *UserCamp `json:"user_camp"` // 用户与打卡营的关系 +} + +// AggregatedTaskDetail 聚合后的任务详情(包含进度信息) +type AggregatedTaskDetail struct { + ID string `json:"id"` // 任务ID + TaskType TaskType `json:"task_type"` // 任务类型 + Title string `json:"title"` // 任务标题(根据类型生成) + Status string `json:"status"` // 任务状态:NotStarted, InProgress, Completed, Reviewing, Rejected + NeedReview bool `json:"need_review"` // 是否需要审核 + AllowNextWhileReviewing bool `json:"allow_next_while_reviewing"` // 审核中是否允许开启下一任务(仅需审核任务有效,默认 true) + ReviewStatus string `json:"review_status"` // 审核状态(如果需要审核) + Progress *UserProgress `json:"progress,omitempty"` // 用户进度(可选) + CanStart bool `json:"can_start"` // 是否可以开始(前置任务已完成) + PrerequisiteTaskID string `json:"prerequisite_task_id,omitempty"` // 前置任务ID(同小节内解锁关系) + Prerequisites []string `json:"prerequisites,omitempty"` // 前置任务ID列表(兼容小程序,仅一个元素) +} + +// UserSectionProgress 用户小节进度 +type UserSectionProgress struct { + SectionID string `json:"section_id"` // 小节ID + TotalTasks int32 `json:"total_tasks"` // 总任务数 + CompletedTasks int32 `json:"completed_tasks"` // 已完成任务数 + IsCompleted bool `json:"is_completed"` // 是否已完成 +} + +// AggregatedSectionDetail 聚合后的小节详情(包含购买状态、完成状态、任务列表) +type AggregatedSectionDetail struct { + ID string `json:"id"` // 小节ID + Title string `json:"title"` // 小节标题 + SectionNumber int32 `json:"section_number"` // 小节编号 + PriceFen int32 `json:"price_fen"` // 售价(分) + IsPurchased bool `json:"is_purchased"` // 是否已购买(已支付订单或授权) + IsStarted bool `json:"is_started"` // 是否已开始 + IsCompleted bool `json:"is_completed"` // 是否已完成 + IsCurrent bool `json:"is_current"` // 是否为当前学习的小节 + RequirePreviousSection bool `json:"require_previous_section"` // 是否需要完成上一章节 + TimeIntervalType TimeIntervalType `json:"time_interval_type"` // 时间间隔类型 + TimeIntervalValue int32 `json:"time_interval_value"` // 时间间隔值 + Tasks []*AggregatedTaskDetail `json:"tasks"` // 任务列表 + SectionProgress *UserSectionProgress `json:"section_progress,omitempty"` // 小节进度 +} + +// GetCampDetailWithStatusRequest 获取打卡营详情及状态请求 +type GetCampDetailWithStatusRequest struct { + CampID string `json:"camp_id"` // 打卡营ID + UserID string `json:"user_id"` // 用户ID +} + +// GetCampDetailWithStatusResponse 获取打卡营详情及状态响应 +type GetCampDetailWithStatusResponse struct { + CampDetail *CampDetail `json:"camp_detail"` // 打卡营详情(包含基本信息、用户状态、整体状态) + Sections []*AggregatedSectionDetail `json:"sections"` // 所有小节列表(树状结构:每个小节包含其任务列表) + Success bool `json:"success"` + Message string `json:"message"` +} + +// CanStartTaskRequest 检查任务是否可开始请求 +type CanStartTaskRequest struct { + UserID string `json:"user_id"` + TaskID string `json:"task_id"` +} + +// CanStartTaskResponse 检查任务是否可开始响应 +type CanStartTaskResponse struct { + CanStart bool `json:"can_start"` // 是否可以开始 + Reason string `json:"reason,omitempty"` // 不可开始的原因 + Success bool `json:"success"` + Message string `json:"message"` +} diff --git a/internal/document/dao/file_dao.go b/internal/document/dao/file_dao.go new file mode 100644 index 0000000..bed2e77 --- /dev/null +++ b/internal/document/dao/file_dao.go @@ -0,0 +1,150 @@ +package dao + +import ( + "database/sql" + "fmt" + + "dd_fiber_api/internal/document" + "dd_fiber_api/pkg/database" + + "github.com/didi/gendry/builder" +) + +// FileDAO 文档文件数据访问 +type FileDAO struct { + client *database.MySQLClient +} + +// NewFileDAO 创建 FileDAO +func NewFileDAO(client *database.MySQLClient) *FileDAO { + return &FileDAO{client: client} +} + +// Create 创建文档记录 +func (d *FileDAO) Create(f *document.DocFile) error { + if d.client == nil { + return fmt.Errorf("mysql client is nil") + } + table := "doc_files" + data := []map[string]any{ + { + "id": f.ID, + "folder_id": f.FolderID, + "name": f.Name, + "file_name": f.FileName, + "file_url": f.FileURL, + "file_size": f.FileSize, + "mime_type": f.MimeType, + }, + } + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return err + } + _, err = d.client.DB.Exec(cond, vals...) + return err +} + +// GetByID 根据 ID 获取 +func (d *FileDAO) GetByID(id string) (*document.DocFile, error) { + if d.client == nil { + return nil, fmt.Errorf("mysql client is nil") + } + table := "doc_files" + where := map[string]any{"id": id} + cond, vals, err := builder.BuildSelect(table, where, []string{"id", "folder_id", "name", "file_name", "file_url", "file_size", "mime_type", "created_at", "updated_at"}) + if err != nil { + return nil, err + } + var f document.DocFile + var createdAt, updatedAt sql.NullString + err = d.client.DB.QueryRow(cond, vals...).Scan(&f.ID, &f.FolderID, &f.Name, &f.FileName, &f.FileURL, &f.FileSize, &f.MimeType, &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if createdAt.Valid { + f.CreatedAt = createdAt.String + } + if updatedAt.Valid { + f.UpdatedAt = updatedAt.String + } + return &f, nil +} + +// Update 更新(仅名称等可改) +func (d *FileDAO) Update(f *document.DocFile) error { + if d.client == nil { + return fmt.Errorf("mysql client is nil") + } + table := "doc_files" + where := map[string]any{"id": f.ID} + data := map[string]any{"name": f.Name} + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return err + } + _, err = d.client.DB.Exec(cond, vals...) + return err +} + +// Delete 删除 +func (d *FileDAO) Delete(id string) error { + if d.client == nil { + return fmt.Errorf("mysql client is nil") + } + table := "doc_files" + where := map[string]any{"id": id} + cond, vals, err := builder.BuildDelete(table, where) + if err != nil { + return err + } + _, err = d.client.DB.Exec(cond, vals...) + return err +} + +// ListByFolderID 按文件夹 ID 列出文件 +func (d *FileDAO) ListByFolderID(folderID string) ([]*document.DocFile, error) { + if d.client == nil { + return nil, fmt.Errorf("mysql client is nil") + } + table := "doc_files" + where := map[string]any{"folder_id": folderID} + cond, vals, err := builder.BuildSelect(table, where, []string{"id", "folder_id", "name", "file_name", "file_url", "file_size", "mime_type", "created_at", "updated_at"}) + if err != nil { + return nil, err + } + cond += " ORDER BY created_at DESC" + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*document.DocFile + for rows.Next() { + var f document.DocFile + var createdAt, updatedAt sql.NullString + if err := rows.Scan(&f.ID, &f.FolderID, &f.Name, &f.FileName, &f.FileURL, &f.FileSize, &f.MimeType, &createdAt, &updatedAt); err != nil { + continue + } + if createdAt.Valid { + f.CreatedAt = createdAt.String + } + if updatedAt.Valid { + f.UpdatedAt = updatedAt.String + } + list = append(list, &f) + } + return list, rows.Err() +} + +// DeleteByFolderID 删除该文件夹下所有文件记录 +func (d *FileDAO) DeleteByFolderID(folderID string) error { + if d.client == nil { + return fmt.Errorf("mysql client is nil") + } + _, err := d.client.DB.Exec("DELETE FROM doc_files WHERE folder_id = ?", folderID) + return err +} diff --git a/internal/document/dao/folder_dao.go b/internal/document/dao/folder_dao.go new file mode 100644 index 0000000..6e5b71d --- /dev/null +++ b/internal/document/dao/folder_dao.go @@ -0,0 +1,128 @@ +package dao + +import ( + "database/sql" + "fmt" + + "dd_fiber_api/internal/document" + "dd_fiber_api/pkg/database" + + "github.com/didi/gendry/builder" +) + +// FolderDAO 文件夹数据访问 +type FolderDAO struct { + client *database.MySQLClient +} + +// NewFolderDAO 创建 FolderDAO +func NewFolderDAO(client *database.MySQLClient) *FolderDAO { + return &FolderDAO{client: client} +} + +// Create 创建文件夹 +func (d *FolderDAO) Create(f *document.DocFolder) error { + if d.client == nil { + return fmt.Errorf("mysql client is nil") + } + table := "doc_folders" + data := []map[string]any{ + {"id": f.ID, "name": f.Name, "sort_order": f.SortOrder}, + } + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return err + } + _, err = d.client.DB.Exec(cond, vals...) + return err +} + +// GetByID 根据 ID 获取 +func (d *FolderDAO) GetByID(id string) (*document.DocFolder, error) { + if d.client == nil { + return nil, fmt.Errorf("mysql client is nil") + } + table := "doc_folders" + where := map[string]any{"id": id} + cond, vals, err := builder.BuildSelect(table, where, []string{"id", "name", "sort_order", "created_at", "updated_at"}) + if err != nil { + return nil, err + } + var f document.DocFolder + var createdAt, updatedAt sql.NullString + err = d.client.DB.QueryRow(cond, vals...).Scan(&f.ID, &f.Name, &f.SortOrder, &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if createdAt.Valid { + f.CreatedAt = createdAt.String + } + if updatedAt.Valid { + f.UpdatedAt = updatedAt.String + } + return &f, nil +} + +// Update 更新 +func (d *FolderDAO) Update(f *document.DocFolder) error { + if d.client == nil { + return fmt.Errorf("mysql client is nil") + } + table := "doc_folders" + where := map[string]any{"id": f.ID} + data := map[string]any{"name": f.Name, "sort_order": f.SortOrder} + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return err + } + _, err = d.client.DB.Exec(cond, vals...) + return err +} + +// Delete 删除 +func (d *FolderDAO) Delete(id string) error { + if d.client == nil { + return fmt.Errorf("mysql client is nil") + } + table := "doc_folders" + where := map[string]any{"id": id} + cond, vals, err := builder.BuildDelete(table, where) + if err != nil { + return err + } + _, err = d.client.DB.Exec(cond, vals...) + return err +} + +// List 列出所有文件夹(按 sort_order 升序) +func (d *FolderDAO) List() ([]*document.DocFolder, error) { + if d.client == nil { + return nil, fmt.Errorf("mysql client is nil") + } + table := "doc_folders" + cond := "SELECT id, name, sort_order, created_at, updated_at FROM " + table + " ORDER BY sort_order ASC, id ASC" + rows, err := d.client.DB.Query(cond) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*document.DocFolder + for rows.Next() { + var f document.DocFolder + var createdAt, updatedAt sql.NullString + if err := rows.Scan(&f.ID, &f.Name, &f.SortOrder, &createdAt, &updatedAt); err != nil { + continue + } + if createdAt.Valid { + f.CreatedAt = createdAt.String + } + if updatedAt.Valid { + f.UpdatedAt = updatedAt.String + } + list = append(list, &f) + } + return list, rows.Err() +} diff --git a/internal/document/handler/document_handler.go b/internal/document/handler/document_handler.go new file mode 100644 index 0000000..e80aed1 --- /dev/null +++ b/internal/document/handler/document_handler.go @@ -0,0 +1,123 @@ +package handler + +import ( + "dd_fiber_api/internal/document" + "dd_fiber_api/internal/document/service" + + "github.com/gofiber/fiber/v2" +) + +// Handler 文档管理 Handler +type Handler struct { + svc *service.DocumentService +} + +// NewHandler 创建 Handler +func NewHandler(svc *service.DocumentService) *Handler { + return &Handler{svc: svc} +} + +// ListFolders 列出文件夹 +func (h *Handler) ListFolders(c *fiber.Ctx) error { + list, err := h.svc.ListFolders() + if err != nil { + return c.Status(500).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true, "list": list}) +} + +// CreateFolder 创建文件夹 +func (h *Handler) CreateFolder(c *fiber.Ctx) error { + var req document.CreateFolderRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "参数错误"}) + } + if req.Name == "" { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "文件夹名称不能为空"}) + } + f, err := h.svc.CreateFolder(&req) + if err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true, "folder": f}) +} + +// UpdateFolder 更新文件夹 +func (h *Handler) UpdateFolder(c *fiber.Ctx) error { + var req document.UpdateFolderRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "参数错误"}) + } + if req.ID == "" || req.Name == "" { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "id 和 name 必填"}) + } + if err := h.svc.UpdateFolder(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true}) +} + +// DeleteFolder 删除文件夹 +func (h *Handler) DeleteFolder(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "id 必填"}) + } + if err := h.svc.DeleteFolder(id); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true}) +} + +// ListFiles 列出文件夹下的文件 +func (h *Handler) ListFiles(c *fiber.Ctx) error { + folderID := c.Query("folder_id") + if folderID == "" { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "folder_id 必填"}) + } + list, err := h.svc.ListFiles(folderID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true, "list": list}) +} + +// CreateFile 创建文档(上传后保存记录,file_url 来自 OSS 等) +func (h *Handler) CreateFile(c *fiber.Ctx) error { + var req document.CreateFileRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "参数错误"}) + } + f, err := h.svc.CreateFile(&req) + if err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true, "file": f}) +} + +// UpdateFile 更新文档名称 +func (h *Handler) UpdateFile(c *fiber.Ctx) error { + var req document.UpdateFileRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "参数错误"}) + } + if req.ID == "" { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "id 必填"}) + } + if err := h.svc.UpdateFile(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true}) +} + +// DeleteFile 删除文档 +func (h *Handler) DeleteFile(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(400).JSON(fiber.Map{"success": false, "message": "id 必填"}) + } + if err := h.svc.DeleteFile(id); err != nil { + return c.Status(400).JSON(fiber.Map{"success": false, "message": err.Error()}) + } + return c.JSON(fiber.Map{"success": true}) +} diff --git a/internal/document/service/document_service.go b/internal/document/service/document_service.go new file mode 100644 index 0000000..6bbaa30 --- /dev/null +++ b/internal/document/service/document_service.go @@ -0,0 +1,105 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/document" + "dd_fiber_api/internal/document/dao" + "dd_fiber_api/pkg/snowflake" +) + +// DocumentService 文档管理服务 +type DocumentService struct { + folderDAO *dao.FolderDAO + fileDAO *dao.FileDAO +} + +// NewDocumentService 创建 DocumentService +func NewDocumentService(folderDAO *dao.FolderDAO, fileDAO *dao.FileDAO) *DocumentService { + return &DocumentService{folderDAO: folderDAO, fileDAO: fileDAO} +} + +// ListFolders 列出所有文件夹 +func (s *DocumentService) ListFolders() ([]*document.DocFolder, error) { + return s.folderDAO.List() +} + +// CreateFolder 创建文件夹 +func (s *DocumentService) CreateFolder(req *document.CreateFolderRequest) (*document.DocFolder, error) { + id := snowflake.GetInstance().NextIDString() + f := &document.DocFolder{ + ID: id, + Name: req.Name, + SortOrder: req.SortOrder, + } + if err := s.folderDAO.Create(f); err != nil { + return nil, err + } + return s.folderDAO.GetByID(id) +} + +// UpdateFolder 更新文件夹 +func (s *DocumentService) UpdateFolder(req *document.UpdateFolderRequest) error { + f, err := s.folderDAO.GetByID(req.ID) + if err != nil || f == nil { + return fmt.Errorf("文件夹不存在") + } + f.Name = req.Name + f.SortOrder = req.SortOrder + return s.folderDAO.Update(f) +} + +// DeleteFolder 删除文件夹(同时删除其下文件记录) +func (s *DocumentService) DeleteFolder(id string) error { + _ = s.fileDAO.DeleteByFolderID(id) + return s.folderDAO.Delete(id) +} + +// ListFiles 列出文件夹下的文件 +func (s *DocumentService) ListFiles(folderID string) ([]*document.DocFile, error) { + return s.fileDAO.ListByFolderID(folderID) +} + +// CreateFile 创建文档记录 +func (s *DocumentService) CreateFile(req *document.CreateFileRequest) (*document.DocFile, error) { + if req.FolderID == "" || req.FileURL == "" { + return nil, fmt.Errorf("folder_id 和 file_url 必填") + } + folder, err := s.folderDAO.GetByID(req.FolderID) + if err != nil || folder == nil { + return nil, fmt.Errorf("文件夹不存在") + } + id := snowflake.GetInstance().NextIDString() + name := req.Name + if name == "" { + name = req.FileName + } + f := &document.DocFile{ + ID: id, + FolderID: req.FolderID, + Name: name, + FileName: req.FileName, + FileURL: req.FileURL, + FileSize: req.FileSize, + MimeType: req.MimeType, + } + if err := s.fileDAO.Create(f); err != nil { + return nil, err + } + return s.fileDAO.GetByID(id) +} + +// UpdateFile 更新文档(名称) +func (s *DocumentService) UpdateFile(req *document.UpdateFileRequest) error { + f, err := s.fileDAO.GetByID(req.ID) + if err != nil || f == nil { + return fmt.Errorf("文档不存在") + } + f.Name = req.Name + return s.fileDAO.Update(f) +} + +// DeleteFile 删除文档记录 +func (s *DocumentService) DeleteFile(id string) error { + return s.fileDAO.Delete(id) +} diff --git a/internal/document/types.go b/internal/document/types.go new file mode 100644 index 0000000..f1f2414 --- /dev/null +++ b/internal/document/types.go @@ -0,0 +1,52 @@ +package document + +// DocFolder 文档文件夹 +type DocFolder struct { + ID string `json:"id"` + Name string `json:"name"` + SortOrder int `json:"sort_order"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// DocFile 文档文件 +type DocFile struct { + ID string `json:"id"` + FolderID string `json:"folder_id"` + Name string `json:"name"` + FileName string `json:"file_name"` + FileURL string `json:"file_url"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// CreateFolderRequest 创建文件夹请求 +type CreateFolderRequest struct { + Name string `json:"name"` + SortOrder int `json:"sort_order"` +} + +// UpdateFolderRequest 更新文件夹请求 +type UpdateFolderRequest struct { + ID string `json:"id"` + Name string `json:"name"` + SortOrder int `json:"sort_order"` +} + +// CreateFileRequest 创建文档请求 +type CreateFileRequest struct { + FolderID string `json:"folder_id"` + Name string `json:"name"` + FileName string `json:"file_name"` + FileURL string `json:"file_url"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` +} + +// UpdateFileRequest 更新文档请求 +type UpdateFileRequest struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/internal/order/dao/order_dao.go b/internal/order/dao/order_dao.go new file mode 100644 index 0000000..0e5737a --- /dev/null +++ b/internal/order/dao/order_dao.go @@ -0,0 +1,621 @@ +package dao + +import ( + "database/sql" + "fmt" + "time" + + "dd_fiber_api/internal/order" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/utils" + + "github.com/didi/gendry/builder" +) + +// OrderDAO 订单数据访问对象 +type OrderDAO struct { + client *database.MySQLClient +} + +// NewOrderDAO 创建订单DAO实例 +func NewOrderDAO(client *database.MySQLClient) *OrderDAO { + return &OrderDAO{client: client} +} + +// CreateOrder 创建订单(使用新的 orders 表) +func (d *OrderDAO) CreateOrder( + orderID, userID string, + orderType order.OrderType, + originalAmount, discountAmount, actualAmount int32, + couponID string, + status order.OrderStatus, + paymentMethod order.PaymentMethod, + paymentTime *time.Time, +) error { + table := "orders" + + data := []map[string]any{{ + "order_id": orderID, + "user_id": userID, + "order_type": string(orderType), + "original_amount": originalAmount, + "discount_amount": discountAmount, + "actual_amount": actualAmount, + "coupon_id": couponID, + "status": string(status), + }} + + // 处理支付方式:如果是空字符串(PaymentMethodUnknown),使用 NULL + if paymentMethod == order.PaymentMethodUnknown || paymentMethod == "" { + data[0]["payment_method"] = nil + } else { + data[0]["payment_method"] = string(paymentMethod) + } + + // 如果提供了支付时间(0元订单直接完成),添加到数据中 + if paymentTime != nil { + data[0]["payment_time"] = *paymentTime + } + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入失败: %v", err) + } + + // 添加幂等性处理(如果订单ID已存在,不更新) + cond += " ON DUPLICATE KEY UPDATE order_id=order_id" + + if _, err := d.client.DB.Exec(cond, vals...); err != nil { + return fmt.Errorf("创建订单失败: %v", err) + } + return nil +} + +// CreateOrderBusinessData 创建订单业务数据(关联订单和小节) +func (d *OrderDAO) CreateOrderBusinessData(orderID, campID, sectionID string) error { + table := "order_business_data" + + data := []map[string]any{{ + "order_id": orderID, + "camp_id": campID, + "section_id": sectionID, + }} + + cond, vals, err := builder.BuildInsert(table, data) + if err != nil { + return fmt.Errorf("构建插入失败: %v", err) + } + + // 添加幂等性处理 + cond += " ON DUPLICATE KEY UPDATE order_id=order_id" + + if _, err := d.client.DB.Exec(cond, vals...); err != nil { + // 如果表不存在,忽略错误(向后兼容) + return nil + } + return nil +} + +// CheckUserHasSection 检查用户是否已拥有某个小节(通过查询已支付的订单) +func (d *OrderDAO) CheckUserHasSection(userID, sectionID string) (bool, error) { + // 先尝试查询 order_business_data 表 + table := "order_business_data" + where := map[string]any{ + "section_id": sectionID, + } + + // 查询该小节的所有订单ID + cond, vals, err := builder.BuildSelect(table, where, []string{"order_id"}) + if err != nil { + // 如果表不存在,返回 false(向后兼容) + return false, nil + } + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + // 如果表不存在,返回 false(向后兼容) + return false, nil + } + defer rows.Close() + + var orderIDs []string + for rows.Next() { + var orderID string + if err := rows.Scan(&orderID); err != nil { + continue + } + orderIDs = append(orderIDs, orderID) + } + + if len(orderIDs) == 0 { + return false, nil + } + + // 查询这些订单中是否有该用户的已支付订单 + ordersTable := "orders" + ordersWhere := map[string]any{ + "user_id": userID, + "status": string(order.OrderStatusPaid), + } + + // 构建 IN 查询 + ordersCond, ordersVals, err := builder.BuildSelect(ordersTable, ordersWhere, []string{"COUNT(*) as count"}) + if err != nil { + return false, nil + } + + // 添加 order_id IN (...) 条件 + if len(orderIDs) > 0 { + placeholders := "" + for i, id := range orderIDs { + if i > 0 { + placeholders += "," + } + placeholders += "?" + ordersVals = append(ordersVals, id) + } + ordersCond += " AND order_id IN (" + placeholders + ")" + } + + var count int + if err := d.client.DB.QueryRow(ordersCond, ordersVals...).Scan(&count); err != nil { + return false, nil + } + + return count > 0, nil +} + +// GetUserSectionAccessTime 获取用户某小节的「开启」时间(上一节开启时的起点:已支付订单的 payment_time,若无则 created_at) +func (d *OrderDAO) GetUserSectionAccessTime(userID, sectionID string) (*time.Time, error) { + table := "order_business_data" + where := map[string]any{"section_id": sectionID} + cond, vals, err := builder.BuildSelect(table, where, []string{"order_id"}) + if err != nil { + return nil, nil + } + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, nil + } + defer rows.Close() + var orderIDs []string + for rows.Next() { + var orderID string + if err := rows.Scan(&orderID); err != nil { + continue + } + orderIDs = append(orderIDs, orderID) + } + if len(orderIDs) == 0 { + return nil, nil + } + placeholders := "" + for i := range orderIDs { + if i > 0 { + placeholders += "," + } + placeholders += "?" + } + args := make([]any, 0, len(orderIDs)+2) + args = append(args, userID, string(order.OrderStatusPaid)) + for _, id := range orderIDs { + args = append(args, id) + } + q := "SELECT COALESCE(payment_time, created_at) FROM orders WHERE user_id=? AND status=? AND order_id IN (" + placeholders + ") ORDER BY COALESCE(payment_time, created_at) DESC LIMIT 1" + var accessAt sql.NullTime + err = d.client.DB.QueryRow(q, args...).Scan(&accessAt) + if err != nil || !accessAt.Valid { + return nil, nil + } + t := accessAt.Time + return &t, nil +} + +// GetOrderByID 根据订单ID查询订单 +func (d *OrderDAO) GetOrderByID(orderID string) (*order.Order, error) { + return d.getOrderByCondition(map[string]any{"order_id": orderID}) +} + +// GetOrderByOrderNo 根据订单号查询订单(兼容方法,新表使用 order_id) +func (d *OrderDAO) GetOrderByOrderNo(orderNo string) (*order.Order, error) { + return d.getOrderByCondition(map[string]any{"order_id": orderNo}) +} + +// getOrderByCondition 根据条件查询订单(内部方法,使用新的 orders 表) +// 使用 Go 代码实现业务数据关联,避免 JOIN 查询 +func (d *OrderDAO) getOrderByCondition(where map[string]any) (*order.Order, error) { + table := "orders" + + // 先查询订单表(不使用 JOIN) + selectFields := []string{ + "order_id", "user_id", "order_type", "original_amount", "discount_amount", + "actual_amount", "coupon_id", "status", "payment_method", "transaction_id", + "payment_time", "created_at", "updated_at", + } + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, fmt.Errorf("构建查询失败: %v", err) + } + cond += " LIMIT 1" + + var ( + orderID, userID, orderTypeStr, couponID, statusStr, paymentMethodStr, transactionID sql.NullString + originalAmount, discountAmount, actualAmount int32 + createdAt, updatedAt, paymentTime sql.NullTime + ) + + err = d.client.DB.QueryRow(cond, vals...).Scan( + &orderID, &userID, &orderTypeStr, &originalAmount, &discountAmount, + &actualAmount, &couponID, &statusStr, &paymentMethodStr, &transactionID, + &paymentTime, &createdAt, &updatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // 订单不存在 + } + return nil, fmt.Errorf("查询订单失败: %v", err) + } + + result := &order.Order{ + OrderID: orderID.String, + UserID: userID.String, + OrderType: order.OrderType(orderTypeStr.String), + OriginalAmount: originalAmount, + DiscountAmount: discountAmount, + ActualAmount: actualAmount, + CouponID: couponID.String, + Status: order.OrderStatus(statusStr.String), + PaymentMethod: order.PaymentMethod(paymentMethodStr.String), + TransactionID: transactionID.String, + PaymentTime: utils.FormatNullTimeToStd(paymentTime), + CreatedAt: utils.FormatNullTimeToStd(createdAt), + UpdatedAt: utils.FormatNullTimeToStd(updatedAt), + } + + // 使用 Go 代码查询业务数据并关联 + if orderID.Valid { + businessDataMap, err := d.getOrderBusinessDataBatch([]string{orderID.String}) + if err == nil { + if data, ok := businessDataMap[orderID.String]; ok { + result.CampID = data.CampID + result.SectionID = data.SectionID + } + } + } + + return result, nil +} + +// ListOrders 查询订单列表(支持多条件筛选和分页,使用新的 orders 表) +func (d *OrderDAO) ListOrders( + userID, campID, sectionID string, + orderStatus order.OrderStatus, + paymentMethod order.PaymentMethod, + page, pageSize int, +) ([]*order.Order, int, error) { + table := "orders" + where := make(map[string]any) + + if userID != "" { + where["user_id"] = userID + } + // 注意:新的 orders 表没有 camp_id 和 section_id 字段,这些信息需要从业务数据中获取 + // 如果传入这些参数,可以通过 order_type = 'CAMP_SECTION' 来筛选打卡营小节订单 + if campID != "" || sectionID != "" { + where["order_type"] = string(order.OrderTypeCampSection) + } + if orderStatus != order.OrderStatusUnknown && orderStatus != "" { + where["status"] = string(orderStatus) + } + if paymentMethod != order.PaymentMethodUnknown && paymentMethod != "" { + where["payment_method"] = string(paymentMethod) + } + + // count - 直接查询 orders 表(不使用 JOIN) + countCond, countVals, err := builder.BuildSelect(table, where, []string{"COUNT(*) as total"}) + if err != nil { + return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) + } + var total int + if err := d.client.DB.QueryRow(countCond, countVals...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("查询总数失败: %v", err) + } + + // select - 先查询订单表(不使用 JOIN) + selectFields := []string{ + "order_id", "user_id", "order_type", "original_amount", "discount_amount", + "actual_amount", "coupon_id", "status", "payment_method", "transaction_id", + "payment_time", "created_at", "updated_at", + } + cond, vals, err := builder.BuildSelect(table, where, selectFields) + if err != nil { + return nil, 0, fmt.Errorf("构建查询失败: %v", err) + } + offset := (page - 1) * pageSize + cond += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + vals = append(vals, pageSize, offset) + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + return nil, 0, fmt.Errorf("查询订单列表失败: %v", err) + } + defer rows.Close() + + list := make([]*order.Order, 0) + orderIDs := make([]string, 0) + + // 第一遍:读取所有订单数据 + for rows.Next() { + var ( + orderID, userID, orderTypeStr, couponID, statusStr, paymentMethodStr, transactionID sql.NullString + originalAmount, discountAmount, actualAmount int32 + createdAt, updatedAt, paymentTime sql.NullTime + ) + if err := rows.Scan( + &orderID, &userID, &orderTypeStr, &originalAmount, &discountAmount, + &actualAmount, &couponID, &statusStr, &paymentMethodStr, &transactionID, + &paymentTime, &createdAt, &updatedAt, + ); err != nil { + continue + } + orderObj := &order.Order{ + OrderID: orderID.String, + UserID: userID.String, + OrderType: order.OrderType(orderTypeStr.String), + OriginalAmount: originalAmount, + DiscountAmount: discountAmount, + ActualAmount: actualAmount, + CouponID: couponID.String, + Status: order.OrderStatus(statusStr.String), + PaymentMethod: order.PaymentMethod(paymentMethodStr.String), + TransactionID: transactionID.String, + PaymentTime: utils.FormatNullTimeToStd(paymentTime), + CreatedAt: utils.FormatNullTimeToStd(createdAt), + UpdatedAt: utils.FormatNullTimeToStd(updatedAt), + } + list = append(list, orderObj) + orderIDs = append(orderIDs, orderID.String) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("遍历订单列表失败: %v", err) + } + + // 第二遍:批量查询业务数据并关联(使用 Go 代码实现,避免 JOIN) + if len(orderIDs) > 0 { + businessDataMap, err := d.getOrderBusinessDataBatch(orderIDs) + if err == nil { + // 将业务数据关联到订单对象 + for _, orderObj := range list { + if data, ok := businessDataMap[orderObj.OrderID]; ok { + orderObj.CampID = data.CampID + orderObj.SectionID = data.SectionID + } + } + } + } + return list, total, nil +} + +// OrderBusinessData 订单业务数据 +type OrderBusinessData struct { + OrderID string + CampID string + SectionID string +} + +// getOrderBusinessDataBatch 批量查询订单业务数据 +func (d *OrderDAO) getOrderBusinessDataBatch(orderIDs []string) (map[string]*OrderBusinessData, error) { + if len(orderIDs) == 0 { + return make(map[string]*OrderBusinessData), nil + } + + table := "order_business_data" + + // 构建 IN 查询 + placeholders := "" + vals := make([]any, 0) + for i, id := range orderIDs { + if i > 0 { + placeholders += "," + } + placeholders += "?" + vals = append(vals, id) + } + + cond := "SELECT order_id, camp_id, section_id FROM " + table + " WHERE order_id IN (" + placeholders + ")" + + rows, err := d.client.DB.Query(cond, vals...) + if err != nil { + // 如果表不存在,返回空映射(向后兼容) + return make(map[string]*OrderBusinessData), nil + } + defer rows.Close() + + result := make(map[string]*OrderBusinessData) + for rows.Next() { + var ( + orderID, campID, sectionID sql.NullString + ) + if err := rows.Scan(&orderID, &campID, §ionID); err != nil { + continue + } + if orderID.Valid { + result[orderID.String] = &OrderBusinessData{ + OrderID: orderID.String, + CampID: campID.String, + SectionID: sectionID.String, + } + } + } + + return result, nil +} + +// UpdateOrderStatus 更新订单状态(使用新的 orders 表) +func (d *OrderDAO) UpdateOrderStatus( + orderID, orderNo string, + orderStatus order.OrderStatus, + paymentMethod order.PaymentMethod, + thirdPartyOrderNo string, + paymentTime *time.Time, +) error { + table := "orders" + where := make(map[string]any) + if orderID != "" { + where["order_id"] = orderID + } else if orderNo != "" { + where["order_id"] = orderNo // 新表使用 order_id + } else { + return fmt.Errorf("订单ID和订单号不能同时为空") + } + + data := make(map[string]any) + if orderStatus != order.OrderStatusUnknown && orderStatus != "" { + data["status"] = string(orderStatus) + } + if paymentMethod != order.PaymentMethodUnknown && paymentMethod != "" { + data["payment_method"] = string(paymentMethod) + } + if thirdPartyOrderNo != "" { + data["transaction_id"] = thirdPartyOrderNo + } + if paymentTime != nil { + data["payment_time"] = *paymentTime + } + + if len(data) == 0 { + return fmt.Errorf("没有需要更新的字段") + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("更新订单状态失败: %v", err) + } + return nil +} + +// RefundOrder 退款订单(使用新的 orders 表,注意:新表没有 refund_amount_fen, refund_reason, refund_time 字段) +// 退款操作只需要更新订单状态为 REFUNDED +func (d *OrderDAO) RefundOrder( + orderID, orderNo string, + refundAmountFen int32, + refundReason string, +) error { + table := "orders" + where := make(map[string]any) + if orderID != "" { + where["order_id"] = orderID + } else if orderNo != "" { + where["order_id"] = orderNo // 新表使用 order_id + } else { + return fmt.Errorf("订单ID和订单号不能同时为空") + } + + // 新表只有 status 字段,退款时更新为 REFUNDED + // 注意:refund_amount_fen, refund_reason, refund_time 字段在新表中不存在 + // 如果需要记录这些信息,可能需要额外的退款记录表 + data := map[string]any{ + "status": string(order.OrderStatusRefunded), + } + + cond, vals, err := builder.BuildUpdate(table, where, data) + if err != nil { + return fmt.Errorf("构建更新语句失败: %v", err) + } + _, err = d.client.DB.Exec(cond, vals...) + if err != nil { + return fmt.Errorf("退款订单失败: %v", err) + } + return nil +} + +// ========== 辅助函数 ========== + +// convertOrderStatus 转换订单状态枚举为数据库值 +func convertOrderStatus(status order.OrderStatus) *string { + switch status { + case order.OrderStatusPending: + s := "PENDING" + return &s + case order.OrderStatusPaid: + s := "PAID" + return &s + case order.OrderStatusFailed: + s := "FAILED" + return &s + case order.OrderStatusRefunded: + s := "REFUNDED" + return &s + case order.OrderStatusCancelled: + s := "CANCELLED" + return &s + default: + return nil // NULL + } +} + +// parseOrderStatus 解析订单状态字符串为枚举 +func parseOrderStatus(s string) order.OrderStatus { + switch s { + case "PENDING": + return order.OrderStatusPending + case "PAID": + return order.OrderStatusPaid + case "FAILED": + return order.OrderStatusFailed + case "REFUNDED": + return order.OrderStatusRefunded + case "CANCELLED": + return order.OrderStatusCancelled + default: + return order.OrderStatusUnknown + } +} + +// convertPaymentMethod 转换支付方式枚举为数据库值 +func convertPaymentMethod(method order.PaymentMethod) *string { + switch method { + case order.PaymentMethodWechat: + s := "WECHAT" + return &s + case order.PaymentMethodAlipay: + s := "ALIPAY" + return &s + case order.PaymentMethodBalance: + s := "BALANCE" + return &s + default: + return nil // NULL + } +} + +// parsePaymentMethod 解析支付方式字符串为枚举 +func parsePaymentMethod(s string) order.PaymentMethod { + switch s { + case "WECHAT": + return order.PaymentMethodWechat + case "ALIPAY": + return order.PaymentMethodAlipay + case "BALANCE": + return order.PaymentMethodBalance + default: + return order.PaymentMethodUnknown + } +} + +// parseAccessSource 解析访问来源字符串为枚举 +func parseAccessSource(s string) order.AccessSource { + switch s { + case "GRANT": + return order.AccessSourceGrant + case "PURCHASE": + return order.AccessSourcePurchase + default: + return order.AccessSourceUnknown + } +} diff --git a/internal/order/handler/order_handler.go b/internal/order/handler/order_handler.go new file mode 100644 index 0000000..2b19672 --- /dev/null +++ b/internal/order/handler/order_handler.go @@ -0,0 +1,237 @@ +package handler + +import ( + "dd_fiber_api/internal/order" + "dd_fiber_api/internal/order/service" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// OrderHandler 订单处理器 +type OrderHandler struct { + orderService *service.OrderService +} + +// NewOrderHandler 创建订单处理器 +func NewOrderHandler(orderService *service.OrderService) *OrderHandler { + return &OrderHandler{ + orderService: orderService, + } +} + +// CreateOrder 创建订单 +func (h *OrderHandler) CreateOrder(c *fiber.Ctx) error { + var req order.CreateOrderRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + if req.CampID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "打卡营ID不能为空", + }) + } + if req.SectionID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "小节ID不能为空", + }) + } + + resp, err := h.orderService.CreateOrder(c.Context(), &req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "创建订单失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetOrder 获取订单详情 +func (h *OrderHandler) GetOrder(c *fiber.Ctx) error { + var req order.GetOrderRequest + if err := c.QueryParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.OrderID == "" && req.OrderNo == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "订单ID和订单号至少需要提供一个", + }) + } + + resp, err := h.orderService.GetOrder(req.OrderID, req.OrderNo) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "获取订单失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListOrders 查询订单列表 +func (h *OrderHandler) ListOrders(c *fiber.Ctx) error { + var req order.ListOrdersRequest + if err := c.QueryParser(&req); err != nil { + req.Page = 1 + req.PageSize = 20 + } + + // 解析 OrderStatus + orderStatusStr := c.Query("order_status") + if orderStatusStr != "" { + if orderStatusInt, err := strconv.Atoi(orderStatusStr); err == nil { + req.OrderStatus = order.OrderStatus(orderStatusInt) + } + } + + // 解析 PaymentMethod + paymentMethodStr := c.Query("payment_method") + if paymentMethodStr != "" { + if paymentMethodInt, err := strconv.Atoi(paymentMethodStr); err == nil { + req.PaymentMethod = order.PaymentMethod(paymentMethodInt) + } + } + + resp, err := h.orderService.ListOrders(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "查询订单列表失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// UpdateOrderStatus 更新订单状态 +func (h *OrderHandler) UpdateOrderStatus(c *fiber.Ctx) error { + var req order.UpdateOrderStatusRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.OrderID == "" && req.OrderNo == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "订单ID和订单号至少需要提供一个", + }) + } + + resp, err := h.orderService.UpdateOrderStatus(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "更新订单状态失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// CancelOrder 取消订单 +func (h *OrderHandler) CancelOrder(c *fiber.Ctx) error { + var req order.CancelOrderRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.OrderID == "" && req.OrderNo == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "订单ID和订单号至少需要提供一个", + }) + } + + resp, err := h.orderService.CancelOrder(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "取消订单失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// AutoCancelOrder 自动关闭订单(用于定时任务回调) +func (h *OrderHandler) AutoCancelOrder(c *fiber.Ctx) error { + orderID := c.Query("order_id") + if orderID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "订单ID不能为空", + }) + } + + // 查询订单当前状态 + orderObj, err := h.orderService.GetOrder(orderID, "") + if err != nil || orderObj == nil || !orderObj.Success { + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": false, + "message": "订单不存在或已处理", + }) + } + + // 如果订单已经是最终状态(已支付、已取消、已退款等),不需要再次关闭 + if orderObj.Order.Status != order.OrderStatusPending { + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + "message": "订单状态已变更,无需关闭", + }) + } + + // 关闭订单 + resp, err := h.orderService.CancelOrder(&order.CancelOrderRequest{ + OrderID: orderID, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "关闭订单失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} diff --git a/internal/order/service/order_service.go b/internal/order/service/order_service.go new file mode 100644 index 0000000..6d02e34 --- /dev/null +++ b/internal/order/service/order_service.go @@ -0,0 +1,460 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "time" + + camp_dao "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/internal/order" + order_dao "dd_fiber_api/internal/order/dao" + "dd_fiber_api/internal/payment" + "dd_fiber_api/internal/scheduler" + "dd_fiber_api/pkg/snowflake" +) + +// OrderService 订单服务 +type OrderService struct { + orderDAO *order_dao.OrderDAO + sectionDAO *camp_dao.SectionDAO // 需要查询小节价格 + accessDAO *camp_dao.UserSectionAccessDAO // 用户小节访问记录 DAO + userCampDAO *camp_dao.UserCampDAO // 用户打卡营 DAO(用于更新当前小节) + wechatPayService *payment.WechatPayV3Service // 微信支付服务 + schedulerService *scheduler.Service // 调度器服务(用于创建定时任务) + apiBaseURL string // API基础URL(用于构建回调URL) +} + +// NewOrderService 创建订单服务 +func NewOrderService(orderDAO *order_dao.OrderDAO, sectionDAO *camp_dao.SectionDAO) *OrderService { + return &OrderService{ + orderDAO: orderDAO, + sectionDAO: sectionDAO, + } +} + +// SetAccessDAO 设置访问记录 DAO +func (s *OrderService) SetAccessDAO(accessDAO *camp_dao.UserSectionAccessDAO) { + s.accessDAO = accessDAO +} + +// SetUserCampDAO 设置用户打卡营 DAO +func (s *OrderService) SetUserCampDAO(userCampDAO *camp_dao.UserCampDAO) { + s.userCampDAO = userCampDAO +} + +// SetPaymentService 设置支付服务(用于创建订单后生成预支付ID) +func (s *OrderService) SetPaymentService(wechatPayService *payment.WechatPayV3Service) { + s.wechatPayService = wechatPayService +} + +// SetSchedulerService 设置调度器服务(用于创建定时任务) +func (s *OrderService) SetSchedulerService(schedulerService *scheduler.Service) { + s.schedulerService = schedulerService +} + +// SetAPIBaseURL 设置API基础URL(用于构建回调URL) +func (s *OrderService) SetAPIBaseURL(baseURL string) { + s.apiBaseURL = baseURL +} + +// CreateOrder 创建订单(适配新的 orders 表,并集成支付功能) +func (s *OrderService) CreateOrder(ctx context.Context, req *order.CreateOrderRequest) (*order.CreateOrderResponse, error) { + // 查询小节信息,获取价格 + section, err := s.sectionDAO.GetByID(req.SectionID) + if err != nil { + return &order.CreateOrderResponse{ + Success: false, + Message: fmt.Sprintf("查询小节失败: %v", err), + }, nil + } + + // 验证小节是否属于指定的打卡营 + if section.CampID != req.CampID { + return &order.CreateOrderResponse{ + Success: false, + Message: "小节不属于指定的打卡营", + }, nil + } + + // 生成订单ID + orderID := snowflake.GetInstance().NextIDString() + + // 确定订单金额 + originalAmount := section.PriceFen + if originalAmount < 0 { + originalAmount = 0 + } + + // 计算优惠金额(暂时为0,后续可以集成优惠券逻辑) + discountAmount := int32(0) + + // 计算实际支付金额 + actualAmount := originalAmount - discountAmount + if actualAmount < 0 { + actualAmount = 0 + } + + // 确定订单状态:如果实际支付金额为0,直接设为已支付;否则设为待支付 + orderStatus := order.OrderStatusPending + var paymentTime *time.Time + if actualAmount == 0 { + // 0元订单直接完成,设置支付时间为当前时间 + orderStatus = order.OrderStatusPaid + now := time.Now() + paymentTime = &now + } + + // 确定支付方式(如果未指定,0元订单不需要支付方式,其他订单默认为微信支付) + paymentMethod := req.PaymentMethod + if paymentMethod == order.PaymentMethodUnknown { + if actualAmount > 0 { + paymentMethod = order.PaymentMethodWechat + } + // 0元订单保持 PaymentMethodUnknown + } + + // 创建订单(使用新的 orders 表) + err = s.orderDAO.CreateOrder( + orderID, + req.UserID, + order.OrderTypeCampSection, + originalAmount, + discountAmount, + actualAmount, + req.CouponID, + orderStatus, + paymentMethod, + paymentTime, // 0元订单的支付时间 + ) + if err != nil { + return &order.CreateOrderResponse{ + Success: false, + Message: fmt.Sprintf("创建订单失败: %v", err), + }, nil + } + + // 创建订单业务数据(关联订单和小节) + // 注意:如果 order_business_data 表不存在,这个调用会失败但不影响订单创建 + if req.CampID != "" && req.SectionID != "" { + _ = s.orderDAO.CreateOrderBusinessData(orderID, req.CampID, req.SectionID) + } + + // 如果是0元订单(已支付),创建访问记录 + if actualAmount == 0 && orderStatus == order.OrderStatusPaid && s.accessDAO != nil { + accessID := snowflake.GetInstance().NextIDString() + paidPriceFen := int32(0) + if section.PriceFen > 0 { + paidPriceFen = section.PriceFen + } + // 创建访问记录(0元订单) + if err := s.accessDAO.Create( + accessID, + req.UserID, + req.CampID, + req.SectionID, + paidPriceFen, + camp_dao.AccessSourcePurchase, + ); err != nil { + // 记录错误但不影响订单创建 + fmt.Printf("创建访问记录失败(0元订单): %v\n", err) + } else { + // 更新当前小节 + if s.userCampDAO != nil && req.CampID != "" { + if err := s.userCampDAO.UpdateCurrentSection(req.UserID, req.CampID, req.SectionID); err != nil { + fmt.Printf("更新当前小节失败(0元订单): %v\n", err) + } else { + fmt.Printf("✅ 当前小节更新成功(0元订单): user_id=%s, camp_id=%s, section_id=%s\n", req.UserID, req.CampID, req.SectionID) + } + } + } + } + + // 构建响应 + resp := &order.CreateOrderResponse{ + Success: true, + Message: "订单创建成功", + OrderID: orderID, + OrderStatus: orderStatus, + ActualAmount: actualAmount, + } + + // 如果订单状态是待支付,创建15分钟后自动关闭订单的定时任务 + if orderStatus == order.OrderStatusPending && s.schedulerService != nil && s.apiBaseURL != "" { + // 构建回调URL + callbackURL := fmt.Sprintf("%s/api/v1/order/auto-cancel?order_id=%s", s.apiBaseURL, orderID) + + // 创建定时任务(15分钟后执行) + taskReq := &scheduler.AddTaskRequest{ + BusinessKey: fmt.Sprintf("order_auto_cancel_%s", orderID), + TaskType: scheduler.TaskTypeOnce, + DelayMs: 15 * 60 * 1000, // 15分钟 = 15 * 60 * 1000 毫秒 + CallbackURL: callbackURL, + Metadata: map[string]string{ + "order_id": orderID, + "type": "order_auto_cancel", + }, + } + + taskResp, err := s.schedulerService.AddTask(taskReq) + if err == nil && taskResp != nil && taskResp.Success { + fmt.Printf("✅ 创建订单自动关闭任务成功: order_id=%s, task_id=%s\n", orderID, taskResp.TaskID) + } else { + fmt.Printf("⚠️ 创建订单自动关闭任务失败: order_id=%s, error=%v\n", orderID, err) + } + } + + // 如果订单需要支付且是微信支付,生成预支付ID + if actualAmount > 0 && orderStatus == order.OrderStatusPending && paymentMethod == order.PaymentMethodWechat { + if s.wechatPayService != nil && req.OpenID != "" { + // 生成商品描述 + description := fmt.Sprintf("打卡营小节-%s", section.Title) + if len(description) > 127 { + description = description[:127] // 微信支付描述最长127字符 + } + + // 调用微信支付服务生成预支付ID + payReq := &payment.CreateWechatPayV3Request{ + OrderID: orderID, + Description: description, + OpenID: req.OpenID, + Amount: actualAmount, + } + + payResp, err := s.wechatPayService.CreateWechatPayV3(ctx, payReq) + if err == nil && payResp != nil && payResp.Success { + // 将支付信息添加到响应中 + resp.PrepayID = payResp.PrepayID + resp.AppID = payResp.AppID + resp.TimeStamp = payResp.TimeStamp + resp.NonceStr = payResp.NonceStr + resp.Package = payResp.Package + resp.SignType = payResp.SignType + resp.PaySign = payResp.PaySign + } else { + // 支付创建失败,但不影响订单创建 + // 可以记录日志,但不返回错误 + } + } + } + + return resp, nil +} + +// GetOrder 获取订单详情 +func (s *OrderService) GetOrder(orderID, orderNo string) (*order.GetOrderResponse, error) { + if orderID == "" && orderNo == "" { + return &order.GetOrderResponse{ + Success: false, + Message: "参数缺失:order_id 和 order_no 至少需要提供一个", + }, nil + } + + var orderObj *order.Order + var err error + + if orderID != "" { + orderObj, err = s.orderDAO.GetOrderByID(orderID) + } else { + orderObj, err = s.orderDAO.GetOrderByOrderNo(orderNo) + } + + if err != nil { + return &order.GetOrderResponse{ + Success: false, + Message: fmt.Sprintf("查询订单失败: %v", err), + }, nil + } + + if orderObj == nil { + return &order.GetOrderResponse{ + Success: false, + Message: "订单不存在", + }, nil + } + + return &order.GetOrderResponse{ + Success: true, + Message: "查询成功", + Order: orderObj, + }, nil +} + +// ListOrders 查询订单列表 +func (s *OrderService) ListOrders(req *order.ListOrdersRequest) (*order.ListOrdersResponse, error) { + // 设置默认分页参数 + page := req.Page + if page <= 0 { + page = 1 + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 // 限制最大每页数量 + } + + orders, total, err := s.orderDAO.ListOrders( + req.UserID, + req.CampID, + req.SectionID, + req.OrderStatus, + req.PaymentMethod, + page, + pageSize, + ) + if err != nil { + return &order.ListOrdersResponse{ + Success: false, + Message: fmt.Sprintf("查询订单列表失败: %v", err), + }, nil + } + + return &order.ListOrdersResponse{ + Success: true, + Message: "查询成功", + Orders: orders, + Total: total, + }, nil +} + +// UpdateOrderStatus 更新订单状态(用于支付回调) +func (s *OrderService) UpdateOrderStatus(req *order.UpdateOrderStatusRequest) (*order.UpdateOrderStatusResponse, error) { + if req.OrderID == "" && req.OrderNo == "" { + return &order.UpdateOrderStatusResponse{ + Success: false, + Message: "参数缺失:order_id 和 order_no 至少需要提供一个", + }, nil + } + + // 解析支付时间 + var paymentTime *time.Time + if req.PaymentTime != "" { + // 尝试解析 Unix 时间戳 + if sec, err := strconv.ParseInt(req.PaymentTime, 10, 64); err == nil { + t := time.Unix(sec, 0) + paymentTime = &t + } else { + // 尝试解析 RFC3339 格式 + if t, err := time.Parse(time.RFC3339, req.PaymentTime); err == nil { + paymentTime = &t + } + } + } + + // 更新订单状态 + err := s.orderDAO.UpdateOrderStatus( + req.OrderID, + req.OrderNo, + req.OrderStatus, + req.PaymentMethod, + req.ThirdPartyOrderNo, + paymentTime, + ) + if err != nil { + return &order.UpdateOrderStatusResponse{ + Success: false, + Message: fmt.Sprintf("更新订单状态失败: %v", err), + }, nil + } + + // 查询更新后的订单 + var orderObj *order.Order + if req.OrderID != "" { + orderObj, err = s.orderDAO.GetOrderByID(req.OrderID) + } else { + orderObj, err = s.orderDAO.GetOrderByOrderNo(req.OrderNo) + } + if err != nil { + return &order.UpdateOrderStatusResponse{ + Success: false, + Message: fmt.Sprintf("查询订单失败: %v", err), + }, nil + } + + // 如果订单状态更新为已支付,取消自动关闭订单的定时任务 + if req.OrderStatus == order.OrderStatusPaid && s.schedulerService != nil && orderObj != nil { + // 通过 business_key 查找任务(需要调度器服务支持通过 business_key 查询) + // 目前调度器服务只支持通过 task_id 删除,所以这里先记录日志 + // 后续可以优化调度器服务,支持通过 business_key 删除任务 + businessKey := fmt.Sprintf("order_auto_cancel_%s", orderObj.OrderID) + fmt.Printf("订单已支付,需要取消自动关闭任务: order_id=%s, business_key=%s\n", orderObj.OrderID, businessKey) + // TODO: 实现通过 business_key 删除任务的逻辑 + } + + // 如果订单状态更新为已支付,且访问记录DAO已设置,创建访问记录 + if req.OrderStatus == order.OrderStatusPaid && s.accessDAO != nil && orderObj != nil && orderObj.SectionID != "" { + // 检查是否已存在访问记录(幂等性检查) + hasAccess, err := s.accessDAO.GetByUserAndSection(orderObj.UserID, orderObj.SectionID) + if err == nil && !hasAccess { + // 生成访问记录ID(使用雪花算法) + accessID := snowflake.GetInstance().NextIDString() + // 创建访问记录 + paidPriceFen := int32(orderObj.ActualAmount) + if paidPriceFen < 0 { + paidPriceFen = 0 + } + + err = s.accessDAO.Create( + accessID, + orderObj.UserID, + orderObj.CampID, + orderObj.SectionID, + paidPriceFen, + camp_dao.AccessSourcePurchase, + ) + if err != nil { + // 记录错误但不影响订单状态更新 + fmt.Printf("创建访问记录失败: %v\n", err) + } else { + fmt.Printf("✅ 访问记录创建成功: user_id=%s, section_id=%s\n", orderObj.UserID, orderObj.SectionID) + // 更新当前小节 + if s.userCampDAO != nil && orderObj.CampID != "" { + if err := s.userCampDAO.UpdateCurrentSection(orderObj.UserID, orderObj.CampID, orderObj.SectionID); err != nil { + fmt.Printf("更新当前小节失败: %v\n", err) + } else { + fmt.Printf("✅ 当前小节更新成功: user_id=%s, camp_id=%s, section_id=%s\n", orderObj.UserID, orderObj.CampID, orderObj.SectionID) + } + } + } + } + } + + return &order.UpdateOrderStatusResponse{ + Success: true, + Message: "更新成功", + Order: orderObj, + }, nil +} + +// CancelOrder 取消订单 +func (s *OrderService) CancelOrder(req *order.CancelOrderRequest) (*order.CancelOrderResponse, error) { + if req.OrderID == "" && req.OrderNo == "" { + return &order.CancelOrderResponse{ + Success: false, + Message: "参数缺失:order_id 和 order_no 至少需要提供一个", + }, nil + } + + // 使用 UpdateOrderStatus 来取消订单 + err := s.orderDAO.UpdateOrderStatus( + req.OrderID, + req.OrderNo, + order.OrderStatusCancelled, + order.PaymentMethodUnknown, + "", + nil, + ) + if err != nil { + return &order.CancelOrderResponse{ + Success: false, + Message: fmt.Sprintf("取消订单失败: %v", err), + }, nil + } + + return &order.CancelOrderResponse{ + Success: true, + Message: "取消订单成功", + }, nil +} diff --git a/internal/order/types.go b/internal/order/types.go new file mode 100644 index 0000000..4ae90d8 --- /dev/null +++ b/internal/order/types.go @@ -0,0 +1,153 @@ +package order + +// OrderType 订单类型枚举 +type OrderType string + +const ( + OrderTypeUnknown OrderType = "" // 未知 + OrderTypeCourseService OrderType = "COURSE_SERVICE" // 课程服务 + OrderTypeVipService OrderType = "VIP_SERVICE" // VIP服务 + OrderTypeCampSection OrderType = "CAMP_SECTION" // 打卡营小节 +) + +// OrderStatus 订单状态枚举 +type OrderStatus string + +const ( + OrderStatusUnknown OrderStatus = "" // 未知 + OrderStatusPending OrderStatus = "PENDING" // 待支付 + OrderStatusPaid OrderStatus = "PAID" // 已支付 + OrderStatusFailed OrderStatus = "FAILED" // 支付失败 + OrderStatusRefunded OrderStatus = "REFUNDED" // 已退款 + OrderStatusCancelled OrderStatus = "CANCELLED" // 已取消 +) + +// PaymentMethod 支付方式枚举 +type PaymentMethod string + +const ( + PaymentMethodUnknown PaymentMethod = "" // 未知 + PaymentMethodWechat PaymentMethod = "WECHAT" // 微信支付 + PaymentMethodAlipay PaymentMethod = "ALIPAY" // 支付宝支付 + PaymentMethodUnionPay PaymentMethod = "UNIONPAY" // 银联支付 + PaymentMethodBalance PaymentMethod = "BALANCE" // 余额支付 +) + +// AccessSource 访问来源枚举 +type AccessSource int32 + +const ( + AccessSourceUnknown AccessSource = 0 // 未知 + AccessSourcePurchase AccessSource = 1 // 前台正常流程获得(含免费) + AccessSourceGrant AccessSource = 2 // 后台/系统授权 +) + +// Order 订单信息(适配新的 orders 表) +type Order struct { + OrderID string `json:"order_id"` // 订单ID + UserID string `json:"user_id"` // 用户ID + OrderType OrderType `json:"order_type"` // 订单类型 + OriginalAmount int32 `json:"original_amount"` // 原始金额(分) + DiscountAmount int32 `json:"discount_amount"` // 优惠金额(分) + ActualAmount int32 `json:"actual_amount"` // 实际支付金额(分) + CouponID string `json:"coupon_id,omitempty"` // 优惠券ID + Status OrderStatus `json:"status"` // 订单状态 + PaymentMethod PaymentMethod `json:"payment_method,omitempty"` // 支付方式 + TransactionID string `json:"transaction_id,omitempty"` // 第三方交易流水号 + PaymentTime string `json:"payment_time,omitempty"` // 支付完成时间 + CreatedAt string `json:"created_at"` // 创建时间 + UpdatedAt string `json:"updated_at"` // 更新时间 + + // 兼容字段(用于打卡营小节订单) + CampID string `json:"camp_id,omitempty"` // 打卡营ID(从业务数据获取) + SectionID string `json:"section_id,omitempty"` // 小节ID(从业务数据获取) +} + +// CreateOrderRequest 创建订单请求 +type CreateOrderRequest struct { + UserID string `json:"user_id"` // 用户ID + CampID string `json:"camp_id"` // 打卡营ID(打卡营小节订单必需) + SectionID string `json:"section_id"` // 小节ID(打卡营小节订单必需) + PaymentMethod PaymentMethod `json:"payment_method,omitempty"` // 支付方式 + CouponID string `json:"coupon_id,omitempty"` // 优惠券ID(可选) + OpenID string `json:"openid,omitempty"` // 用户openid(微信支付必需) +} + +// CreateOrderResponse 创建订单响应(包含预支付信息) +type CreateOrderResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + OrderID string `json:"order_id"` // 订单ID + OrderStatus OrderStatus `json:"order_status"` // 订单状态 + ActualAmount int32 `json:"actual_amount"` // 实际支付金额(分) + + // 支付信息(如果订单需要支付) + PrepayID string `json:"prepay_id,omitempty"` // 预支付ID(微信支付) + AppID string `json:"app_id,omitempty"` // 微信应用ID + TimeStamp string `json:"time_stamp,omitempty"` // 时间戳 + NonceStr string `json:"nonce_str,omitempty"` // 随机字符串 + Package string `json:"package,omitempty"` // 订单详情扩展字符串 + SignType string `json:"sign_type,omitempty"` // 签名方式 + PaySign string `json:"pay_sign,omitempty"` // 签名 +} + +// GetOrderRequest 获取订单请求 +type GetOrderRequest struct { + OrderID string `json:"order_id" query:"order_id"` + OrderNo string `json:"order_no" query:"order_no"` +} + +// GetOrderResponse 获取订单响应 +type GetOrderResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Order *Order `json:"order,omitempty"` +} + +// ListOrdersRequest 查询订单列表请求 +type ListOrdersRequest struct { + UserID string `json:"user_id" query:"user_id"` + CampID string `json:"camp_id" query:"camp_id"` + SectionID string `json:"section_id" query:"section_id"` + OrderStatus OrderStatus `json:"order_status" query:"order_status"` + PaymentMethod PaymentMethod `json:"payment_method" query:"payment_method"` + Page int `json:"page" query:"page"` + PageSize int `json:"page_size" query:"page_size"` +} + +// ListOrdersResponse 查询订单列表响应 +type ListOrdersResponse struct { + Orders []*Order `json:"orders"` + Total int `json:"total"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// UpdateOrderStatusRequest 更新订单状态请求 +type UpdateOrderStatusRequest struct { + OrderID string `json:"order_id"` + OrderNo string `json:"order_no"` + OrderStatus OrderStatus `json:"order_status"` + PaymentMethod PaymentMethod `json:"payment_method,omitempty"` + ThirdPartyOrderNo string `json:"third_party_order_no,omitempty"` + PaymentTime string `json:"payment_time,omitempty"` +} + +// UpdateOrderStatusResponse 更新订单状态响应 +type UpdateOrderStatusResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Order *Order `json:"order,omitempty"` +} + +// CancelOrderRequest 取消订单请求 +type CancelOrderRequest struct { + OrderID string `json:"order_id"` + OrderNo string `json:"order_no"` +} + +// CancelOrderResponse 取消订单响应 +type CancelOrderResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} diff --git a/internal/oss/handler.go b/internal/oss/handler.go new file mode 100644 index 0000000..fe0ace6 --- /dev/null +++ b/internal/oss/handler.go @@ -0,0 +1,43 @@ +package oss + +import ( + "github.com/gofiber/fiber/v2" +) + +// Handler OSS处理器 +type Handler struct { + ossService *Service +} + +// NewHandler 创建OSS处理器 +func NewHandler(ossService *Service) *Handler { + return &Handler{ + ossService: ossService, + } +} + +// GetPolicyToken 获取OSS上传凭证 +func (h *Handler) GetPolicyToken(c *fiber.Ctx) error { + // 获取目录参数 + dir := c.Query("dir", "user-dir") + + // 生成凭证 + policyToken, err := h.ossService.GetPolicyToken(dir) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "获取凭证失败: " + err.Error(), + }) + } + + return c.JSON(policyToken) +} + +// GetMockPolicyToken 获取模拟OSS上传凭证(用于测试) +func (h *Handler) GetMockPolicyToken(c *fiber.Ctx) error { + // 获取目录参数 + dir := c.Query("dir", "user-dir") + + // 生成模拟凭证 + policyToken := h.ossService.GetMockPolicyToken(dir) + return c.JSON(policyToken) +} diff --git a/internal/oss/service.go b/internal/oss/service.go new file mode 100644 index 0000000..80b3bbe --- /dev/null +++ b/internal/oss/service.go @@ -0,0 +1,197 @@ +package oss + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "io" + "time" + + "dd_fiber_api/config" + "dd_fiber_api/pkg/database" + + "github.com/aliyun/credentials-go/credentials" +) + +// PolicyToken 结构体用于存储生成的表单数据 +type PolicyToken struct { + Policy string `json:"policy"` + SecurityToken string `json:"security_token"` + SignatureVersion string `json:"x_oss_signature_version"` + Credential string `json:"x_oss_credential"` + Date string `json:"x_oss_date"` + Signature string `json:"signature"` + Host string `json:"host"` + Dir string `json:"dir"` +} + +// Service OSS服务结构体 +type Service struct { + Region string + BucketName string + AccessKeyID string + AccessKeySecret string + RoleARN string + RoleSessionName string + Redis *database.RedisClient +} + +// NewService 创建OSS服务实例 +func NewService(cfg *config.OSSConfig, redisClient *database.RedisClient) *Service { + return &Service{ + Region: cfg.Region, + BucketName: cfg.BucketName, + AccessKeyID: cfg.AccessKeyID, + AccessKeySecret: cfg.AccessKeySecret, + RoleARN: cfg.RoleARN, + RoleSessionName: cfg.RoleSessionName, + Redis: redisClient, + } +} + +// GetPolicyToken 生成OSS上传所需的签名和凭证 +func (s *Service) GetPolicyToken(dir string) (*PolicyToken, error) { + ctx := context.Background() + + // 生成缓存键(添加服务前缀避免重复,使用日期避免旧缓存) + today := time.Now().Format("2006-01-02") + cacheKey := fmt.Sprintf("ali_sts_credentials:%s:%s", today, dir) + + // 尝试从Redis获取缓存的凭证 + if s.Redis != nil { + var cachedToken PolicyToken + if err := s.Redis.Get(ctx, cacheKey, &cachedToken); err == nil { + return &cachedToken, nil + } + } + + // 从配置获取参数 + accessKeyId := s.AccessKeyID + accessKeySecret := s.AccessKeySecret + roleArn := s.RoleARN + roleSessionName := s.RoleSessionName + + if accessKeyId == "" || accessKeySecret == "" || roleArn == "" { + return nil, fmt.Errorf("缺少必要的配置参数: AccessKeyID, AccessKeySecret, RoleARN") + } + + // 设置OSS上传地址 + host := fmt.Sprintf("https://%s.oss-%s.aliyuncs.com", s.BucketName, s.Region) + + config := new(credentials.Config). + SetType("ram_role_arn"). + SetAccessKeyId(accessKeyId). + SetAccessKeySecret(accessKeySecret). + SetRoleArn(roleArn). + SetRoleSessionName(roleSessionName). + SetPolicy(""). + SetRoleSessionExpiration(3600) + + // 根据配置创建凭证提供器 + provider, err := credentials.NewCredential(config) + if err != nil { + return nil, fmt.Errorf("创建凭证提供器失败: %v", err) + } + + // 从凭证提供器获取凭证 + accessKeyIdResult, err := provider.GetAccessKeyId() + if err != nil { + return nil, fmt.Errorf("获取AccessKeyId失败: %v", err) + } + accessKeySecretResult, err := provider.GetAccessKeySecret() + if err != nil { + return nil, fmt.Errorf("获取AccessKeySecret失败: %v", err) + } + securityToken, err := provider.GetSecurityToken() + if err != nil { + return nil, fmt.Errorf("获取SecurityToken失败: %v", err) + } + + // 构建policy + utcTime := time.Now().UTC() + date := utcTime.Format("20060102") + expiration := utcTime.Add(1 * time.Hour) + policyMap := map[string]any{ + "expiration": expiration.Format("2006-01-02T15:04:05.000Z"), + "conditions": []any{ + map[string]string{"bucket": s.BucketName}, + map[string]string{"x-oss-signature-version": "OSS4-HMAC-SHA256"}, + map[string]string{"x-oss-credential": fmt.Sprintf("%v/%v/%v/oss/aliyun_v4_request", *accessKeyIdResult, date, s.Region)}, + map[string]string{"x-oss-date": utcTime.Format("20060102T150405Z")}, + map[string]string{"x-oss-security-token": *securityToken}, + }, + } + + // 将policy转换为JSON格式 + policy, err := json.Marshal(policyMap) + if err != nil { + return nil, fmt.Errorf("序列化policy失败: %v", err) + } + + // 构造待签名字符串(StringToSign) + stringToSign := base64.StdEncoding.EncodeToString([]byte(policy)) + + hmacHash := func() hash.Hash { return sha256.New() } + // 构建signing key + signingKey := "aliyun_v4" + *accessKeySecretResult + h1 := hmac.New(hmacHash, []byte(signingKey)) + io.WriteString(h1, date) + h1Key := h1.Sum(nil) + + h2 := hmac.New(hmacHash, h1Key) + io.WriteString(h2, s.Region) + h2Key := h2.Sum(nil) + + h3 := hmac.New(hmacHash, h2Key) + io.WriteString(h3, "oss") + h3Key := h3.Sum(nil) + + h4 := hmac.New(hmacHash, h3Key) + io.WriteString(h4, "aliyun_v4_request") + h4Key := h4.Sum(nil) + + // 生成签名 + h := hmac.New(hmacHash, h4Key) + io.WriteString(h, stringToSign) + signature := hex.EncodeToString(h.Sum(nil)) + + // 构建返回给前端的表单 + policyToken := &PolicyToken{ + Policy: stringToSign, + SecurityToken: *securityToken, + SignatureVersion: "OSS4-HMAC-SHA256", + Credential: fmt.Sprintf("%v/%v/%v/oss/aliyun_v4_request", *accessKeyIdResult, date, s.Region), + Date: utcTime.UTC().Format("20060102T150405Z"), + Signature: signature, + Host: host, + Dir: dir, + } + + // 将凭证缓存到Redis(1小时过期) + if s.Redis != nil { + _ = s.Redis.Set(ctx, cacheKey, policyToken, time.Hour) + } + + return policyToken, nil +} + +// GetMockPolicyToken 生成模拟凭证(用于测试) +func (s *Service) GetMockPolicyToken(dir string) *PolicyToken { + host := fmt.Sprintf("https://%s.oss-%s.aliyuncs.com", s.BucketName, s.Region) + + return &PolicyToken{ + Policy: "mock_policy", + SecurityToken: "mock_security_token", + SignatureVersion: "OSS4-HMAC-SHA256", + Credential: "mock_credential", + Date: "20241022T150000Z", + Signature: "mock_signature", + Host: host, + Dir: dir, + } +} diff --git a/internal/payment/handler.go b/internal/payment/handler.go new file mode 100644 index 0000000..6509c86 --- /dev/null +++ b/internal/payment/handler.go @@ -0,0 +1,421 @@ +package payment + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + + camp_dao "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/internal/order" + order_dao "dd_fiber_api/internal/order/dao" + "dd_fiber_api/pkg/snowflake" +) + +// Handler 支付处理器 +type Handler struct { + wechatPayV3Service *WechatPayV3Service + orderDAO *order_dao.OrderDAO + accessDAO *camp_dao.UserSectionAccessDAO // 用户小节访问记录 DAO + userCampDAO *camp_dao.UserCampDAO // 用户打卡营 DAO(用于更新当前小节) +} + +// NewHandler 创建支付处理器 +func NewHandler(wechatPayV3Service *WechatPayV3Service) *Handler { + return &Handler{ + wechatPayV3Service: wechatPayV3Service, + } +} + +// SetOrderDAO 设置订单DAO(用于支付回调时更新订单状态) +func (h *Handler) SetOrderDAO(orderDAO *order_dao.OrderDAO) { + h.orderDAO = orderDAO +} + +// SetAccessDAO 设置访问记录 DAO(用于支付完成后创建访问记录) +func (h *Handler) SetAccessDAO(accessDAO *camp_dao.UserSectionAccessDAO) { + h.accessDAO = accessDAO +} + +// SetUserCampDAO 设置用户打卡营 DAO(用于更新当前小节) +func (h *Handler) SetUserCampDAO(userCampDAO *camp_dao.UserCampDAO) { + h.userCampDAO = userCampDAO +} + +// CreateWechatPayV3 创建微信支付V3订单 +// POST /api/v1/payment/wechat/v3 +func (h *Handler) CreateWechatPayV3(c *fiber.Ctx) error { + var req CreateWechatPayV3Request + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证必填字段 + if req.OrderID == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "order_id 是必需的", + }) + } + if req.Description == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "description 是必需的", + }) + } + if req.OpenID == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "openid 是必需的", + }) + } + if req.Amount <= 0 { + return c.Status(400).JSON(fiber.Map{ + "error": "amount 必须大于0", + }) + } + + // 调用服务 + resp, err := h.wechatPayV3Service.CreateWechatPayV3(c.Context(), &req) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "创建支付订单失败: " + err.Error(), + }) + } + + // 返回响应 + if !resp.Success { + return c.Status(400).JSON(resp) + } + + return c.JSON(resp) +} + +// HandleWechatPayV3Notify 处理微信支付V3通知(使用官方SDK的notify handler) +// POST /api/v1/payment/wechat/v3/notify +func (h *Handler) HandleWechatPayV3Notify(c *fiber.Ctx) error { + // 保存原始请求体(用于测试和调试) + rawBody := c.Body() + if len(rawBody) == 0 { + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "请求体为空", + }) + } + + // 复制 body(用于保存) + rawBodyCopy := make([]byte, len(rawBody)) + copy(rawBodyCopy, rawBody) + + // 保存请求头信息 + headers := make(map[string]string) + c.Request().Header.VisitAll(func(key, val []byte) { + headers[string(key)] = string(val) + }) + + // 先保存原始数据 + h.saveNotifyData("raw", rawBodyCopy, headers, nil) + + // 使用官方SDK的notify handler处理回调通知 + // 官方SDK会自动处理签名验证和解密 + apiKeyV3 := h.wechatPayV3Service.GetAPIKeyV3() + + // 将Fiber的请求转换为标准http.Request(用于notify handler) + // 注意:Fiber使用fasthttp,需要手动构造http.Request + httpReq, err := http.NewRequestWithContext(c.Context(), "POST", c.OriginalURL(), bytes.NewReader(rawBodyCopy)) + if err != nil { + h.saveNotifyData("parse_error", rawBodyCopy, headers, nil) + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "创建http.Request失败: " + err.Error(), + }) + } + + // 复制请求头 + c.Request().Header.VisitAll(func(key, val []byte) { + httpReq.Header.Set(string(key), string(val)) + }) + + // 创建notify handler(使用NewRSANotifyHandler,它包含AES-GCM解密能力) + // 获取verifier用于验证签名 + verifier, err := h.wechatPayV3Service.GetVerifier() + if err != nil { + h.saveNotifyData("parse_error", rawBodyCopy, headers, nil) + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "获取verifier失败: " + err.Error(), + }) + } + + handler, err := notify.NewRSANotifyHandler(apiKeyV3, verifier) + if err != nil { + h.saveNotifyData("parse_error", rawBodyCopy, headers, nil) + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "创建notify handler失败: " + err.Error(), + }) + } + + // 使用官方SDK解析通知(自动验证签名和解密) + // content参数可以是map[string]interface{}来接收解密后的数据 + content := make(map[string]interface{}) + notification, err := handler.ParseNotifyRequest(c.Context(), httpReq, content) + if err != nil { + // 检查是否是时间戳过期错误 + errMsg := err.Error() + if strings.Contains(errMsg, "timestamp") && strings.Contains(errMsg, "expires") { + log.Printf("⚠️ 解析微信支付回调通知失败: 时间戳已过期(可能是使用旧测试数据导致): %v", err) + } else { + log.Printf("⚠️ 解析微信支付回调通知失败: %v", err) + } + h.saveNotifyData("parse_error", rawBodyCopy, headers, nil) + // 返回 200 状态码,避免微信重复推送 + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "解析通知失败: " + err.Error(), + }) + } + + // 保存解析后的数据 + h.saveNotifyData("success", rawBodyCopy, headers, notification) + + // 从notification.Resource.Plaintext中获取解密后的业务数据 + // 官方SDK会将解密后的数据放在Resource.Plaintext字段中(JSON字符串格式) + var transactionData map[string]interface{} + + // 使用反射获取Resource.Plaintext字段 + notificationValue := reflect.ValueOf(notification) + if notificationValue.Kind() == reflect.Ptr { + notificationValue = notificationValue.Elem() + } + + resourceField := notificationValue.FieldByName("Resource") + if !resourceField.IsValid() || resourceField.IsNil() { + log.Printf("❌ notification.Resource字段无效或为空") + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "无法获取Resource字段", + }) + } + + resourceValue := resourceField.Interface() + // 尝试将Resource转换为map + resourceMap, ok := resourceValue.(map[string]interface{}) + if !ok { + // 如果不是map,尝试使用反射获取Plaintext字段 + resourceReflectValue := reflect.ValueOf(resourceValue) + if resourceReflectValue.Kind() == reflect.Ptr { + resourceReflectValue = resourceReflectValue.Elem() + } + plaintextField := resourceReflectValue.FieldByName("Plaintext") + if !plaintextField.IsValid() || plaintextField.Kind() != reflect.String { + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "无法获取Plaintext字段", + }) + } + plaintextStr := plaintextField.String() + if err := json.Unmarshal([]byte(plaintextStr), &transactionData); err != nil { + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "解析解密数据失败: " + err.Error(), + }) + } + } else { + // 从map中获取Plaintext + plaintext, exists := resourceMap["Plaintext"] + if !exists { + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "Resource中不存在Plaintext字段", + }) + } + + plaintextStr, ok := plaintext.(string) + if !ok { + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "Plaintext不是字符串类型", + }) + } + + // 解析Plaintext中的JSON字符串 + if err := json.Unmarshal([]byte(plaintextStr), &transactionData); err != nil { + return c.Status(200).JSON(fiber.Map{ + "code": "FAIL", + "message": "解析解密数据失败: " + err.Error(), + }) + } + } + + // 获取交易状态和订单号 + tradeState, _ := transactionData["trade_state"].(string) + outTradeNo, _ := transactionData["out_trade_no"].(string) + transactionID, _ := transactionData["transaction_id"].(string) + successTime, _ := transactionData["success_time"].(string) + + // 更新订单状态 + if h.orderDAO != nil && outTradeNo != "" { + // 先查询当前订单状态(幂等性检查) + currentOrder, err := h.orderDAO.GetOrderByOrderNo(outTradeNo) + if err == nil && currentOrder != nil { + // 如果订单已经是最终状态(PAID, REFUNDED, CANCELLED),且交易状态也是成功,则跳过更新 + if currentOrder.Status == order.OrderStatusPaid && tradeState == "SUCCESS" { + // 仍然返回成功,避免微信重复推送 + return c.Status(200).JSON(fiber.Map{ + "code": "SUCCESS", + "message": "订单已处理", + }) + } + } + + // 将微信支付的交易状态映射到订单状态 + var orderStatus order.OrderStatus + switch tradeState { + case "SUCCESS": + orderStatus = order.OrderStatusPaid + case "NOTPAY", "USERPAYING": + orderStatus = order.OrderStatusPending + case "PAYERROR", "CLOSED": + orderStatus = order.OrderStatusFailed + case "REVOKED": + orderStatus = order.OrderStatusCancelled + case "REFUND": + orderStatus = order.OrderStatusRefunded + default: + // 如果事件类型是 TRANSACTION.SUCCESS,则认为是支付成功 + if notification.EventType == "TRANSACTION.SUCCESS" { + orderStatus = order.OrderStatusPaid + } else { + // 其他情况不更新订单状态 + orderStatus = "" + } + } + + // 只有当订单状态确定时才更新 + if orderStatus != "" { + // 解析支付时间 + var paymentTime *time.Time + if successTime != "" { + // 尝试解析 RFC3339 格式(微信支付返回的格式) + if t, err := time.Parse(time.RFC3339, successTime); err == nil { + paymentTime = &t + } else if t, err := time.Parse("2006-01-02T15:04:05+08:00", successTime); err == nil { + paymentTime = &t + } + } + + // 直接使用 orderDAO 更新订单状态 + err := h.orderDAO.UpdateOrderStatus( + "", // orderID 为空,使用 orderNo + outTradeNo, + orderStatus, + order.PaymentMethodWechat, + transactionID, + paymentTime, + ) + if err != nil { + log.Printf("更新订单状态失败: %v", err) + } else if orderStatus == order.OrderStatusPaid && h.accessDAO != nil { + // 订单支付成功后,创建访问记录 + // 查询订单信息(包括业务数据) + orderObj, err := h.orderDAO.GetOrderByOrderNo(outTradeNo) + if err == nil && orderObj != nil && orderObj.SectionID != "" { + // 检查是否已存在访问记录(幂等性检查,避免重复创建) + hasAccess, checkErr := h.accessDAO.GetByUserAndSection(orderObj.UserID, orderObj.SectionID) + if checkErr == nil && !hasAccess { + // 生成访问记录ID(使用雪花算法) + accessID := snowflake.GetInstance().NextIDString() + // 创建访问记录(只保留访问权限相关字段,订单信息从 orders 表查询) + paidPriceFen := int32(orderObj.ActualAmount) + if paidPriceFen < 0 { + paidPriceFen = 0 + } + + err = h.accessDAO.Create( + accessID, + orderObj.UserID, + orderObj.CampID, + orderObj.SectionID, + paidPriceFen, + camp_dao.AccessSourcePurchase, + ) + if err != nil { + log.Printf("创建访问记录失败: %v", err) + } else { + log.Printf("✅ 访问记录创建成功: user_id=%s, section_id=%s", orderObj.UserID, orderObj.SectionID) + // 更新当前小节 + if h.userCampDAO != nil && orderObj.CampID != "" { + if err := h.userCampDAO.UpdateCurrentSection(orderObj.UserID, orderObj.CampID, orderObj.SectionID); err != nil { + log.Printf("更新当前小节失败: %v", err) + } else { + log.Printf("✅ 当前小节更新成功: user_id=%s, camp_id=%s, section_id=%s", orderObj.UserID, orderObj.CampID, orderObj.SectionID) + } + } + } + } else if hasAccess { + log.Printf("访问记录已存在,跳过创建: user_id=%s, section_id=%s", orderObj.UserID, orderObj.SectionID) + } + } + } + } + } else if h.orderDAO == nil { + log.Printf("⚠️ 订单DAO未初始化,无法更新订单状态") + } else if outTradeNo == "" { + log.Printf("⚠️ 订单号为空,无法更新订单状态") + } + + // 返回 200 状态码和成功响应 + return c.Status(200).JSON(fiber.Map{ + "code": "SUCCESS", + "message": "成功", + }) +} + +// saveNotifyData 保存支付回调数据到文件(用于测试和调试) +func (h *Handler) saveNotifyData(status string, rawBody []byte, headers map[string]string, parsedReq interface{}) { + // 获取当前工作目录 + workDir, err := os.Getwd() + if err != nil { + workDir = "." // 使用当前目录 + } + + // 创建保存目录(使用绝对路径) + saveDir := filepath.Join(workDir, "storage", "payment_notify") + if err := os.MkdirAll(saveDir, 0755); err != nil { + return + } + + // 构建保存的数据 + saveData := map[string]interface{}{ + "timestamp": time.Now().Format("2006-01-02 15:04:05"), + "status": status, + "raw_body": string(rawBody), + "headers": headers, + } + + if parsedReq != nil { + saveData["parsed_request"] = parsedReq + } + + // 转换为 JSON + jsonData, err := json.MarshalIndent(saveData, "", " ") + if err != nil { + return + } + + // 生成文件名(使用时间戳和状态) + fileName := fmt.Sprintf("notify_%s_%s.json", time.Now().Format("20060102_150405"), status) + filePath := filepath.Join(saveDir, fileName) + + // 保存到文件 + _ = os.WriteFile(filePath, jsonData, 0644) +} diff --git a/internal/payment/service.go b/internal/payment/service.go new file mode 100644 index 0000000..7aef378 --- /dev/null +++ b/internal/payment/service.go @@ -0,0 +1,291 @@ +package payment + +import ( + "context" + "crypto/rsa" + "fmt" + "log" + "strings" + "time" + + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" + "github.com/wechatpay-apiv3/wechatpay-go/core/option" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" + "github.com/wechatpay-apiv3/wechatpay-go/utils" + + "dd_fiber_api/config" +) + +// min 返回两个整数中的较小值 +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// WechatPayV3Service 微信支付V3服务(使用官方SDK) +type WechatPayV3Service struct { + config *config.WechatConfig + client *core.Client // 官方SDK客户端 + privateKey *rsa.PrivateKey // 保存私钥,用于生成支付签名 +} + +// NewWechatPayV3Service 创建微信支付V3服务实例(使用官方SDK) +func NewWechatPayV3Service(cfg *config.WechatConfig) (*WechatPayV3Service, error) { + // 加载私钥 + var privateKey *rsa.PrivateKey + var err error + + // 优先使用私钥内容,其次使用文件路径 + if cfg.PrivateKey != "" { + // 从字符串加载私钥 + privateKey, err = utils.LoadPrivateKey(cfg.PrivateKey) + if err != nil { + return nil, fmt.Errorf("加载私钥失败: %v", err) + } + } else if cfg.PrivateKeyPath != "" { + // 从文件路径加载私钥 + privateKey, err = utils.LoadPrivateKeyWithPath(cfg.PrivateKeyPath) + if err != nil { + return nil, fmt.Errorf("加载私钥文件失败: %v", err) + } + } else { + return nil, fmt.Errorf("未配置私钥,请设置 private_key 或 private_key_path") + } + + // 使用官方SDK初始化客户端 + ctx := context.Background() + + // 验证配置参数 + apiKeyV3 := strings.TrimSpace(cfg.APIKeyV3) + if apiKeyV3 == "" { + return nil, fmt.Errorf("API Key V3 不能为空") + } + if len(apiKeyV3) < 32 { + log.Printf("⚠️ 警告: API Key V3 长度异常(%d 字符),请确认配置正确", len(apiKeyV3)) + } + if cfg.MchID == "" { + return nil, fmt.Errorf("商户号(mch_id)不能为空") + } + if cfg.SerialNo == "" { + return nil, fmt.Errorf("证书序列号(serial_no)不能为空") + } + + log.Printf(" 正在初始化微信支付客户端: 商户号=%s, 证书序列号=%s, API Key V3长度=%d", + cfg.MchID, cfg.SerialNo, len(apiKeyV3)) + + opts := []core.ClientOption{ + option.WithWechatPayAutoAuthCipher( + cfg.MchID, + cfg.SerialNo, + privateKey, + apiKeyV3, + ), + } + + client, err := core.NewClient(ctx, opts...) + if err != nil { + // 提供更详细的错误信息 + errMsg := fmt.Sprintf("初始化微信支付客户端失败: %v", err) + if strings.Contains(err.Error(), "decrypt") || strings.Contains(err.Error(), "cipher") { + errMsg += "\n可能的原因:" + errMsg += "\n1. API Key V3 配置错误(请检查微信商户平台中的 API v3 密钥)" + errMsg += "\n2. 证书序列号(serial_no)不正确" + errMsg += "\n3. 私钥文件与证书序列号不匹配" + errMsg += "\n4. 商户号(mch_id)配置错误" + errMsg += fmt.Sprintf("\n当前配置: mch_id=%s, serial_no=%s, api_key_v3长度=%d", + cfg.MchID, cfg.SerialNo, len(apiKeyV3)) + } + return nil, fmt.Errorf(errMsg) + } + + log.Println(" ✅ 微信支付 V3 客户端初始化成功(使用官方SDK)") + + // 由于WithWechatPayAutoAuthCipher已经配置了自动证书更新,我们需要获取证书管理器 + // 但是client没有公开方法获取,我们需要通过其他方式 + // 实际上,我们可以创建一个新的证书管理器,或者使用client的内部方法 + // 这里我们先尝试使用client作为CertificateGetter(如果它实现了接口) + // 如果不行,我们需要手动创建证书管理器 + + // 暂时先不设置certificateGetter,在GetVerifier中处理 + service := &WechatPayV3Service{ + config: cfg, + client: client, + privateKey: privateKey, // 保存私钥用于生成支付签名 + } + + // 尝试从client获取证书管理器(如果client实现了CertificateGetter接口) + // 由于client没有公开方法,我们需要使用反射或其他方式 + // 或者,我们可以创建一个新的证书管理器 + // 这里我们先返回service,在GetVerifier中处理 + + return service, nil +} + +// CreateWechatPayV3Request 创建微信支付V3订单请求 +type CreateWechatPayV3Request struct { + OrderID string `json:"order_id"` // 关联的订单ID(雪花ID,字符串格式) + Description string `json:"description"` // 商品描述 + OpenID string `json:"openid"` // 用户openid(小程序支付必需) + Amount int32 `json:"amount"` // 支付金额(分) +} + +// CreateWechatPayV3Response 创建微信支付V3订单响应 +type CreateWechatPayV3Response struct { + OrderID string `json:"order_id"` // 关联的订单ID + PrepayID string `json:"prepay_id"` // 预支付交易会话标识 + AppID string `json:"app_id"` // 微信应用ID + TimeStamp string `json:"time_stamp"` // 时间戳 + NonceStr string `json:"nonce_str"` // 随机字符串 + Package string `json:"package"` // 订单详情扩展字符串(prepay_id=xxx) + SignType string `json:"sign_type"` // 签名方式(RSA) + PaySign string `json:"pay_sign"` // 签名 + Success bool `json:"success"` // 是否成功 + Message string `json:"message"` // 响应消息 +} + +// CreateWechatPayV3 创建微信支付V3订单(使用官方SDK) +func (s *WechatPayV3Service) CreateWechatPayV3(ctx context.Context, req *CreateWechatPayV3Request) (*CreateWechatPayV3Response, error) { + // 验证 openid(小程序支付必需) + if req.OpenID == "" { + return &CreateWechatPayV3Response{ + OrderID: req.OrderID, + Success: false, + Message: "openid 是必需的(小程序支付)", + }, nil + } + + // 验证金额 + if req.Amount <= 0 { + return &CreateWechatPayV3Response{ + OrderID: req.OrderID, + Success: false, + Message: "支付金额必须大于0", + }, nil + } + + // 使用官方SDK创建小程序支付订单 + svc := jsapi.JsapiApiService{Client: s.client} + + request := jsapi.PrepayRequest{ + Appid: core.String(s.config.AppID), + Mchid: core.String(s.config.MchID), + Description: core.String(req.Description), + OutTradeNo: core.String(req.OrderID), + NotifyUrl: core.String(s.config.NotifyURL), + Amount: &jsapi.Amount{ + Total: core.Int64(int64(req.Amount)), + Currency: core.String("CNY"), + }, + Payer: &jsapi.Payer{ + Openid: core.String(req.OpenID), + }, + } + + // 调用官方SDK创建订单 + resp, result, err := svc.Prepay(ctx, request) + if err != nil { + log.Printf("⚠️ 创建微信支付订单失败: %v", err) + return &CreateWechatPayV3Response{ + OrderID: req.OrderID, + Success: false, + Message: fmt.Sprintf("创建支付订单失败: %v", err), + }, nil + } + + // 检查HTTP响应状态码(从result中获取) + if result != nil && result.Response != nil && result.Response.StatusCode != 200 { + log.Printf("⚠️ 微信支付返回错误状态码: %d", result.Response.StatusCode) + return &CreateWechatPayV3Response{ + OrderID: req.OrderID, + Success: false, + Message: fmt.Sprintf("微信支付返回错误: HTTP %d", result.Response.StatusCode), + }, nil + } + + // 获取 prepay_id(从resp中获取) + prepayID := "" + if resp != nil && resp.PrepayId != nil { + prepayID = *resp.PrepayId + } + + if prepayID == "" { + return &CreateWechatPayV3Response{ + OrderID: req.OrderID, + Success: false, + Message: "微信支付返回缺少 prepay_id", + }, nil + } + + log.Printf("✅ 创建微信支付订单成功: prepay_id=%s", prepayID) + + // 生成小程序支付所需的签名参数 + timestamp := time.Now().Unix() + nonceStr, err := utils.GenerateNonce() + if err != nil { + return &CreateWechatPayV3Response{ + OrderID: req.OrderID, + Success: false, + Message: fmt.Sprintf("生成随机字符串失败: %v", err), + }, nil + } + packageStr := fmt.Sprintf("prepay_id=%s", prepayID) + + // 使用官方SDK生成支付签名 + signStr := fmt.Sprintf("%s\n%d\n%s\n%s\n", s.config.AppID, timestamp, nonceStr, packageStr) + signature, err := utils.SignSHA256WithRSA(signStr, s.privateKey) + if err != nil { + return &CreateWechatPayV3Response{ + OrderID: req.OrderID, + Success: false, + Message: fmt.Sprintf("生成支付签名失败: %v", err), + }, nil + } + + // 构造返回结果 + resultResp := &CreateWechatPayV3Response{ + OrderID: req.OrderID, + PrepayID: prepayID, + AppID: s.config.AppID, + TimeStamp: fmt.Sprintf("%d", timestamp), + NonceStr: nonceStr, + Package: packageStr, + SignType: "RSA", + PaySign: signature, + Success: true, + Message: "创建支付订单成功", + } + + return resultResp, nil +} + +// GetClient 获取官方SDK客户端(用于notify handler) +func (s *WechatPayV3Service) GetClient() *core.Client { + return s.client +} + +// GetAPIKeyV3 获取APIv3密钥(用于notify handler) +func (s *WechatPayV3Service) GetAPIKeyV3() string { + return strings.TrimSpace(s.config.APIKeyV3) +} + +// GetVerifier 获取验证器(用于notify handler) +// 直接使用官方SDK的downloader管理器获取证书访问器 +// 这与WithWechatPayAutoAuthCipher使用的机制相同,是官方推荐的方式 +func (s *WechatPayV3Service) GetVerifier() (auth.Verifier, error) { + // 使用官方SDK的downloader管理器获取证书访问器 + mgr := downloader.MgrInstance() + certGetter := mgr.GetCertificateVisitor(s.config.MchID) + + if certGetter == nil { + return nil, fmt.Errorf("无法从downloader获取证书访问器,商户号: %s", s.config.MchID) + } + + // 使用证书管理器创建verifier + verifier := verifiers.NewSHA256WithRSAVerifier(certGetter) + return verifier, nil +} diff --git a/internal/question/dao/answer_record_dao_mongo.go b/internal/question/dao/answer_record_dao_mongo.go new file mode 100644 index 0000000..4bd38e0 --- /dev/null +++ b/internal/question/dao/answer_record_dao_mongo.go @@ -0,0 +1,467 @@ +package dao + +import ( + "context" + "fmt" + "time" + + "dd_fiber_api/internal/question" + "dd_fiber_api/pkg/database" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// AnswerRecordDAOMongo MongoDB 实现的答题记录数据访问对象 +type AnswerRecordDAOMongo struct { + client *database.MongoDBClient + collection *mongo.Collection +} + +// NewAnswerRecordDAOMongo 创建 MongoDB 答题记录 DAO +func NewAnswerRecordDAOMongo(client *database.MongoDBClient) *AnswerRecordDAOMongo { + return &AnswerRecordDAOMongo{ + client: client, + collection: client.Collection("answer_records"), + } +} + +// AnswerRecordDocument MongoDB 文档结构 +// 使用引用方式:存储题目ID和试卷ID,而不是嵌入数据 +type AnswerRecordDocument struct { + ID string `bson:"_id" json:"id"` + UserID string `bson:"user_id" json:"user_id"` + QuestionID string `bson:"question_id" json:"question_id"` // 引用:题目ID + PaperID string `bson:"paper_id,omitempty" json:"paper_id,omitempty"` // 引用:试卷ID(可选) + TaskID string `bson:"task_id,omitempty" json:"task_id,omitempty"` // 打卡营任务ID,用于不同打卡营的答题隔离 + UserAnswer string `bson:"user_answer" json:"user_answer"` + CorrectAnswer string `bson:"correct_answer" json:"correct_answer"` + IsCorrect bool `bson:"is_correct" json:"is_correct"` + StartTime int64 `bson:"start_time" json:"start_time"` + EndTime int64 `bson:"end_time" json:"end_time"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +// Create 创建答题记录 +func (dao *AnswerRecordDAOMongo) Create(record *question.AnswerRecord) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + doc := &AnswerRecordDocument{ + ID: record.ID, + UserID: record.UserID, + QuestionID: record.QuestionID, // 引用:题目ID + PaperID: record.PaperID, // 引用:试卷ID + TaskID: record.TaskID, // 打卡营任务ID + UserAnswer: record.UserAnswer, + CorrectAnswer: record.CorrectAnswer, + IsCorrect: record.IsCorrect, + StartTime: record.StartTime, + EndTime: record.EndTime, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + } + + _, err := dao.collection.InsertOne(ctx, doc) + if err != nil { + return fmt.Errorf("插入答题记录失败: %v", err) + } + + return nil +} + +// GetByID 根据ID获取答题记录 +func (dao *AnswerRecordDAOMongo) GetByID(id string) (*question.AnswerRecord, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var doc AnswerRecordDocument + filter := bson.M{"_id": id} + + err := dao.collection.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("答题记录不存在") + } + return nil, fmt.Errorf("查询答题记录失败: %v", err) + } + + return dao.documentToAnswerRecord(&doc), nil +} + +// GetByUserAndQuestion 根据用户ID和题目ID获取答题记录 +func (dao *AnswerRecordDAOMongo) GetByUserAndQuestion(userID, questionID string) (*question.AnswerRecord, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var doc AnswerRecordDocument + filter := bson.M{ + "user_id": userID, + "question_id": questionID, + } + + err := dao.collection.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("答题记录不存在") + } + return nil, fmt.Errorf("查询答题记录失败: %v", err) + } + + return dao.documentToAnswerRecord(&doc), nil +} + +// GetByUserQuestionAndPaper 根据用户ID、题目ID和试卷ID获取答题记录(支持可选的 taskID 过滤) +func (dao *AnswerRecordDAOMongo) GetByUserQuestionAndPaper(userID, questionID, paperID, taskID string) (*question.AnswerRecord, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var doc AnswerRecordDocument + filter := bson.M{ + "user_id": userID, + "question_id": questionID, + "paper_id": paperID, + } + if taskID != "" { + filter["task_id"] = taskID + } + + err := dao.collection.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("答题记录不存在") + } + return nil, fmt.Errorf("查询答题记录失败: %v", err) + } + + return dao.documentToAnswerRecord(&doc), nil +} + +// GetByConditionsWithPagination 根据条件获取答题记录列表(支持分页,支持可选的 taskID 过滤) +func (dao *AnswerRecordDAOMongo) GetByConditionsWithPagination(userID, questionID, paperID, taskID string, page, pageSize int32) ([]*question.AnswerRecord, int32, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 构建查询条件 + filter := bson.M{} + + if userID != "" { + filter["user_id"] = userID + } + if questionID != "" { + filter["question_id"] = questionID + } + if paperID != "" { + filter["paper_id"] = paperID + } + if taskID != "" { + filter["task_id"] = taskID + } + + // 至少需要一个条件 + if len(filter) == 0 { + return nil, 0, fmt.Errorf("至少需要提供一个查询条件") + } + + // 查询总数 + total, err := dao.collection.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, fmt.Errorf("查询总数失败: %v", err) + } + + // 查询数据 + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + opts := options.Find(). + SetSkip(skip). + SetLimit(limit). + SetSort(bson.M{"created_at": -1}) + + cursor, err := dao.collection.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("查询答题记录失败: %v", err) + } + defer cursor.Close(ctx) + + var docs []AnswerRecordDocument + if err := cursor.All(ctx, &docs); err != nil { + return nil, 0, fmt.Errorf("解析答题记录数据失败: %v", err) + } + + records := make([]*question.AnswerRecord, len(docs)) + for i, doc := range docs { + records[i] = dao.documentToAnswerRecord(&doc) + } + + return records, int32(total), nil +} + +// Update 更新答题记录 +func (dao *AnswerRecordDAOMongo) Update(record *question.AnswerRecord) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{"_id": record.ID} + update := bson.M{ + "$set": bson.M{ + "user_answer": record.UserAnswer, + "correct_answer": record.CorrectAnswer, + "is_correct": record.IsCorrect, + "start_time": record.StartTime, + "end_time": record.EndTime, + "updated_at": record.UpdatedAt, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("更新答题记录失败: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("答题记录不存在") + } + + return nil +} + +// DeleteByUserID 根据用户ID删除答题记录 +func (dao *AnswerRecordDAOMongo) DeleteByUserID(userID string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{"user_id": userID} + result, err := dao.collection.DeleteMany(ctx, filter) + if err != nil { + return 0, fmt.Errorf("删除答题记录失败: %v", err) + } + + return result.DeletedCount, nil +} + +// DeleteByUserAndPaper 根据用户ID和试卷ID删除答题记录(支持可选的 taskID 过滤) +func (dao *AnswerRecordDAOMongo) DeleteByUserAndPaper(userID, paperID, taskID string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{ + "user_id": userID, + "paper_id": paperID, + } + if taskID != "" { + filter["task_id"] = taskID + } + result, err := dao.collection.DeleteMany(ctx, filter) + if err != nil { + return 0, fmt.Errorf("删除答题记录失败: %v", err) + } + + return result.DeletedCount, nil +} + +// DeleteByUserAndTaskIDs 根据用户ID和任务ID列表删除该用户在这些任务下的客观题答题记录(用于重置打卡营进度) +func (dao *AnswerRecordDAOMongo) DeleteByUserAndTaskIDs(userID string, taskIDs []string) (int64, error) { + if len(taskIDs) == 0 { + return 0, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + filter := bson.M{ + "user_id": userID, + "task_id": bson.M{"$in": taskIDs}, + } + result, err := dao.collection.DeleteMany(ctx, filter) + if err != nil { + return 0, fmt.Errorf("删除答题记录失败: %v", err) + } + return result.DeletedCount, nil +} + +// CountByUserAndPaper 统计用户在某试卷下的答题记录条数 +func (dao *AnswerRecordDAOMongo) CountByUserAndPaper(userID, paperID, taskID string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "user_id": userID, + "paper_id": paperID, + } + if taskID != "" { + filter["task_id"] = taskID + } + count, err := dao.collection.CountDocuments(ctx, filter) + if err != nil { + return 0, fmt.Errorf("统计答题记录失败: %v", err) + } + return count, nil +} + +// CountByUserAndPaperWithNonEmptyAnswer 统计用户在某试卷下「有填写 user_answer」的答题记录条数(用于客观题是否“真完成”) +func (dao *AnswerRecordDAOMongo) CountByUserAndPaperWithNonEmptyAnswer(userID, paperID, taskID string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "user_id": userID, + "paper_id": paperID, + "user_answer": bson.M{"$exists": true, "$nin": []interface{}{nil, ""}}, + } + if taskID != "" { + filter["task_id"] = taskID + } + count, err := dao.collection.CountDocuments(ctx, filter) + if err != nil { + return 0, fmt.Errorf("统计答题记录失败: %v", err) + } + return count, nil +} + +// GetPaperStatistics 获取试卷答题统计(支持可选的 taskID 过滤) +func (dao *AnswerRecordDAOMongo) GetPaperStatistics(userID, paperID, taskID string) (*question.PaperStatistics, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 构建匹配条件 + matchFilter := bson.M{ + "user_id": userID, + "paper_id": paperID, + } + if taskID != "" { + matchFilter["task_id"] = taskID + } + + // 构建聚合管道 + pipeline := []bson.M{ + { + "$match": matchFilter, + }, + { + "$group": bson.M{ + "_id": nil, + "total_questions": bson.M{ + "$sum": 1, + }, + "correct_answers": bson.M{ + "$sum": bson.M{ + "$cond": []interface{}{"$is_correct", 1, 0}, + }, + }, + }, + }, + } + + cursor, err := dao.collection.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("统计答题记录失败: %v", err) + } + defer cursor.Close(ctx) + + var result struct { + TotalQuestions int32 `bson:"total_questions"` + CorrectAnswers int32 `bson:"correct_answers"` + } + + if cursor.Next(ctx) { + if err := cursor.Decode(&result); err != nil { + return nil, fmt.Errorf("解析统计结果失败: %v", err) + } + } else { + // 没有记录,返回空统计 + return &question.PaperStatistics{ + UserID: userID, + PaperID: paperID, + TotalQuestions: 0, + AnsweredQuestions: 0, + CorrectAnswers: 0, + WrongAnswers: 0, + }, nil + } + + wrongAnswers := result.TotalQuestions - result.CorrectAnswers + + return &question.PaperStatistics{ + UserID: userID, + PaperID: paperID, + TotalQuestions: result.TotalQuestions, + AnsweredQuestions: result.TotalQuestions, + CorrectAnswers: result.CorrectAnswers, + WrongAnswers: wrongAnswers, + }, nil +} + +// documentToAnswerRecord 将 MongoDB 文档转换为 AnswerRecord 对象 +func (dao *AnswerRecordDAOMongo) documentToAnswerRecord(doc *AnswerRecordDocument) *question.AnswerRecord { + return &question.AnswerRecord{ + ID: doc.ID, + UserID: doc.UserID, + QuestionID: doc.QuestionID, // 引用:题目ID + PaperID: doc.PaperID, // 引用:试卷ID + TaskID: doc.TaskID, // 打卡营任务ID + UserAnswer: doc.UserAnswer, + CorrectAnswer: doc.CorrectAnswer, + IsCorrect: doc.IsCorrect, + StartTime: doc.StartTime, + EndTime: doc.EndTime, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + } +} + +// CreateIndexes 创建索引 +func (dao *AnswerRecordDAOMongo) CreateIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "question_id", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "paper_id", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "paper_id", Value: 1}, + {Key: "task_id", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "question_id", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "paper_id", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "task_id", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "created_at", Value: -1}, + }, + }, + } + + _, err := dao.collection.Indexes().CreateMany(ctx, indexes) + if err != nil { + return fmt.Errorf("创建索引失败: %v", err) + } + + return nil +} + diff --git a/internal/question/dao/interfaces.go b/internal/question/dao/interfaces.go new file mode 100644 index 0000000..fa1e0e3 --- /dev/null +++ b/internal/question/dao/interfaces.go @@ -0,0 +1,62 @@ +package dao + +import "dd_fiber_api/internal/question" + +// QuestionDAOInterface 题目DAO接口 +type QuestionDAOInterface interface { + Create(question *question.Question) error + GetByID(id string) (*question.Question, error) + Search(query string, qType question.QuestionType, knowledgeTreeIds []string, page, pageSize int32) ([]*question.Question, int32, error) + Update(question *question.Question) error + Delete(id string) error + BatchDelete(ids []string) (int, []string, error) +} + +// PaperDAOInterface 试卷DAO接口 +type PaperDAOInterface interface { + Create(paper *question.Paper) error + GetByID(id string) (*question.Paper, error) + GetWithQuestions(id string) (*question.Paper, error) + Search(query string, page, pageSize int32) ([]*question.Paper, int32, error) + Update(paper *question.Paper) error + Delete(id string) error + BatchDelete(ids []string) (int, []string, error) + AddQuestions(paperID string, questionIDs []string) (int, []string, error) + RemoveQuestions(paperID string, questionIDs []string) (int, []string, error) +} + +// AnswerRecordDAOInterface 答题记录DAO接口 +type AnswerRecordDAOInterface interface { + Create(record *question.AnswerRecord) error + GetByID(id string) (*question.AnswerRecord, error) + GetByUserAndQuestion(userID, questionID string) (*question.AnswerRecord, error) + GetByUserQuestionAndPaper(userID, questionID, paperID, taskID string) (*question.AnswerRecord, error) + GetByConditionsWithPagination(userID, questionID, paperID, taskID string, page, pageSize int32) ([]*question.AnswerRecord, int32, error) + Update(record *question.AnswerRecord) error + DeleteByUserID(userID string) (int64, error) + DeleteByUserAndPaper(userID, paperID, taskID string) (int64, error) + DeleteByUserAndTaskIDs(userID string, taskIDs []string) (int64, error) + GetPaperStatistics(userID, paperID, taskID string) (*question.PaperStatistics, error) + CountByUserAndPaper(userID, paperID, taskID string) (int64, error) + CountByUserAndPaperWithNonEmptyAnswer(userID, paperID, taskID string) (int64, error) +} + +// MaterialDAOInterface 材料DAO接口 +type MaterialDAOInterface interface { + Create(material *question.Material) error + GetByID(id string) (*question.Material, error) + Search(query string, materialType question.MaterialType, page, pageSize int32) ([]*question.Material, int32, error) + Update(material *question.Material) error + Delete(id string) error +} + +// KnowledgeTreeDAOInterface 知识树DAO接口 +type KnowledgeTreeDAOInterface interface { + Create(node *question.KnowledgeTree) error + GetByID(id string) (*question.KnowledgeTree, error) + GetAll(treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) + GetByParentID(parentID string, treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) + Update(node *question.KnowledgeTree) error + Delete(id string) error + GetTree(treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) // 获取完整树形结构 +} diff --git a/internal/question/dao/knowledge_tree_dao_mongo.go b/internal/question/dao/knowledge_tree_dao_mongo.go new file mode 100644 index 0000000..fe4289a --- /dev/null +++ b/internal/question/dao/knowledge_tree_dao_mongo.go @@ -0,0 +1,272 @@ +package dao + +import ( + "context" + "fmt" + "time" + + "dd_fiber_api/internal/question" + "dd_fiber_api/pkg/database" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// KnowledgeTreeDAOMongo MongoDB 实现的知识树数据访问对象 +type KnowledgeTreeDAOMongo struct { + client *database.MongoDBClient + collection *mongo.Collection +} + +// NewKnowledgeTreeDAOMongo 创建 MongoDB 知识树 DAO +func NewKnowledgeTreeDAOMongo(client *database.MongoDBClient) *KnowledgeTreeDAOMongo { + return &KnowledgeTreeDAOMongo{ + client: client, + collection: client.Collection("knowledge_trees"), + } +} + +// KnowledgeTreeDocument MongoDB 文档结构 +type KnowledgeTreeDocument struct { + ID string `bson:"_id" json:"id"` + Type string `bson:"type" json:"type"` // 知识树类型:objective(客观题)或 subjective(主观题) + Title string `bson:"title" json:"title"` + ParentID string `bson:"parent_id" json:"parent_id"` // 根节点为空字符串 + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + DeletedAt *int64 `bson:"deleted_at,omitempty" json:"deleted_at,omitempty"` +} + +// Create 创建知识树节点 +func (dao *KnowledgeTreeDAOMongo) Create(node *question.KnowledgeTree) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + doc := &KnowledgeTreeDocument{ + ID: node.ID, + Type: string(node.Type), + Title: node.Title, + ParentID: node.ParentID, + CreatedAt: node.CreatedAt, + UpdatedAt: node.UpdatedAt, + } + + _, err := dao.collection.InsertOne(ctx, doc) + if err != nil { + return fmt.Errorf("插入知识树节点失败: %v", err) + } + + return nil +} + +// GetByID 根据ID获取知识树节点 +func (dao *KnowledgeTreeDAOMongo) GetByID(id string) (*question.KnowledgeTree, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "_id": id, + "deleted_at": bson.M{"$exists": false}, + } + + var doc KnowledgeTreeDocument + err := dao.collection.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("知识树节点不存在: %s", id) + } + return nil, fmt.Errorf("查询知识树节点失败: %v", err) + } + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("知识树节点不存在: %s", id) + } + return nil, fmt.Errorf("查询知识树节点失败: %v", err) + } + + return &question.KnowledgeTree{ + ID: doc.ID, + Type: question.KnowledgeTreeType(doc.Type), + Title: doc.Title, + ParentID: doc.ParentID, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }, nil +} + +// GetAll 获取所有知识树节点(不包括已删除的) +func (dao *KnowledgeTreeDAOMongo) GetAll(treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "type": string(treeType), + "deleted_at": bson.M{"$exists": false}, + } + + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}) + + cursor, err := dao.collection.Find(ctx, filter, opts) + if err != nil { + return nil, fmt.Errorf("查询知识树节点失败: %v", err) + } + defer cursor.Close(ctx) + + var nodes []*question.KnowledgeTree + for cursor.Next(ctx) { + var doc KnowledgeTreeDocument + if err := cursor.Decode(&doc); err != nil { + return nil, fmt.Errorf("解码知识树节点失败: %v", err) + } + nodes = append(nodes, &question.KnowledgeTree{ + ID: doc.ID, + Type: question.KnowledgeTreeType(doc.Type), + Title: doc.Title, + ParentID: doc.ParentID, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }) + } + + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("遍历知识树节点失败: %v", err) + } + + return nodes, nil +} + +// GetByParentID 根据父节点ID获取子节点列表 +func (dao *KnowledgeTreeDAOMongo) GetByParentID(parentID string, treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "parent_id": parentID, + "type": string(treeType), + "deleted_at": bson.M{"$exists": false}, + } + + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}) + + cursor, err := dao.collection.Find(ctx, filter, opts) + if err != nil { + return nil, fmt.Errorf("查询子节点失败: %v", err) + } + defer cursor.Close(ctx) + + var nodes []*question.KnowledgeTree + for cursor.Next(ctx) { + var doc KnowledgeTreeDocument + if err := cursor.Decode(&doc); err != nil { + return nil, fmt.Errorf("解码子节点失败: %v", err) + } + nodes = append(nodes, &question.KnowledgeTree{ + ID: doc.ID, + Type: question.KnowledgeTreeType(doc.Type), + Title: doc.Title, + ParentID: doc.ParentID, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }) + } + + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("遍历子节点失败: %v", err) + } + + return nodes, nil +} + +// Update 更新知识树节点 +func (dao *KnowledgeTreeDAOMongo) Update(node *question.KnowledgeTree) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "_id": node.ID, + "deleted_at": bson.M{"$exists": false}, + } + + update := bson.M{ + "$set": bson.M{ + "type": string(node.Type), + "title": node.Title, + "parent_id": node.ParentID, + "updated_at": node.UpdatedAt, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("更新知识树节点失败: %v", err) + } + + if result.MatchedCount == 0 { + return fmt.Errorf("知识树节点不存在: %s", node.ID) + } + + return nil +} + +// Delete 删除知识树节点(软删除) +func (dao *KnowledgeTreeDAOMongo) Delete(id string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + now := time.Now().Unix() + filter := bson.M{ + "_id": id, + "deleted_at": bson.M{"$exists": false}, + } + + update := bson.M{ + "$set": bson.M{ + "deleted_at": now, + "updated_at": now, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("删除知识树节点失败: %v", err) + } + + if result.MatchedCount == 0 { + return fmt.Errorf("知识树节点不存在: %s", id) + } + + return nil +} + +// GetTree 获取完整树形结构 +func (dao *KnowledgeTreeDAOMongo) GetTree(treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) { + // 获取所有节点 + allNodes, err := dao.GetAll(treeType) + if err != nil { + return nil, err + } + + // 构建节点映射 + nodeMap := make(map[string]*question.KnowledgeTree) + for _, node := range allNodes { + nodeMap[node.ID] = node + node.Children = []*question.KnowledgeTree{} // 初始化子节点数组 + } + + // 构建树形结构 + var rootNodes []*question.KnowledgeTree + for _, node := range allNodes { + if node.ParentID == "" { + // 根节点 + rootNodes = append(rootNodes, node) + } else { + // 子节点,添加到父节点的Children中 + if parent, exists := nodeMap[node.ParentID]; exists { + parent.Children = append(parent.Children, node) + } + } + } + + return rootNodes, nil +} diff --git a/internal/question/dao/material_dao_mongo.go b/internal/question/dao/material_dao_mongo.go new file mode 100644 index 0000000..6a33862 --- /dev/null +++ b/internal/question/dao/material_dao_mongo.go @@ -0,0 +1,247 @@ +package dao + +import ( + "context" + "fmt" + "time" + + "dd_fiber_api/internal/question" + "dd_fiber_api/pkg/database" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// MaterialDAOMongo MongoDB 实现的材料数据访问对象 +type MaterialDAOMongo struct { + client *database.MongoDBClient + collection *mongo.Collection +} + +// NewMaterialDAOMongo 创建 MongoDB 材料 DAO +func NewMaterialDAOMongo(client *database.MongoDBClient) *MaterialDAOMongo { + return &MaterialDAOMongo{ + client: client, + collection: client.Collection("materials"), + } +} + +// MaterialDocument MongoDB 文档结构 +type MaterialDocument struct { + ID string `bson:"_id" json:"id"` + Type string `bson:"type" json:"type"` // 材料类型:objective 或 subjective + Name string `bson:"name" json:"name"` + Content string `bson:"content" json:"content"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + DeletedAt *int64 `bson:"deleted_at,omitempty" json:"deleted_at,omitempty"` +} + +// Create 创建材料 +func (dao *MaterialDAOMongo) Create(material *question.Material) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + doc := &MaterialDocument{ + ID: material.ID, + Type: string(material.Type), + Name: material.Name, + Content: material.Content, + CreatedAt: material.CreatedAt, + UpdatedAt: material.UpdatedAt, + } + + _, err := dao.collection.InsertOne(ctx, doc) + if err != nil { + return fmt.Errorf("插入材料失败: %v", err) + } + + return nil +} + +// GetByID 根据ID获取材料 +func (dao *MaterialDAOMongo) GetByID(id string) (*question.Material, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var doc MaterialDocument + filter := bson.M{ + "_id": id, + "deleted_at": bson.M{"$exists": false}, + } + + err := dao.collection.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("材料不存在") + } + return nil, fmt.Errorf("查询材料失败: %v", err) + } + + return dao.documentToMaterial(&doc), nil +} + +// Search 搜索材料 +func (dao *MaterialDAOMongo) Search(query string, materialType question.MaterialType, page, pageSize int32) ([]*question.Material, int32, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{ + "deleted_at": bson.M{"$exists": false}, + } + + // 按类型过滤 + if materialType != "" { + filter["type"] = string(materialType) + } + + // 全文搜索(支持名称和内容) + if query != "" { + filter["$or"] = []bson.M{ + {"name": bson.M{"$regex": query, "$options": "i"}}, + {"content": bson.M{"$regex": query, "$options": "i"}}, + } + } + + // 查询总数 + total, err := dao.collection.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, fmt.Errorf("查询总数失败: %v", err) + } + + // 查询数据 + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + opts := options.Find(). + SetSkip(skip). + SetLimit(limit). + SetSort(bson.M{"created_at": -1}) + + cursor, err := dao.collection.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("查询材料失败: %v", err) + } + defer cursor.Close(ctx) + + var docs []MaterialDocument + if err := cursor.All(ctx, &docs); err != nil { + return nil, 0, fmt.Errorf("解析材料数据失败: %v", err) + } + + materials := make([]*question.Material, len(docs)) + for i, doc := range docs { + materials[i] = dao.documentToMaterial(&doc) + } + + return materials, int32(total), nil +} + +// Update 更新材料 +func (dao *MaterialDAOMongo) Update(material *question.Material) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "_id": material.ID, + "deleted_at": bson.M{"$exists": false}, + } + update := bson.M{ + "$set": bson.M{ + "type": string(material.Type), + "name": material.Name, + "content": material.Content, + "updated_at": material.UpdatedAt, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("更新材料失败: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("材料不存在或已被删除") + } + + return nil +} + +// Delete 删除材料(软删除) +func (dao *MaterialDAOMongo) Delete(id string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "_id": id, + "deleted_at": bson.M{"$exists": false}, + } + now := time.Now().Unix() + update := bson.M{ + "$set": bson.M{ + "deleted_at": now, + "updated_at": now, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("删除材料失败: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("材料不存在或已被删除") + } + + return nil +} + +// documentToMaterial 将 MongoDB 文档转换为 Material 对象 +func (dao *MaterialDAOMongo) documentToMaterial(doc *MaterialDocument) *question.Material { + materialType := question.MaterialType(doc.Type) + // 兼容旧数据:如果没有 type 字段,默认为 objective + if materialType == "" { + materialType = question.MaterialTypeObjective + } + return &question.Material{ + ID: doc.ID, + Type: materialType, + Name: doc.Name, + Content: doc.Content, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + } +} + +// CreateIndexes 创建索引 +func (dao *MaterialDAOMongo) CreateIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "name", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "created_at", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "name", Value: "text"}, + {Key: "content", Value: "text"}, + }, + Options: options.Index().SetName("text_index"), + }, + } + + _, err := dao.collection.Indexes().CreateMany(ctx, indexes) + if err != nil { + return fmt.Errorf("创建索引失败: %v", err) + } + + return nil +} + diff --git a/internal/question/dao/paper_dao_mongo.go b/internal/question/dao/paper_dao_mongo.go new file mode 100644 index 0000000..f7a1254 --- /dev/null +++ b/internal/question/dao/paper_dao_mongo.go @@ -0,0 +1,449 @@ +package dao + +import ( + "context" + "fmt" + "log" + "time" + + "dd_fiber_api/internal/question" + "dd_fiber_api/pkg/database" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// PaperDAOMongo MongoDB 实现的试卷数据访问对象 +type PaperDAOMongo struct { + client *database.MongoDBClient + collection *mongo.Collection + questionDAO QuestionDAOInterface // 用于获取题目详情 +} + +// NewPaperDAOMongo 创建 MongoDB 试卷 DAO +func NewPaperDAOMongo(client *database.MongoDBClient, questionDAO QuestionDAOInterface) *PaperDAOMongo { + return &PaperDAOMongo{ + client: client, + collection: client.Collection("papers"), + questionDAO: questionDAO, + } +} + +// PaperDocument MongoDB 文档结构 +// 使用引用方式:存储题目ID数组,而不是嵌入题目数据 +type PaperDocument struct { + ID string `bson:"_id" json:"id"` + Title string `bson:"title" json:"title"` + Description string `bson:"description" json:"description"` + Source string `bson:"source,omitempty" json:"source,omitempty"` // 题目出处(可选) + QuestionIDs []string `bson:"question_ids" json:"question_ids"` // 引用:题目ID数组 + MaterialIDs []string `bson:"material_ids,omitempty" json:"material_ids,omitempty"` // 关联的材料ID列表(可选,用于主观题组卷) + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + DeletedAt *int64 `bson:"deleted_at,omitempty" json:"deleted_at,omitempty"` +} + +// Create 创建试卷 +func (dao *PaperDAOMongo) Create(paper *question.Paper) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 确保 MaterialIDs 不为 nil + materialIDs := paper.MaterialIDs + if materialIDs == nil { + materialIDs = []string{} + } + + doc := &PaperDocument{ + ID: paper.ID, + Title: paper.Title, + Description: paper.Description, + Source: paper.Source, + QuestionIDs: paper.QuestionIDs, + MaterialIDs: materialIDs, + CreatedAt: paper.CreatedAt, + UpdatedAt: paper.UpdatedAt, + } + + _, err := dao.collection.InsertOne(ctx, doc) + if err != nil { + return fmt.Errorf("插入试卷失败: %v", err) + } + + return nil +} + +// GetByID 根据ID获取试卷 +func (dao *PaperDAOMongo) GetByID(id string) (*question.Paper, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var doc PaperDocument + filter := bson.M{ + "_id": id, + "deleted_at": bson.M{"$exists": false}, + } + + // 先检查是否存在(包括已删除的) + var allDoc PaperDocument + allFilter := bson.M{"_id": id} + err := dao.collection.FindOne(ctx, allFilter).Decode(&allDoc) + if err == nil { + // 如果找到了,检查是否被删除 + if allDoc.DeletedAt != nil { + log.Printf("试卷 %s 存在但已被软删除 (deleted_at: %d)", id, *allDoc.DeletedAt) + return nil, fmt.Errorf("试卷不存在") + } + } + + err = dao.collection.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + log.Printf("试卷 %s 在数据库中不存在", id) + return nil, fmt.Errorf("试卷不存在") + } + return nil, fmt.Errorf("查询试卷失败: %v", err) + } + + return dao.documentToPaper(&doc), nil +} + +// GetWithQuestions 获取试卷及题目详情 +func (dao *PaperDAOMongo) GetWithQuestions(id string) (*question.Paper, error) { + paper, err := dao.GetByID(id) + if err != nil { + return nil, err + } + + // 通过引用获取题目详情 + if len(paper.QuestionIDs) > 0 && dao.questionDAO != nil { + var questions []*question.QuestionInfo + for _, questionID := range paper.QuestionIDs { + q, err := dao.questionDAO.GetByID(questionID) + if err != nil { + // 如果题目不存在,跳过 + continue + } + + questionInfo := &question.QuestionInfo{ + ID: q.ID, + Type: q.Type, + Content: q.Content, + Answer: q.Answer, + Explanation: q.Explanation, + Options: q.Options, + KnowledgeTreeIDs: q.KnowledgeTreeIDs, + MaterialID: q.MaterialID, + } + questions = append(questions, questionInfo) + } + paper.Questions = questions + } + + return paper, nil +} + +// Search 搜索试卷 +func (dao *PaperDAOMongo) Search(query string, page, pageSize int32) ([]*question.Paper, int32, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{ + "deleted_at": bson.M{"$exists": false}, + } + + // 全文搜索 + if query != "" { + filter["$or"] = []bson.M{ + {"title": bson.M{"$regex": query, "$options": "i"}}, + {"description": bson.M{"$regex": query, "$options": "i"}}, + } + } + + // 查询总数 + total, err := dao.collection.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, fmt.Errorf("查询总数失败: %v", err) + } + + // 查询数据 + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + opts := options.Find(). + SetSkip(skip). + SetLimit(limit). + SetSort(bson.M{"created_at": -1}) + + cursor, err := dao.collection.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("查询试卷失败: %v", err) + } + defer cursor.Close(ctx) + + var docs []PaperDocument + if err := cursor.All(ctx, &docs); err != nil { + return nil, 0, fmt.Errorf("解析试卷数据失败: %v", err) + } + + papers := make([]*question.Paper, len(docs)) + for i, doc := range docs { + papers[i] = dao.documentToPaper(&doc) + } + + return papers, int32(total), nil +} + +// Update 更新试卷 +func (dao *PaperDAOMongo) Update(paper *question.Paper) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 确保 MaterialIDs 不为 nil + materialIDs := paper.MaterialIDs + if materialIDs == nil { + materialIDs = []string{} + } + + // 确保 QuestionIDs 不为 nil + questionIDs := paper.QuestionIDs + if questionIDs == nil { + questionIDs = []string{} + } + + filter := bson.M{ + "_id": paper.ID, + "deleted_at": bson.M{"$exists": false}, + } + update := bson.M{ + "$set": bson.M{ + "title": paper.Title, + "description": paper.Description, + "source": paper.Source, + "question_ids": questionIDs, + "material_ids": materialIDs, + "updated_at": paper.UpdatedAt, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err == nil { + log.Printf("DAO Update - 更新结果: MatchedCount=%d, ModifiedCount=%d", result.MatchedCount, result.ModifiedCount) + } + if err != nil { + return fmt.Errorf("更新试卷失败: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("试卷不存在或已被删除") + } + + return nil +} + +// Delete 删除试卷(软删除) +func (dao *PaperDAOMongo) Delete(id string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "_id": id, + "deleted_at": bson.M{"$exists": false}, + } + now := time.Now().Unix() + update := bson.M{ + "$set": bson.M{ + "deleted_at": now, + "updated_at": now, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("删除试卷失败: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("试卷不存在或已被删除") + } + + return nil +} + +// BatchDelete 批量删除试卷 +func (dao *PaperDAOMongo) BatchDelete(ids []string) (int, []string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var deleted int + var failed []string + now := time.Now().Unix() + + for _, id := range ids { + filter := bson.M{"_id": id, "deleted_at": bson.M{"$exists": false}} + update := bson.M{ + "$set": bson.M{ + "deleted_at": now, + "updated_at": now, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + failed = append(failed, id) + continue + } + if result.MatchedCount > 0 { + deleted++ + } else { + failed = append(failed, id) + } + } + + return deleted, failed, nil +} + +// AddQuestions 添加题目到试卷(使用引用方式) +func (dao *PaperDAOMongo) AddQuestions(paperID string, questionIDs []string) (int, []string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 获取当前试卷 + paper, err := dao.GetByID(paperID) + if err != nil { + return 0, questionIDs, err + } + + // 合并题目ID(去重) + existingIDs := make(map[string]bool) + for _, id := range paper.QuestionIDs { + existingIDs[id] = true + } + + var added int + var failed []string + var newIDs []string + + for _, questionID := range questionIDs { + if existingIDs[questionID] { + // 已存在,跳过 + continue + } + newIDs = append(newIDs, questionID) + added++ + } + + if len(newIDs) > 0 { + // 更新试卷的题目ID数组 + allIDs := append(paper.QuestionIDs, newIDs...) + filter := bson.M{"_id": paperID} + update := bson.M{ + "$set": bson.M{ + "question_ids": allIDs, + "updated_at": question.GetCurrentTimestamp(), + }, + } + + _, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return 0, questionIDs, fmt.Errorf("添加题目失败: %v", err) + } + } + + return added, failed, nil +} + +// RemoveQuestions 从试卷移除题目(使用引用方式) +func (dao *PaperDAOMongo) RemoveQuestions(paperID string, questionIDs []string) (int, []string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 获取当前试卷 + paper, err := dao.GetByID(paperID) + if err != nil { + return 0, questionIDs, err + } + + // 构建要移除的ID集合 + removeSet := make(map[string]bool) + for _, id := range questionIDs { + removeSet[id] = true + } + + // 过滤出保留的题目ID + var remainingIDs []string + for _, id := range paper.QuestionIDs { + if !removeSet[id] { + remainingIDs = append(remainingIDs, id) + } + } + + removed := len(paper.QuestionIDs) - len(remainingIDs) + + // 更新试卷 + filter := bson.M{"_id": paperID} + update := bson.M{ + "$set": bson.M{ + "question_ids": remainingIDs, + "updated_at": question.GetCurrentTimestamp(), + }, + } + + _, err = dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return 0, questionIDs, fmt.Errorf("移除题目失败: %v", err) + } + + return removed, []string{}, nil +} + +// documentToPaper 将 MongoDB 文档转换为 Paper 对象 +func (dao *PaperDAOMongo) documentToPaper(doc *PaperDocument) *question.Paper { + return &question.Paper{ + ID: doc.ID, + Title: doc.Title, + Description: doc.Description, + Source: doc.Source, + QuestionIDs: doc.QuestionIDs, + MaterialIDs: doc.MaterialIDs, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + } +} + +// CreateIndexes 创建索引 +func (dao *PaperDAOMongo) CreateIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "title", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "question_ids", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "created_at", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "title", Value: "text"}, + {Key: "description", Value: "text"}, + }, + Options: options.Index().SetName("text_index"), + }, + } + + _, err := dao.collection.Indexes().CreateMany(ctx, indexes) + if err != nil { + return fmt.Errorf("创建索引失败: %v", err) + } + + return nil +} diff --git a/internal/question/dao/question_dao_mongo.go b/internal/question/dao/question_dao_mongo.go new file mode 100644 index 0000000..58d0805 --- /dev/null +++ b/internal/question/dao/question_dao_mongo.go @@ -0,0 +1,344 @@ +package dao + +import ( + "context" + "fmt" + "time" + + "dd_fiber_api/internal/question" + "dd_fiber_api/pkg/database" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// QuestionDAOMongo MongoDB 实现的题目数据访问对象 +type QuestionDAOMongo struct { + client *database.MongoDBClient + collection *mongo.Collection +} + +// NewQuestionDAOMongo 创建 MongoDB 题目 DAO +func NewQuestionDAOMongo(client *database.MongoDBClient) *QuestionDAOMongo { + return &QuestionDAOMongo{ + client: client, + collection: client.Collection("questions"), + } +} + +// QuestionDocument MongoDB 文档结构 +type QuestionDocument struct { + ID string `bson:"_id" json:"id"` + Type int32 `bson:"type" json:"type"` + Name string `bson:"name,omitempty" json:"name,omitempty"` // 题目名称(可选) + Source string `bson:"source,omitempty" json:"source,omitempty"` // 题目出处(可选) + MaterialID string `bson:"material_id,omitempty" json:"material_id,omitempty"` // 关联材料ID(引用) + Content string `bson:"content" json:"content"` + Options []string `bson:"options" json:"options"` + Answer string `bson:"answer" json:"answer"` + Explanation string `bson:"explanation" json:"explanation"` + KnowledgeTreeIDs []string `bson:"knowledge_tree_ids" json:"knowledge_tree_ids"` // 关联的知识树ID列表(替代原来的tags) + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + DeletedAt *int64 `bson:"deleted_at,omitempty" json:"deleted_at,omitempty"` +} + +// Create 创建题目 +func (dao *QuestionDAOMongo) Create(question *question.Question) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 确保 KnowledgeTreeIDs 不为 nil(即使是空数组也要写入) + knowledgeTreeIDs := question.KnowledgeTreeIDs + if knowledgeTreeIDs == nil { + knowledgeTreeIDs = []string{} + } + + doc := &QuestionDocument{ + ID: question.ID, + Type: int32(question.Type), + Name: question.Name, + Source: question.Source, + MaterialID: question.MaterialID, + Content: question.Content, + Options: question.Options, + Answer: question.Answer, + Explanation: question.Explanation, + KnowledgeTreeIDs: knowledgeTreeIDs, + CreatedAt: question.CreatedAt, + UpdatedAt: question.UpdatedAt, + } + + _, err := dao.collection.InsertOne(ctx, doc) + if err != nil { + return fmt.Errorf("插入题目失败: %v", err) + } + + return nil +} + +// GetByID 根据ID获取题目 +func (dao *QuestionDAOMongo) GetByID(id string) (*question.Question, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var doc QuestionDocument + filter := bson.M{ + "_id": id, + "deleted_at": bson.M{"$exists": false}, + } + + err := dao.collection.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("题目不存在") + } + return nil, fmt.Errorf("查询题目失败: %v", err) + } + + questionObj := dao.documentToQuestion(&doc) + return questionObj, nil +} + +// Search 搜索题目 +func (dao *QuestionDAOMongo) Search(query string, qType question.QuestionType, knowledgeTreeIds []string, page, pageSize int32) ([]*question.Question, int32, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 构建查询条件 + filter := bson.M{ + "deleted_at": bson.M{"$exists": false}, + } + + // 全文搜索(支持名称、内容、出处) + if query != "" { + filter["$or"] = []bson.M{ + {"name": bson.M{"$regex": query, "$options": "i"}}, + {"content": bson.M{"$regex": query, "$options": "i"}}, + {"source": bson.M{"$regex": query, "$options": "i"}}, + } + } + + // 题目类型过滤 + if qType != question.QuestionTypeUnspecified { + filter["type"] = int32(qType) + } + + // 知识树过滤 + if len(knowledgeTreeIds) > 0 { + filter["knowledge_tree_ids"] = bson.M{"$in": knowledgeTreeIds} + } + + // 查询总数 + total, err := dao.collection.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, fmt.Errorf("查询总数失败: %v", err) + } + + // 查询数据 + skip := int64((page - 1) * pageSize) + limit := int64(pageSize) + + opts := options.Find(). + SetSkip(skip). + SetLimit(limit). + SetSort(bson.M{"created_at": -1}) + + cursor, err := dao.collection.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("查询题目失败: %v", err) + } + defer cursor.Close(ctx) + + var docs []QuestionDocument + if err := cursor.All(ctx, &docs); err != nil { + return nil, 0, fmt.Errorf("解析题目数据失败: %v", err) + } + + questions := make([]*question.Question, len(docs)) + for i, doc := range docs { + questions[i] = dao.documentToQuestion(&doc) + } + + return questions, int32(total), nil +} + +// Update 更新题目 +func (dao *QuestionDAOMongo) Update(question *question.Question) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 确保 KnowledgeTreeIDs 不为 nil(即使是空数组也要写入) + knowledgeTreeIDs := question.KnowledgeTreeIDs + if knowledgeTreeIDs == nil { + knowledgeTreeIDs = []string{} + } + + filter := bson.M{"_id": question.ID} + update := bson.M{ + "$set": bson.M{ + "type": int32(question.Type), + "name": question.Name, + "source": question.Source, + "material_id": question.MaterialID, + "content": question.Content, + "options": question.Options, + "answer": question.Answer, + "explanation": question.Explanation, + "knowledge_tree_ids": knowledgeTreeIDs, + "updated_at": question.UpdatedAt, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("更新题目失败: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("题目不存在") + } + + return nil +} + +// Delete 删除题目(软删除) +func (dao *QuestionDAOMongo) Delete(id string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{"_id": id} + now := time.Now().Unix() + update := bson.M{ + "$set": bson.M{ + "deleted_at": now, + "updated_at": now, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("删除题目失败: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("题目不存在") + } + + return nil +} + +// documentToQuestion 将 MongoDB 文档转换为 Question 对象 +func (dao *QuestionDAOMongo) documentToQuestion(doc *QuestionDocument) *question.Question { + // 确保 KnowledgeTreeIDs 不为 nil + knowledgeTreeIDs := doc.KnowledgeTreeIDs + if knowledgeTreeIDs == nil { + knowledgeTreeIDs = []string{} + } + + return &question.Question{ + ID: doc.ID, + Type: question.QuestionType(doc.Type), + Name: doc.Name, + Source: doc.Source, + MaterialID: doc.MaterialID, + Content: doc.Content, + Options: doc.Options, + Answer: doc.Answer, + Explanation: doc.Explanation, + KnowledgeTreeIDs: knowledgeTreeIDs, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + } +} + +// BatchDelete 批量删除题目 +func (dao *QuestionDAOMongo) BatchDelete(ids []string) (int, []string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var deleted int + var failed []string + now := time.Now().Unix() + + for _, id := range ids { + filter := bson.M{"_id": id, "deleted_at": bson.M{"$exists": false}} + update := bson.M{ + "$set": bson.M{ + "deleted_at": now, + "updated_at": now, + }, + } + + result, err := dao.collection.UpdateOne(ctx, filter, update) + if err != nil { + failed = append(failed, id) + continue + } + if result.MatchedCount > 0 { + deleted++ + } else { + failed = append(failed, id) + } + } + + return deleted, failed, nil +} + +// CreateIndexes 创建索引(在应用启动时调用) +func (dao *QuestionDAOMongo) CreateIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 先尝试删除旧的 text_index(如果存在且包含 title 字段) + // 忽略删除错误,因为索引可能不存在 + _, _ = dao.collection.Indexes().DropOne(ctx, "text_index") + + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "type", Value: 1}, + {Key: "created_at", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "knowledge_tree_ids", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "name", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "source", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "material_id", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "content", Value: "text"}, + {Key: "name", Value: "text"}, + {Key: "source", Value: "text"}, + }, + Options: options.Index().SetName("text_index"), + }, + { + Keys: bson.D{ + {Key: "created_at", Value: -1}, + }, + }, + } + + _, err := dao.collection.Indexes().CreateMany(ctx, indexes) + if err != nil { + return fmt.Errorf("创建索引失败: %v", err) + } + + return nil +} diff --git a/internal/question/handler/answer_record_handler.go b/internal/question/handler/answer_record_handler.go new file mode 100644 index 0000000..d8365b1 --- /dev/null +++ b/internal/question/handler/answer_record_handler.go @@ -0,0 +1,174 @@ +package handler + +import ( + "dd_fiber_api/internal/question/service" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// AnswerRecordHandler 答题记录处理器 +type AnswerRecordHandler struct { + answerRecordService *service.AnswerRecordService +} + +// NewAnswerRecordHandler 创建答题记录处理器 +func NewAnswerRecordHandler(answerRecordService *service.AnswerRecordService) *AnswerRecordHandler { + return &AnswerRecordHandler{ + answerRecordService: answerRecordService, + } +} + +// CreateAnswerRecords 批量创建答题记录 +func (h *AnswerRecordHandler) CreateAnswerRecords(c *fiber.Ctx) error { + var req service.CreateAnswerRecordsRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.UserId == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + + if len(req.Answers) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "答案列表不能为空", + }) + } + + resp, err := h.answerRecordService.CreateAnswerRecords(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "创建成功", + "results": resp.Results, + "total_questions": resp.TotalQuestions, + "correct_count": resp.CorrectCount, + "wrong_count": resp.WrongCount, + }) +} + +// GetAnswerRecord 获取答题记录 +func (h *AnswerRecordHandler) GetAnswerRecord(c *fiber.Ctx) error { + id := c.Query("id") + userID := c.Query("user_id") + questionID := c.Query("question_id") + paperID := c.Query("paper_id") + taskID := c.Query("task_id") // 打卡营任务ID,用于不同打卡营的答题隔离 + pageStr := c.Query("page", "1") + pageSizeStr := c.Query("page_size", "10") + + page, _ := strconv.ParseInt(pageStr, 10, 32) + pageSize, _ := strconv.ParseInt(pageSizeStr, 10, 32) + + records, total, err := h.answerRecordService.GetAnswerRecord(id, userID, questionID, paperID, taskID, int32(page), int32(pageSize)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "records": records, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// GetUserAnswerRecord 获取用户答题记录 +func (h *AnswerRecordHandler) GetUserAnswerRecord(c *fiber.Ctx) error { + userID := c.Query("user_id") + questionID := c.Query("question_id") + + if userID == "" || questionID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID和题目ID不能为空", + }) + } + + record, err := h.answerRecordService.GetUserAnswerRecord(userID, questionID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "record": record, + }) +} + +// GetPaperAnswerStatistics 获取试卷答题统计 +func (h *AnswerRecordHandler) GetPaperAnswerStatistics(c *fiber.Ctx) error { + userID := c.Query("user_id") + paperID := c.Query("paper_id") + taskID := c.Query("task_id") // 打卡营任务ID,用于不同打卡营的答题隔离 + + if userID == "" || paperID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID和试卷ID不能为空", + }) + } + + stats, err := h.answerRecordService.GetPaperAnswerStatistics(userID, paperID, taskID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "statistics": stats, + }) +} + +// DeleteAnswerRecord 根据用户ID删除答题记录 +func (h *AnswerRecordHandler) DeleteAnswerRecord(c *fiber.Ctx) error { + userID := c.Query("user_id") + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "用户ID不能为空", + }) + } + + deletedCount, err := h.answerRecordService.DeleteAnswerRecord(userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "删除成功", + "deleted_count": deletedCount, + }) +} + diff --git a/internal/question/handler/knowledge_tree_handler.go b/internal/question/handler/knowledge_tree_handler.go new file mode 100644 index 0000000..6961c18 --- /dev/null +++ b/internal/question/handler/knowledge_tree_handler.go @@ -0,0 +1,253 @@ +package handler + +import ( + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/service" + + "github.com/gofiber/fiber/v2" +) + +// KnowledgeTreeHandler 知识树处理器 +type KnowledgeTreeHandler struct { + knowledgeTreeService *service.KnowledgeTreeService +} + +// NewKnowledgeTreeHandler 创建知识树处理器 +func NewKnowledgeTreeHandler(knowledgeTreeService *service.KnowledgeTreeService) *KnowledgeTreeHandler { + return &KnowledgeTreeHandler{ + knowledgeTreeService: knowledgeTreeService, + } +} + +// CreateKnowledgeTreeNode 创建知识树节点 +func (h *KnowledgeTreeHandler) CreateKnowledgeTreeNode(c *fiber.Ctx) error { + var req service.CreateKnowledgeTreeNodeRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.Title == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "节点标题不能为空", + }) + } + + if req.Type == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型不能为空", + }) + } + + if req.Type != question.KnowledgeTreeTypeObjective && req.Type != question.KnowledgeTreeTypeSubjective { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型无效,必须是 objective 或 subjective", + }) + } + + node, err := h.knowledgeTreeService.CreateKnowledgeTreeNode(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "创建成功", + "node": node, + }) +} + +// GetKnowledgeTreeNode 获取知识树节点 +func (h *KnowledgeTreeHandler) GetKnowledgeTreeNode(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "节点ID不能为空", + }) + } + + node, err := h.knowledgeTreeService.GetKnowledgeTreeNode(id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "node": node, + }) +} + +// GetAllKnowledgeTreeNodes 获取所有知识树节点(扁平列表) +func (h *KnowledgeTreeHandler) GetAllKnowledgeTreeNodes(c *fiber.Ctx) error { + treeTypeStr := c.Query("type", "") + if treeTypeStr == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型不能为空", + }) + } + + treeType := question.KnowledgeTreeType(treeTypeStr) + if treeType != question.KnowledgeTreeTypeObjective && treeType != question.KnowledgeTreeTypeSubjective { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型无效,必须是 objective 或 subjective", + }) + } + + nodes, err := h.knowledgeTreeService.GetAllKnowledgeTreeNodes(treeType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "nodes": nodes, + }) +} + +// GetKnowledgeTreeByParentID 根据父节点ID获取子节点列表 +func (h *KnowledgeTreeHandler) GetKnowledgeTreeByParentID(c *fiber.Ctx) error { + parentID := c.Query("parent_id", "") // 空字符串表示根节点 + treeTypeStr := c.Query("type", "") + if treeTypeStr == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型不能为空", + }) + } + + treeType := question.KnowledgeTreeType(treeTypeStr) + if treeType != question.KnowledgeTreeTypeObjective && treeType != question.KnowledgeTreeTypeSubjective { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型无效,必须是 objective 或 subjective", + }) + } + + nodes, err := h.knowledgeTreeService.GetKnowledgeTreeByParentID(parentID, treeType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "nodes": nodes, + }) +} + +// GetKnowledgeTree 获取完整树形结构 +func (h *KnowledgeTreeHandler) GetKnowledgeTree(c *fiber.Ctx) error { + treeTypeStr := c.Query("type", "") + if treeTypeStr == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型不能为空", + }) + } + + treeType := question.KnowledgeTreeType(treeTypeStr) + if treeType != question.KnowledgeTreeTypeObjective && treeType != question.KnowledgeTreeTypeSubjective { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "知识树类型无效,必须是 objective 或 subjective", + }) + } + + tree, err := h.knowledgeTreeService.GetKnowledgeTree(treeType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "tree": tree, + }) +} + +// UpdateKnowledgeTreeNode 更新知识树节点 +func (h *KnowledgeTreeHandler) UpdateKnowledgeTreeNode(c *fiber.Ctx) error { + var req service.UpdateKnowledgeTreeNodeRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "节点ID不能为空", + }) + } + + if req.Title == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "节点标题不能为空", + }) + } + + err := h.knowledgeTreeService.UpdateKnowledgeTreeNode(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "更新成功", + }) +} + +// DeleteKnowledgeTreeNode 删除知识树节点 +func (h *KnowledgeTreeHandler) DeleteKnowledgeTreeNode(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "节点ID不能为空", + }) + } + + err := h.knowledgeTreeService.DeleteKnowledgeTreeNode(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "删除成功", + }) +} diff --git a/internal/question/handler/material_handler.go b/internal/question/handler/material_handler.go new file mode 100644 index 0000000..813ce03 --- /dev/null +++ b/internal/question/handler/material_handler.go @@ -0,0 +1,169 @@ +package handler + +import ( + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/service" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// MaterialHandler 材料处理器 +type MaterialHandler struct { + materialService *service.MaterialService +} + +// NewMaterialHandler 创建材料处理器 +func NewMaterialHandler(materialService *service.MaterialService) *MaterialHandler { + return &MaterialHandler{ + materialService: materialService, + } +} + +// CreateMaterial 创建材料 +func (h *MaterialHandler) CreateMaterial(c *fiber.Ctx) error { + var req service.CreateMaterialRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + material, err := h.materialService.CreateMaterial(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "创建成功", + "id": material.ID, + "material": material, + }) +} + +// GetMaterial 获取材料 +func (h *MaterialHandler) GetMaterial(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "材料ID不能为空", + }) + } + + material, err := h.materialService.GetMaterial(id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "material": material, + }) +} + +// SearchMaterials 搜索材料 +func (h *MaterialHandler) SearchMaterials(c *fiber.Ctx) error { + query := c.Query("query", "") + materialType := c.Query("type", "") // 材料类型:objective 或 subjective + pageStr := c.Query("page", "1") + pageSizeStr := c.Query("page_size", "10") + + page, err := strconv.ParseInt(pageStr, 10, 32) + if err != nil || page < 1 { + page = 1 + } + + pageSize, err := strconv.ParseInt(pageSizeStr, 10, 32) + if err != nil || pageSize < 1 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + var materialTypeEnum question.MaterialType + if materialType != "" { + materialTypeEnum = question.MaterialType(materialType) + } + + materials, total, err := h.materialService.SearchMaterials(query, materialTypeEnum, int32(page), int32(pageSize)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "materials": materials, + "total": total, + }) +} + +// UpdateMaterial 更新材料 +func (h *MaterialHandler) UpdateMaterial(c *fiber.Ctx) error { + var req service.UpdateMaterialRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.ID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "材料ID不能为空", + }) + } + + err := h.materialService.UpdateMaterial(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "更新成功", + }) +} + +// DeleteMaterial 删除材料 +func (h *MaterialHandler) DeleteMaterial(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "材料ID不能为空", + }) + } + + err := h.materialService.DeleteMaterial(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "删除成功", + }) +} + diff --git a/internal/question/handler/paper_handler.go b/internal/question/handler/paper_handler.go new file mode 100644 index 0000000..66451d1 --- /dev/null +++ b/internal/question/handler/paper_handler.go @@ -0,0 +1,285 @@ +package handler + +import ( + "dd_fiber_api/internal/question/service" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// PaperHandler 试卷处理器 +type PaperHandler struct { + paperService *service.PaperService +} + +// NewPaperHandler 创建试卷处理器 +func NewPaperHandler(paperService *service.PaperService) *PaperHandler { + return &PaperHandler{ + paperService: paperService, + } +} + +// CreatePaper 创建试卷 +func (h *PaperHandler) CreatePaper(c *fiber.Ctx) error { + var req service.CreatePaperRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + paper, err := h.paperService.CreatePaper(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "创建成功", + "paper": paper, + }) +} + +// GetPaper 获取试卷 +func (h *PaperHandler) GetPaper(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "试卷ID不能为空", + }) + } + + paper, err := h.paperService.GetPaper(id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "paper": paper, + }) +} + +// SearchPapers 搜索试卷 +func (h *PaperHandler) SearchPapers(c *fiber.Ctx) error { + query := c.Query("query", "") + pageStr := c.Query("page", "1") + pageSizeStr := c.Query("page_size", "10") + + page, _ := strconv.ParseInt(pageStr, 10, 32) + pageSize, _ := strconv.ParseInt(pageSizeStr, 10, 32) + + papers, total, err := h.paperService.SearchPapers(query, int32(page), int32(pageSize)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "搜索成功", + "papers": papers, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// UpdatePaper 更新试卷 +func (h *PaperHandler) UpdatePaper(c *fiber.Ctx) error { + var req service.UpdatePaperRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.Id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "试卷ID不能为空", + }) + } + + err := h.paperService.UpdatePaper(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "更新成功", + }) +} + +// DeletePaper 删除试卷 +func (h *PaperHandler) DeletePaper(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "试卷ID不能为空", + }) + } + + err := h.paperService.DeletePaper(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "删除成功", + }) +} + +// BatchDeletePapers 批量删除试卷 +func (h *PaperHandler) BatchDeletePapers(c *fiber.Ctx) error { + var req struct { + PaperIds []string `json:"paper_ids"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if len(req.PaperIds) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "试卷ID列表不能为空", + }) + } + + deletedCount, failedIDs, err := h.paperService.BatchDeletePapers(req.PaperIds) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "批量删除完成", + "deleted_count": deletedCount, + "failed_paper_ids": failedIDs, + }) +} + +// AddQuestionToPaper 添加题目到试卷 +func (h *PaperHandler) AddQuestionToPaper(c *fiber.Ctx) error { + var req struct { + PaperId string `json:"paper_id"` + QuestionIds []string `json:"question_ids"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.PaperId == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "试卷ID不能为空", + }) + } + + addedCount, failedIDs, err := h.paperService.AddQuestionToPaper(req.PaperId, req.QuestionIds) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "添加题目完成", + "added_count": addedCount, + "failed_question_ids": failedIDs, + }) +} + +// RemoveQuestionFromPaper 从试卷移除题目 +func (h *PaperHandler) RemoveQuestionFromPaper(c *fiber.Ctx) error { + var req struct { + PaperId string `json:"paper_id"` + QuestionIds []string `json:"question_ids"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.PaperId == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "试卷ID不能为空", + }) + } + + removedCount, failedIDs, err := h.paperService.RemoveQuestionFromPaper(req.PaperId, req.QuestionIds) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "移除题目完成", + "removed_count": removedCount, + "failed_question_ids": failedIDs, + }) +} + +// GetPaperWithAnswers 获取试卷及题目详情(包含答案和解析,用于答题结果页面) +func (h *PaperHandler) GetPaperWithAnswers(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "试卷ID不能为空", + }) + } + + paper, err := h.paperService.GetPaperWithAnswers(id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "paper": paper, + "data": paper, // 兼容前端可能使用的 data 字段 + }) +} diff --git a/internal/question/handler/question_handler.go b/internal/question/handler/question_handler.go new file mode 100644 index 0000000..e7c669e --- /dev/null +++ b/internal/question/handler/question_handler.go @@ -0,0 +1,245 @@ +package handler + +import ( + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/service" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// QuestionHandler 题目处理器 +type QuestionHandler struct { + questionService *service.QuestionService + knowledgeTreeService *service.KnowledgeTreeService +} + +// NewQuestionHandler 创建题目处理器 +func NewQuestionHandler(questionService *service.QuestionService, knowledgeTreeService *service.KnowledgeTreeService) *QuestionHandler { + return &QuestionHandler{ + questionService: questionService, + knowledgeTreeService: knowledgeTreeService, + } +} + +// CreateQuestion 创建题目 +func (h *QuestionHandler) CreateQuestion(c *fiber.Ctx) error { + var req service.CreateQuestionRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + question, err := h.questionService.CreateQuestion(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "创建成功", + "question": question, + }) +} + +// GetQuestion 获取题目 +func (h *QuestionHandler) GetQuestion(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "题目ID不能为空", + }) + } + + question, err := h.questionService.GetQuestion(id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "获取成功", + "question": question, + }) +} + +// SearchQuestions 搜索题目 +func (h *QuestionHandler) SearchQuestions(c *fiber.Ctx) error { + query := c.Query("query", "") + qTypeStr := c.Query("type", "0") + knowledgeTreeIdsStr := c.Query("knowledge_tree_ids", "") + pageStr := c.Query("page", "1") + pageSizeStr := c.Query("page_size", "10") + + qType, _ := strconv.ParseInt(qTypeStr, 10, 32) + page, _ := strconv.ParseInt(pageStr, 10, 32) + pageSize, _ := strconv.ParseInt(pageSizeStr, 10, 32) + + var knowledgeTreeIds []string + if knowledgeTreeIdsStr != "" { + // 逗号分割的知识树ID列表 + knowledgeTreeIds = strings.Split(knowledgeTreeIdsStr, ",") + } + + questions, total, err := h.questionService.SearchQuestions( + query, + question.QuestionType(qType), + knowledgeTreeIds, + int32(page), + int32(pageSize), + ) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + // 如果有 KnowledgeTreeService,则为每道题附加知识树名称,便于前端展示 + if h.knowledgeTreeService != nil { + // 收集所有题目中出现过的知识树ID(去重) + idSet := make(map[string]struct{}) + var allIDs []string + for _, q := range questions { + for _, id := range q.KnowledgeTreeIDs { + if id == "" { + continue + } + if _, exists := idSet[id]; !exists { + idSet[id] = struct{}{} + allIDs = append(allIDs, id) + } + } + } + + if len(allIDs) > 0 { + // 批量获取名称(不存在的知识树节点会跳过,不报错) + idToName, err := h.knowledgeTreeService.GetKnowledgeTreeNames(allIDs) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + // 为每道题填充 KnowledgeTreeNames + for _, q := range questions { + q.KnowledgeTreeNames = nil + for _, id := range q.KnowledgeTreeIDs { + if name, ok := idToName[id]; ok { + q.KnowledgeTreeNames = append(q.KnowledgeTreeNames, name) + } + } + } + } + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "搜索成功", + "questions": questions, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// UpdateQuestion 更新题目 +func (h *QuestionHandler) UpdateQuestion(c *fiber.Ctx) error { + var req service.UpdateQuestionRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if req.Id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "题目ID不能为空", + }) + } + + err := h.questionService.UpdateQuestion(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "更新成功", + }) +} + +// DeleteQuestion 删除题目 +func (h *QuestionHandler) DeleteQuestion(c *fiber.Ctx) error { + id := c.Query("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "题目ID不能为空", + }) + } + + err := h.questionService.DeleteQuestion(id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "删除成功", + }) +} + +// BatchDeleteQuestions 批量删除题目 +func (h *QuestionHandler) BatchDeleteQuestions(c *fiber.Ctx) error { + var req struct { + QuestionIds []string `json:"question_ids"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + if len(req.QuestionIds) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "题目ID列表不能为空", + }) + } + + deletedCount, failedIDs, err := h.questionService.BatchDeleteQuestions(req.QuestionIds) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "message": "批量删除完成", + "deleted_count": deletedCount, + "failed_question_ids": failedIDs, + }) +} diff --git a/internal/question/service/answer_record_service.go b/internal/question/service/answer_record_service.go new file mode 100644 index 0000000..9db7572 --- /dev/null +++ b/internal/question/service/answer_record_service.go @@ -0,0 +1,292 @@ +package service + +import ( + "fmt" + "log" + "strings" + + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/dao" +) + +// AnswerRecordService 答题记录服务 +type AnswerRecordService struct { + answerRecordDAO dao.AnswerRecordDAOInterface + questionDAO dao.QuestionDAOInterface +} + +// NewAnswerRecordService 创建答题记录服务 +func NewAnswerRecordService(answerRecordDAO dao.AnswerRecordDAOInterface, questionDAO dao.QuestionDAOInterface) *AnswerRecordService { + return &AnswerRecordService{ + answerRecordDAO: answerRecordDAO, + questionDAO: questionDAO, + } +} + +// CreateAnswerRecords 批量创建答题记录 +func (s *AnswerRecordService) CreateAnswerRecords(req *CreateAnswerRecordsRequest) (*CreateAnswerRecordsResponse, error) { + var results []AnswerResult + var totalQuestions int32 + var correctCount int32 + var wrongCount int32 + + for _, answer := range req.Answers { + existingRecord, err := s.answerRecordDAO.GetByUserQuestionAndPaper( + req.UserId, answer.QuestionId, req.PaperId, req.TaskId) + + var record *question.AnswerRecord + + if err != nil { + // 记录不存在,创建新记录 + recordID := question.GenerateID() + + questionObj, err := s.questionDAO.GetByID(answer.QuestionId) + if err != nil { + record = &question.AnswerRecord{ + ID: recordID, + UserID: req.UserId, + QuestionID: answer.QuestionId, + PaperID: req.PaperId, + TaskID: req.TaskId, + UserAnswer: answer.UserAnswer, + CorrectAnswer: "", + IsCorrect: false, + StartTime: req.StartTime, + EndTime: req.EndTime, + CreatedAt: question.GetCurrentTimestamp(), + UpdatedAt: question.GetCurrentTimestamp(), + } + } else { + correctAnswer := questionObj.Answer + isCorrect := compareAnswers(answer.UserAnswer, correctAnswer, questionObj.Type) + + record = &question.AnswerRecord{ + ID: recordID, + UserID: req.UserId, + QuestionID: answer.QuestionId, + PaperID: req.PaperId, + TaskID: req.TaskId, + UserAnswer: answer.UserAnswer, + CorrectAnswer: correctAnswer, + IsCorrect: isCorrect, + StartTime: req.StartTime, + EndTime: req.EndTime, + CreatedAt: question.GetCurrentTimestamp(), + UpdatedAt: question.GetCurrentTimestamp(), + } + } + + if err := s.answerRecordDAO.Create(record); err != nil { + log.Printf("创建答题记录失败: %v", err) + continue + } + } else { + // 记录已存在,更新 + questionObj, err := s.questionDAO.GetByID(answer.QuestionId) + if err != nil { + record = existingRecord + record.UserAnswer = answer.UserAnswer + record.CorrectAnswer = "" + record.IsCorrect = false + record.StartTime = req.StartTime + record.EndTime = req.EndTime + record.UpdatedAt = question.GetCurrentTimestamp() + } else { + correctAnswer := questionObj.Answer + isCorrect := compareAnswers(answer.UserAnswer, correctAnswer, questionObj.Type) + + record = existingRecord + record.UserAnswer = answer.UserAnswer + record.CorrectAnswer = correctAnswer + record.IsCorrect = isCorrect + record.StartTime = req.StartTime + record.EndTime = req.EndTime + record.UpdatedAt = question.GetCurrentTimestamp() + } + + if err := s.answerRecordDAO.Update(record); err != nil { + log.Printf("更新答题记录失败: %v", err) + continue + } + } + + totalQuestions++ + + result := AnswerResult{ + QuestionId: answer.QuestionId, + IsCorrect: record.IsCorrect, + CorrectAnswer: record.CorrectAnswer, + } + results = append(results, result) + + if record.IsCorrect { + correctCount++ + } else { + wrongCount++ + } + } + + return &CreateAnswerRecordsResponse{ + Results: results, + TotalQuestions: totalQuestions, + CorrectCount: correctCount, + WrongCount: wrongCount, + }, nil +} + +// compareAnswers 比较用户答案和正确答案 +func compareAnswers(userAnswer, correctAnswer string, questionType question.QuestionType) bool { + userAnswer = strings.TrimSpace(strings.ToLower(userAnswer)) + correctAnswer = strings.TrimSpace(strings.ToLower(correctAnswer)) + + switch questionType { + case question.QuestionTypeMultipleChoice: + return userAnswer == correctAnswer + case question.QuestionTypeTrueFalse: + userNormalized := normalizeTrueFalse(userAnswer) + correctNormalized := normalizeTrueFalse(correctAnswer) + return userNormalized == correctNormalized + case question.QuestionTypeFillBlank: + return userAnswer == correctAnswer + case question.QuestionTypeSubjective: + return userAnswer == correctAnswer + default: + return userAnswer == correctAnswer + } +} + +// normalizeTrueFalse 规范化判断题答案 +func normalizeTrueFalse(answer string) string { + answer = strings.TrimSpace(strings.ToLower(answer)) + switch answer { + case "true", "t", "1", "yes", "y", "正确", "对", "是": + return "true" + case "false", "f", "0", "no", "n", "错误", "错", "否": + return "false" + default: + return answer + } +} + +// GetAnswerRecord 获取答题记录(支持多种查询条件和分页,支持可选的 taskID 过滤) +func (s *AnswerRecordService) GetAnswerRecord(id, userID, questionID, paperID, taskID string, page, pageSize int32) ([]*question.AnswerRecord, int32, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + + if id != "" { + record, err := s.answerRecordDAO.GetByID(id) + if err != nil { + return nil, 0, fmt.Errorf("答题记录不存在: %v", err) + } + return []*question.AnswerRecord{record}, 1, nil + } + + records, total, err := s.answerRecordDAO.GetByConditionsWithPagination(userID, questionID, paperID, taskID, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("获取答题记录失败: %v", err) + } + + return records, total, nil +} + +// GetUserAnswerRecord 获取用户答题记录 +func (s *AnswerRecordService) GetUserAnswerRecord(userID, questionID string) (*question.AnswerRecord, error) { + record, err := s.answerRecordDAO.GetByUserAndQuestion(userID, questionID) + if err != nil { + return nil, fmt.Errorf("答题记录不存在: %v", err) + } + + return record, nil +} + +// GetPaperAnswerStatistics 获取试卷答题统计(支持可选的 taskID 过滤) +func (s *AnswerRecordService) GetPaperAnswerStatistics(userID, paperID, taskID string) (*question.PaperStatistics, error) { + stats, err := s.answerRecordDAO.GetPaperStatistics(userID, paperID, taskID) + if err != nil { + return nil, fmt.Errorf("获取统计失败: %v", err) + } + + return stats, nil +} + +// DeleteAnswerRecord 根据用户ID删除答题记录 +func (s *AnswerRecordService) DeleteAnswerRecord(userID string) (int64, error) { + if userID == "" { + return 0, fmt.Errorf("用户ID不能为空") + } + + deletedCount, err := s.answerRecordDAO.DeleteByUserID(userID) + if err != nil { + return 0, fmt.Errorf("删除答题记录失败: %v", err) + } + + return deletedCount, nil +} + +// DeleteAnswerRecordByUserAndPaper 根据用户ID和试卷ID删除答题记录(支持可选的 taskID 过滤) +func (s *AnswerRecordService) DeleteAnswerRecordByUserAndPaper(userID, paperID, taskID string) (int64, error) { + if userID == "" { + return 0, fmt.Errorf("用户ID不能为空") + } + if paperID == "" { + return 0, fmt.Errorf("试卷ID不能为空") + } + + deletedCount, err := s.answerRecordDAO.DeleteByUserAndPaper(userID, paperID, taskID) + if err != nil { + return 0, fmt.Errorf("删除答题记录失败: %v", err) + } + + return deletedCount, nil +} + +// CountByUserAndPaper 统计用户在某试卷下的答题记录条数(用于判断客观题是否“真完成”) +func (s *AnswerRecordService) CountByUserAndPaper(userID, paperID, taskID string) (int64, error) { + if userID == "" || paperID == "" { + return 0, nil + } + return s.answerRecordDAO.CountByUserAndPaper(userID, paperID, taskID) +} + +// CountByUserAndPaperWithNonEmptyAnswer 统计用户在某试卷下「有填写 user_answer」的答题记录条数(客观题只有有作答才算完成) +func (s *AnswerRecordService) CountByUserAndPaperWithNonEmptyAnswer(userID, paperID, taskID string) (int64, error) { + if userID == "" || paperID == "" { + return 0, nil + } + return s.answerRecordDAO.CountByUserAndPaperWithNonEmptyAnswer(userID, paperID, taskID) +} + +// CreateAnswerRecordsRequest 创建答题记录请求 +type CreateAnswerRecordsRequest struct { + UserId string `json:"user_id"` + PaperId string `json:"paper_id"` + TaskId string `json:"task_id"` // 打卡营任务ID,用于不同打卡营的答题隔离 + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + Answers []AnswerRequestItem `json:"answers"` +} + +// AnswerRequestItem 答案请求项 +type AnswerRequestItem struct { + QuestionId string `json:"question_id"` + UserAnswer string `json:"user_answer"` +} + +// CreateAnswerRecordsResponse 创建答题记录响应 +type CreateAnswerRecordsResponse struct { + Results []AnswerResult `json:"results"` + TotalQuestions int32 `json:"total_questions"` + CorrectCount int32 `json:"correct_count"` + WrongCount int32 `json:"wrong_count"` +} + +// AnswerResult 答案结果 +type AnswerResult struct { + QuestionId string `json:"question_id"` + IsCorrect bool `json:"is_correct"` + CorrectAnswer string `json:"correct_answer"` +} diff --git a/internal/question/service/knowledge_tree_service.go b/internal/question/service/knowledge_tree_service.go new file mode 100644 index 0000000..dcd0d60 --- /dev/null +++ b/internal/question/service/knowledge_tree_service.go @@ -0,0 +1,137 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/dao" +) + +// KnowledgeTreeService 知识树服务 +type KnowledgeTreeService struct { + knowledgeTreeDAO dao.KnowledgeTreeDAOInterface +} + +// NewKnowledgeTreeService 创建知识树服务 +func NewKnowledgeTreeService(knowledgeTreeDAO dao.KnowledgeTreeDAOInterface) *KnowledgeTreeService { + return &KnowledgeTreeService{ + knowledgeTreeDAO: knowledgeTreeDAO, + } +} + +// CreateKnowledgeTreeNode 创建知识树节点 +func (s *KnowledgeTreeService) CreateKnowledgeTreeNode(req *CreateKnowledgeTreeNodeRequest) (*question.KnowledgeTree, error) { + nodeID := question.GenerateID() + now := question.GetCurrentTimestamp() + + node := &question.KnowledgeTree{ + ID: nodeID, + Type: req.Type, + Title: req.Title, + ParentID: req.ParentID, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.knowledgeTreeDAO.Create(node); err != nil { + return nil, fmt.Errorf("创建知识树节点失败: %v", err) + } + + return node, nil +} + +// GetKnowledgeTreeNode 获取知识树节点 +func (s *KnowledgeTreeService) GetKnowledgeTreeNode(id string) (*question.KnowledgeTree, error) { + return s.knowledgeTreeDAO.GetByID(id) +} + +// GetKnowledgeTreeNames 获取知识树名称,返回 id -> 标题 的映射;不存在的节点跳过,不报错 +func (s *KnowledgeTreeService) GetKnowledgeTreeNames(ids []string) (map[string]string, error) { + idToName := make(map[string]string, len(ids)) + for _, id := range ids { + if id == "" { + continue + } + tree, err := s.knowledgeTreeDAO.GetByID(id) + if err != nil { + // 知识树节点不存在或已删除时跳过,不影响整体结果 + continue + } + idToName[id] = tree.Title + } + return idToName, nil +} + +// GetAllKnowledgeTreeNodes 获取所有知识树节点(扁平列表) +func (s *KnowledgeTreeService) GetAllKnowledgeTreeNodes(treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) { + return s.knowledgeTreeDAO.GetAll(treeType) +} + +// GetKnowledgeTreeByParentID 根据父节点ID获取子节点列表 +func (s *KnowledgeTreeService) GetKnowledgeTreeByParentID(parentID string, treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) { + return s.knowledgeTreeDAO.GetByParentID(parentID, treeType) +} + +// GetKnowledgeTree 获取完整树形结构 +func (s *KnowledgeTreeService) GetKnowledgeTree(treeType question.KnowledgeTreeType) ([]*question.KnowledgeTree, error) { + return s.knowledgeTreeDAO.GetTree(treeType) +} + +// UpdateKnowledgeTreeNode 更新知识树节点 +func (s *KnowledgeTreeService) UpdateKnowledgeTreeNode(req *UpdateKnowledgeTreeNodeRequest) error { + // 先获取现有节点 + existingNode, err := s.knowledgeTreeDAO.GetByID(req.ID) + if err != nil { + return fmt.Errorf("知识树节点不存在: %v", err) + } + + // 更新字段 + existingNode.Title = req.Title + existingNode.ParentID = req.ParentID + existingNode.UpdatedAt = question.GetCurrentTimestamp() + + if err := s.knowledgeTreeDAO.Update(existingNode); err != nil { + return fmt.Errorf("更新知识树节点失败: %v", err) + } + + return nil +} + +// DeleteKnowledgeTreeNode 删除知识树节点 +func (s *KnowledgeTreeService) DeleteKnowledgeTreeNode(id string) error { + // 先获取节点,获取类型 + existingNode, err := s.knowledgeTreeDAO.GetByID(id) + if err != nil { + return fmt.Errorf("知识树节点不存在: %v", err) + } + + // 检查是否有子节点 + children, err := s.knowledgeTreeDAO.GetByParentID(id, existingNode.Type) + if err != nil { + return fmt.Errorf("查询子节点失败: %v", err) + } + + if len(children) > 0 { + return fmt.Errorf("该节点存在子节点,无法删除") + } + + if err := s.knowledgeTreeDAO.Delete(id); err != nil { + return fmt.Errorf("删除知识树节点失败: %v", err) + } + + return nil +} + +// CreateKnowledgeTreeNodeRequest 创建知识树节点请求 +type CreateKnowledgeTreeNodeRequest struct { + Type question.KnowledgeTreeType `json:"type" binding:"required"` // 知识树类型:objective(客观题)或 subjective(主观题) + Title string `json:"title" binding:"required"` // 节点标题 + ParentID string `json:"parent_id"` // 父节点ID,根节点为空字符串 +} + +// UpdateKnowledgeTreeNodeRequest 更新知识树节点请求 +type UpdateKnowledgeTreeNodeRequest struct { + ID string `json:"id" binding:"required"` // 节点ID + Title string `json:"title" binding:"required"` // 节点标题 + ParentID string `json:"parent_id"` // 父节点ID +} diff --git a/internal/question/service/material_service.go b/internal/question/service/material_service.go new file mode 100644 index 0000000..513ed04 --- /dev/null +++ b/internal/question/service/material_service.go @@ -0,0 +1,120 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/dao" +) + +// MaterialService 材料服务 +type MaterialService struct { + materialDAO dao.MaterialDAOInterface +} + +// NewMaterialService 创建材料服务 +func NewMaterialService(materialDAO dao.MaterialDAOInterface) *MaterialService { + return &MaterialService{ + materialDAO: materialDAO, + } +} + +// CreateMaterialRequest 创建材料请求 +type CreateMaterialRequest struct { + Type string `json:"type"` // 材料类型:objective 或 subjective + Name string `json:"name"` + Content string `json:"content"` +} + +// UpdateMaterialRequest 更新材料请求 +type UpdateMaterialRequest struct { + ID string `json:"id"` + Type string `json:"type"` // 材料类型:objective 或 subjective + Name string `json:"name"` + Content string `json:"content"` +} + +// CreateMaterial 创建材料 +func (s *MaterialService) CreateMaterial(req *CreateMaterialRequest) (*question.Material, error) { + materialID := question.GenerateID() + + materialType := question.MaterialType(req.Type) + // 如果没有指定类型,默认为 objective(兼容旧数据) + if materialType == "" { + materialType = question.MaterialTypeObjective + } + + materialObj := &question.Material{ + ID: materialID, + Type: materialType, + Name: req.Name, + Content: req.Content, + CreatedAt: question.GetCurrentTimestamp(), + UpdatedAt: question.GetCurrentTimestamp(), + } + + err := s.materialDAO.Create(materialObj) + if err != nil { + return nil, fmt.Errorf("创建材料失败: %v", err) + } + + return materialObj, nil +} + +// GetMaterial 获取材料 +func (s *MaterialService) GetMaterial(id string) (*question.Material, error) { + material, err := s.materialDAO.GetByID(id) + if err != nil { + return nil, fmt.Errorf("材料不存在: %v", err) + } + + return material, nil +} + +// SearchMaterials 搜索材料 +func (s *MaterialService) SearchMaterials(query string, materialType question.MaterialType, page, pageSize int32) ([]*question.Material, int32, error) { + materials, total, err := s.materialDAO.Search(query, materialType, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("搜索材料失败: %v", err) + } + + return materials, total, nil +} + +// UpdateMaterial 更新材料 +func (s *MaterialService) UpdateMaterial(req *UpdateMaterialRequest) error { + materialType := question.MaterialType(req.Type) + // 如果没有指定类型,保持原有类型(需要先查询) + if materialType == "" { + existing, err := s.materialDAO.GetByID(req.ID) + if err != nil { + return fmt.Errorf("获取材料失败: %v", err) + } + materialType = existing.Type + } + + material := &question.Material{ + ID: req.ID, + Type: materialType, + Name: req.Name, + Content: req.Content, + UpdatedAt: question.GetCurrentTimestamp(), + } + + err := s.materialDAO.Update(material) + if err != nil { + return fmt.Errorf("更新材料失败: %v", err) + } + + return nil +} + +// DeleteMaterial 删除材料 +func (s *MaterialService) DeleteMaterial(id string) error { + err := s.materialDAO.Delete(id) + if err != nil { + return fmt.Errorf("删除材料失败: %v", err) + } + + return nil +} diff --git a/internal/question/service/paper_service.go b/internal/question/service/paper_service.go new file mode 100644 index 0000000..b248c6c --- /dev/null +++ b/internal/question/service/paper_service.go @@ -0,0 +1,228 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/dao" +) + +// PaperService 试卷服务 +type PaperService struct { + paperDAO dao.PaperDAOInterface + materialService *MaterialService +} + +// NewPaperService 创建试卷服务 +func NewPaperService(paperDAO dao.PaperDAOInterface, materialService *MaterialService) *PaperService { + return &PaperService{ + paperDAO: paperDAO, + materialService: materialService, + } +} + +// CreatePaper 创建试卷 +func (s *PaperService) CreatePaper(req *CreatePaperRequest) (*question.Paper, error) { + // 验证:题目数量不能为空 + if req.QuestionIds == nil || len(req.QuestionIds) == 0 { + return nil, fmt.Errorf("题目数量不能为空,请至少选择一个题目") + } + + paperID := question.GenerateID() + + // 确保 MaterialIds 不为 nil + materialIds := req.MaterialIds + if materialIds == nil { + materialIds = []string{} + } + + // 确保 QuestionIDs 不为 nil(已验证不为空,但确保不为 nil) + questionIds := req.QuestionIds + if questionIds == nil { + questionIds = []string{} + } + + paper := &question.Paper{ + ID: paperID, + Title: req.Title, + Description: req.Description, + Source: req.Source, + QuestionIDs: questionIds, + MaterialIDs: materialIds, + CreatedAt: question.GetCurrentTimestamp(), + UpdatedAt: question.GetCurrentTimestamp(), + } + + err := s.paperDAO.Create(paper) + if err != nil { + return nil, fmt.Errorf("创建试卷失败: %v", err) + } + + return paper, nil +} + +// GetPaper 获取试卷 +func (s *PaperService) GetPaper(id string) (*question.Paper, error) { + paper, err := s.paperDAO.GetWithQuestions(id) + if err != nil { + return nil, fmt.Errorf("试卷不存在: %v", err) + } + + // 收集材料 ID:试卷级 + 题目级(按题目中首次出现顺序去重),并拉取材料详情 + if s.materialService != nil { + seen := make(map[string]bool) + var orderedIDs []string + for _, mid := range paper.MaterialIDs { + if mid != "" && !seen[mid] { + seen[mid] = true + orderedIDs = append(orderedIDs, mid) + } + } + if paper.Questions != nil { + for _, q := range paper.Questions { + if q.MaterialID != "" && !seen[q.MaterialID] { + seen[q.MaterialID] = true + orderedIDs = append(orderedIDs, q.MaterialID) + } + } + } + for _, mid := range orderedIDs { + mat, err := s.materialService.GetMaterial(mid) + if err != nil { + continue + } + paper.Materials = append(paper.Materials, mat) + } + } + + return paper, nil +} + +// SearchPapers 搜索试卷 +func (s *PaperService) SearchPapers(query string, page, pageSize int32) ([]*question.Paper, int32, error) { + papers, total, err := s.paperDAO.Search(query, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("搜索试卷失败: %v", err) + } + + return papers, total, nil +} + +// UpdatePaper 更新试卷 +func (s *PaperService) UpdatePaper(req *UpdatePaperRequest) error { + // 验证:题目数量不能为空 + if req.QuestionIds == nil || len(req.QuestionIds) == 0 { + return fmt.Errorf("题目数量不能为空,请至少选择一个题目") + } + + paper, err := s.paperDAO.GetByID(req.Id) + if err != nil { + return fmt.Errorf("试卷不存在: %v", err) + } + + paper.Title = req.Title + paper.Description = req.Description + paper.Source = req.Source + // 确保 QuestionIDs 不为 nil(已验证不为空,但确保不为 nil) + questionIds := req.QuestionIds + if questionIds == nil { + questionIds = []string{} + } + paper.QuestionIDs = questionIds + // 确保 MaterialIds 不为 nil + materialIds := req.MaterialIds + if materialIds == nil { + materialIds = []string{} + } + paper.MaterialIDs = materialIds + paper.UpdatedAt = question.GetCurrentTimestamp() + + err = s.paperDAO.Update(paper) + if err != nil { + return fmt.Errorf("更新试卷失败: %v", err) + } + + return nil +} + +// DeletePaper 删除试卷 +func (s *PaperService) DeletePaper(id string) error { + err := s.paperDAO.Delete(id) + if err != nil { + return fmt.Errorf("删除试卷失败: %v", err) + } + + return nil +} + +// BatchDeletePapers 批量删除试卷 +func (s *PaperService) BatchDeletePapers(ids []string) (int, []string, error) { + deletedCount, failedIDs, err := s.paperDAO.BatchDelete(ids) + if err != nil { + return 0, nil, fmt.Errorf("批量删除失败: %v", err) + } + + return deletedCount, failedIDs, nil +} + +// AddQuestionToPaper 添加题目到试卷 +func (s *PaperService) AddQuestionToPaper(paperID string, questionIDs []string) (int, []string, error) { + addedCount, failedIDs, err := s.paperDAO.AddQuestions(paperID, questionIDs) + if err != nil { + return 0, nil, fmt.Errorf("添加题目失败: %v", err) + } + + return addedCount, failedIDs, nil +} + +// RemoveQuestionFromPaper 从试卷移除题目 +func (s *PaperService) RemoveQuestionFromPaper(paperID string, questionIDs []string) (int, []string, error) { + removedCount, failedIDs, err := s.paperDAO.RemoveQuestions(paperID, questionIDs) + if err != nil { + return 0, nil, fmt.Errorf("移除题目失败: %v", err) + } + + return removedCount, failedIDs, nil +} + +// GetPaperWithAnswers 获取试卷及题目详情(包含答案和解析,用于答题结果页面) +// 这个方法确保返回的题目包含答案和解析字段 +func (s *PaperService) GetPaperWithAnswers(id string) (*question.Paper, error) { + paper, err := s.GetPaper(id) + if err != nil { + return nil, err + } + + // 确保每个题目都有答案和解析字段(即使为空字符串) + if paper.Questions != nil { + for _, q := range paper.Questions { + if q.Answer == "" { + q.Answer = "" + } + if q.Explanation == "" { + q.Explanation = "" + } + } + } + + return paper, nil +} + +// CreatePaperRequest 创建试卷请求 +type CreatePaperRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Source string `json:"source,omitempty"` // 题目出处(可选) + QuestionIds []string `json:"question_ids"` // 题目ID列表 + MaterialIds []string `json:"material_ids,omitempty"` // 关联的材料ID列表(可选,用于主观题组卷) +} + +// UpdatePaperRequest 更新试卷请求 +type UpdatePaperRequest struct { + Id string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Source string `json:"source,omitempty"` // 题目出处(可选) + QuestionIds []string `json:"question_ids"` // 题目ID列表 + MaterialIds []string `json:"material_ids,omitempty"` // 关联的材料ID列表(可选,用于主观题组卷) +} diff --git a/internal/question/service/question_service.go b/internal/question/service/question_service.go new file mode 100644 index 0000000..9279bba --- /dev/null +++ b/internal/question/service/question_service.go @@ -0,0 +1,155 @@ +package service + +import ( + "fmt" + + "dd_fiber_api/internal/question" + "dd_fiber_api/internal/question/dao" +) + +// QuestionService 题目服务 +type QuestionService struct { + questionDAO dao.QuestionDAOInterface +} + +// NewQuestionService 创建题目服务 +func NewQuestionService(questionDAO dao.QuestionDAOInterface) *QuestionService { + return &QuestionService{ + questionDAO: questionDAO, + } +} + +// CreateQuestion 创建题目 +func (s *QuestionService) CreateQuestion(req *CreateQuestionRequest) (*question.Question, error) { + questionID := question.GenerateID() + + // 确保 KnowledgeTreeIds 不为 nil + knowledgeTreeIds := req.KnowledgeTreeIds + if knowledgeTreeIds == nil { + knowledgeTreeIds = []string{} + } + + questionObj := &question.Question{ + ID: questionID, + Type: question.QuestionType(req.Type), + Name: req.Name, + Source: req.Source, + MaterialID: req.MaterialID, + Content: req.Content, + Options: req.Options, + Answer: req.Answer, + Explanation: req.Explanation, + KnowledgeTreeIDs: knowledgeTreeIds, + CreatedAt: question.GetCurrentTimestamp(), + UpdatedAt: question.GetCurrentTimestamp(), + } + + err := s.questionDAO.Create(questionObj) + if err != nil { + return nil, fmt.Errorf("创建题目失败: %v", err) + } + + return questionObj, nil +} + +// GetQuestion 获取题目 +func (s *QuestionService) GetQuestion(id string) (*question.Question, error) { + questionObj, err := s.questionDAO.GetByID(id) + if err != nil { + return nil, fmt.Errorf("题目不存在: %v", err) + } + + return questionObj, nil +} + +// SearchQuestions 搜索题目 +func (s *QuestionService) SearchQuestions(query string, qType question.QuestionType, knowledgeTreeIds []string, page, pageSize int32) ([]*question.Question, int32, error) { + questions, total, err := s.questionDAO.Search(query, qType, knowledgeTreeIds, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("搜索题目失败: %v", err) + } + + return questions, total, nil +} + +// UpdateQuestion 更新题目 +func (s *QuestionService) UpdateQuestion(req *UpdateQuestionRequest) error { + // 获取现有题目 + questionObj, err := s.questionDAO.GetByID(req.Id) + if err != nil { + return fmt.Errorf("题目不存在: %v", err) + } + + // 确保 KnowledgeTreeIds 不为 nil + knowledgeTreeIds := req.KnowledgeTreeIds + if knowledgeTreeIds == nil { + knowledgeTreeIds = []string{} + } + + // 更新字段 + questionObj.Type = question.QuestionType(req.Type) + questionObj.Name = req.Name + questionObj.Source = req.Source + questionObj.MaterialID = req.MaterialID + questionObj.Content = req.Content + questionObj.Options = req.Options + questionObj.Answer = req.Answer + questionObj.Explanation = req.Explanation + questionObj.KnowledgeTreeIDs = knowledgeTreeIds + questionObj.UpdatedAt = question.GetCurrentTimestamp() + + // 保存 + err = s.questionDAO.Update(questionObj) + if err != nil { + return fmt.Errorf("更新题目失败: %v", err) + } + + return nil +} + +// DeleteQuestion 删除题目 +func (s *QuestionService) DeleteQuestion(id string) error { + err := s.questionDAO.Delete(id) + if err != nil { + return fmt.Errorf("删除题目失败: %v", err) + } + + return nil +} + +// BatchDeleteQuestions 批量删除题目 +func (s *QuestionService) BatchDeleteQuestions(ids []string) (int, []string, error) { + deletedCount, failedIDs, err := s.questionDAO.BatchDelete(ids) + if err != nil { + return 0, nil, fmt.Errorf("批量删除失败: %v", err) + } + + return deletedCount, failedIDs, nil +} + +// CreateQuestionRequest 创建题目请求 +type CreateQuestionRequest struct { + Type int32 `json:"type"` + Name string `json:"name,omitempty"` // 题目名称(可选) + Source string `json:"source,omitempty"` // 题目出处(可选) + MaterialID string `json:"material_id,omitempty"` // 关联材料ID(可选) + Content string `json:"content"` + Options []string `json:"options"` + Answer string `json:"answer"` + Explanation string `json:"explanation"` + KnowledgeTreeIds []string `json:"knowledge_tree_ids"` // 关联的知识树ID列表(替代原来的tags) +} + +// UpdateQuestionRequest 更新题目请求 +type UpdateQuestionRequest struct { + Id string `json:"id"` + Type int32 `json:"type"` + Name string `json:"name,omitempty"` // 题目名称(可选) + Source string `json:"source,omitempty"` // 题目出处(可选) + MaterialID string `json:"material_id,omitempty"` // 关联材料ID(可选) + Content string `json:"content"` + Options []string `json:"options"` + Answer string `json:"answer"` + Explanation string `json:"explanation"` + KnowledgeTreeIds []string `json:"knowledge_tree_ids"` // 关联的知识树ID列表(替代原来的tags) +} diff --git a/internal/question/types.go b/internal/question/types.go new file mode 100644 index 0000000..385bff2 --- /dev/null +++ b/internal/question/types.go @@ -0,0 +1,151 @@ +package question + +import ( + "time" + + "dd_fiber_api/pkg/snowflake" +) + +// QuestionType 题目类型 +type QuestionType int32 + +const ( + QuestionTypeUnspecified QuestionType = 0 + QuestionTypeSubjective QuestionType = 1 // 主观题 + QuestionTypeMultipleChoice QuestionType = 2 // 选择题 + QuestionTypeTrueFalse QuestionType = 3 // 判断题 + QuestionTypeFillBlank QuestionType = 4 // 填空题 +) + +// String 返回题目类型的字符串表示 +func (qt QuestionType) String() string { + switch qt { + case QuestionTypeSubjective: + return "主观题" + case QuestionTypeMultipleChoice: + return "选择题" + case QuestionTypeTrueFalse: + return "判断题" + case QuestionTypeFillBlank: + return "填空题" + default: + return "未知类型" + } +} + +// Question 题目结构 +type Question struct { + ID string `json:"id"` + Type QuestionType `json:"type"` + Name string `json:"name,omitempty"` // 题目名称(可选,用于搜索) + Source string `json:"source,omitempty"` // 题目出处(可选) + MaterialID string `json:"material_id,omitempty"` // 关联材料ID(可选,引用) + Content string `json:"content"` + Options []string `json:"options"` + Answer string `json:"answer"` + Explanation string `json:"explanation"` + KnowledgeTreeIDs []string `json:"knowledge_tree_ids"` // 关联的知识树ID列表(替代原来的tags) + KnowledgeTreeNames []string `json:"knowledge_tree_names,omitempty"` // 关联的知识树名称列表(便于前端展示) + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// Paper 试卷结构 +type Paper struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Source string `json:"source,omitempty"` // 题目出处(可选) + QuestionIDs []string `json:"question_ids"` // 题目ID列表 + MaterialIDs []string `json:"material_ids,omitempty"` // 关联的材料ID列表(可选,用于主观题组卷) + Questions []*QuestionInfo `json:"questions,omitempty"` // 题目详情列表(可选) + Materials []*Material `json:"materials,omitempty"` // 材料列表(客观题材料题用,按题目中首次出现顺序) + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// QuestionInfo 题目详细信息(用于试卷中的题目列表) +type QuestionInfo struct { + ID string `json:"id"` + Type QuestionType `json:"type"` + Content string `json:"content"` + Answer string `json:"answer"` + Explanation string `json:"explanation"` + Options []string `json:"options"` + KnowledgeTreeIDs []string `json:"knowledge_tree_ids,omitempty"` // 关联的知识树ID列表(替代原来的tags) + MaterialID string `json:"material_id,omitempty"` // 关联材料ID(客观题材料题用) +} + +// AnswerRecord 答题记录 +type AnswerRecord struct { + ID string `json:"id"` + UserID string `json:"user_id"` + QuestionID string `json:"question_id"` + PaperID string `json:"paper_id"` + TaskID string `json:"task_id,omitempty"` // 打卡营任务ID,用于不同打卡营的答题隔离 + UserAnswer string `json:"user_answer"` + CorrectAnswer string `json:"correct_answer"` + IsCorrect bool `json:"is_correct"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// PaperStatistics 试卷答题统计 +type PaperStatistics struct { + UserID string `json:"user_id"` + PaperID string `json:"paper_id"` + TotalQuestions int32 `json:"total_questions"` + AnsweredQuestions int32 `json:"answered_questions"` + CorrectAnswers int32 `json:"correct_answers"` + WrongAnswers int32 `json:"wrong_answers"` +} + +// MaterialType 材料类型 +type MaterialType string + +const ( + MaterialTypeObjective MaterialType = "objective" // 客观题材料 + MaterialTypeSubjective MaterialType = "subjective" // 主观题材料 +) + +// Material 材料结构 +type Material struct { + ID string `json:"id"` + Type MaterialType `json:"type"` // 材料类型:objective(客观题)或 subjective(主观题) + Name string `json:"name"` // 材料名称(用于搜索和显示) + Content string `json:"content"` // 材料内容(富文本) + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// KnowledgeTreeType 知识树类型 +type KnowledgeTreeType string + +const ( + KnowledgeTreeTypeObjective KnowledgeTreeType = "objective" // 客观题知识树 + KnowledgeTreeTypeSubjective KnowledgeTreeType = "subjective" // 主观题知识树 +) + +// KnowledgeTree 知识树节点结构 +type KnowledgeTree struct { + ID string `json:"id"` + Type KnowledgeTreeType `json:"type"` // 知识树类型:objective(客观题)或 subjective(主观题) + Title string `json:"title"` // 节点标题 + ParentID string `json:"parent_id"` // 父节点ID,根节点为空字符串 + Children []*KnowledgeTree `json:"children,omitempty"` // 子节点列表(可选,用于树形结构返回) + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + DeletedAt *int64 `json:"deleted_at,omitempty"` +} + +// GetCurrentTimestamp 获取当前时间戳 +func GetCurrentTimestamp() int64 { + return time.Now().Unix() +} + +// GenerateID 生成唯一ID(使用雪花算法) +func GenerateID() string { + return snowflake.GenerateID() +} diff --git a/internal/scheduler/handler.go b/internal/scheduler/handler.go new file mode 100644 index 0000000..bc0a64c --- /dev/null +++ b/internal/scheduler/handler.go @@ -0,0 +1,138 @@ +package scheduler + +import ( + "github.com/gofiber/fiber/v2" +) + +// Handler 调度器处理器 +type Handler struct { + service *Service +} + +// NewHandler 创建调度器处理器 +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + } +} + +// AddTask 添加任务 +func (h *Handler) AddTask(c *fiber.Ctx) error { + var req AddTaskRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "请求参数解析失败: " + err.Error(), + }) + } + + // 验证任务类型 + if req.TaskType != TaskTypeOnce && req.TaskType != TaskTypeCyclic { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "任务类型必须是 'once' 或 'cyclic'", + }) + } + + // 验证延迟时间 + if req.DelayMs < 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "延迟时间不能为负数", + }) + } + + // 循环任务必须提供间隔时间 + if req.TaskType == TaskTypeCyclic && req.IntervalMs <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "循环任务必须提供 interval_ms(大于0)", + }) + } + + resp, err := h.service.AddTask(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "添加任务失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusBadRequest).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// RemoveTask 删除任务 +func (h *Handler) RemoveTask(c *fiber.Ctx) error { + taskID := c.Params("task_id") + if taskID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "message": "task_id不能为空", + }) + } + + resp, err := h.service.RemoveTask(taskID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "message": "删除任务失败: " + err.Error(), + }) + } + + if !resp.Success { + return c.Status(fiber.StatusNotFound).JSON(resp) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetTaskStatus 查询任务状态 +func (h *Handler) GetTaskStatus(c *fiber.Ctx) error { + taskID := c.Params("task_id") + if taskID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "exists": false, + "message": "task_id不能为空", + }) + } + + resp, err := h.service.GetTaskStatus(taskID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "exists": false, + "message": "查询任务状态失败: " + err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(resp) +} + +// GetTaskCount 获取任务数量 +func (h *Handler) GetTaskCount(c *fiber.Ctx) error { + resp := h.service.GetTaskCount() + return c.Status(fiber.StatusOK).JSON(resp) +} + +// ListTasks 列出所有任务 +func (h *Handler) ListTasks(c *fiber.Ctx) error { + var req ListTasksRequest + if err := c.QueryParser(&req); err != nil { + req.Page = 1 + req.PageSize = 10 + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + + resp := h.service.ListTasks(req.Page, req.PageSize) + return c.Status(fiber.StatusOK).JSON(resp) +} + diff --git a/internal/scheduler/service.go b/internal/scheduler/service.go new file mode 100644 index 0000000..6540ad7 --- /dev/null +++ b/internal/scheduler/service.go @@ -0,0 +1,306 @@ +package scheduler + +import ( + "fmt" + "io" + "log" + "net/http" + "sync" + "time" + + "dd_fiber_api/pkg/snowflake" + "dd_fiber_api/pkg/timewheel" +) + +// Service 调度器服务 +type Service struct { + timeWheel *timewheel.TimeWheel + taskInfos map[string]*TaskInfo // 存储任务信息(内存) + mu sync.RWMutex +} + +// NewService 创建调度器服务 +func NewService(tickInterval time.Duration, slotNum int) *Service { + service := &Service{ + timeWheel: timewheel.New(tickInterval, slotNum), + taskInfos: make(map[string]*TaskInfo), + } + service.timeWheel.Start() + return service +} + +// Stop 停止服务 +func (s *Service) Stop() { + s.timeWheel.Stop() +} + +// AddTask 添加任务 +func (s *Service) AddTask(req *AddTaskRequest) (*AddTaskResponse, error) { + // 自动生成 task_id(如果未提供) + taskID := req.TaskID + if taskID == "" { + taskID = snowflake.GenerateID() + } + + s.mu.Lock() + // 检查任务是否已存在 + if _, exists := s.taskInfos[taskID]; exists { + s.mu.Unlock() + return &AddTaskResponse{ + Success: false, + Message: "任务ID已存在", + TaskID: taskID, + BusinessKey: req.BusinessKey, + }, nil + } + s.mu.Unlock() + + // 创建任务回调函数 + callback := func() { + s.executeTask(req, taskID) + } + + var err error + switch req.TaskType { + case TaskTypeOnce: + // 一次性任务 + delay := time.Duration(req.DelayMs) * time.Millisecond + err = s.timeWheel.AddTask(taskID, delay, callback) + case TaskTypeCyclic: + // 循环任务 + interval := time.Duration(req.IntervalMs) * time.Millisecond + err = s.timeWheel.AddCyclicTask(taskID, interval, callback) + default: + return &AddTaskResponse{ + Success: false, + Message: "不支持的任务类型", + }, nil + } + + if err != nil { + return &AddTaskResponse{ + Success: false, + Message: fmt.Sprintf("添加任务失败: %v", err), + }, nil + } + + // 保存任务信息到内存 + s.mu.Lock() + s.taskInfos[taskID] = &TaskInfo{ + TaskID: taskID, + TaskType: req.TaskType, + Status: TaskStatusPending, + DelayMs: req.DelayMs, + IntervalMs: req.IntervalMs, + Metadata: req.Metadata, + } + s.mu.Unlock() + + return &AddTaskResponse{ + Success: true, + Message: "任务添加成功", + TaskID: taskID, + BusinessKey: req.BusinessKey, + }, nil +} + +// RemoveTask 删除任务 +func (s *Service) RemoveTask(taskID string) (*RemoveTaskResponse, error) { + if taskID == "" { + return &RemoveTaskResponse{ + Success: false, + Message: "task_id不能为空", + }, nil + } + + s.mu.Lock() + _, exists := s.taskInfos[taskID] + if exists { + delete(s.taskInfos, taskID) + } + s.mu.Unlock() + + if !exists { + return &RemoveTaskResponse{ + Success: false, + Message: "任务不存在", + }, nil + } + + // 从时间轮中删除 + s.timeWheel.RemoveTask(taskID) + + return &RemoveTaskResponse{ + Success: true, + Message: "任务删除成功", + }, nil +} + +// GetTaskStatus 查询任务状态 +func (s *Service) GetTaskStatus(taskID string) (*GetTaskStatusResponse, error) { + if taskID == "" { + return &GetTaskStatusResponse{ + Exists: false, + Message: "task_id不能为空", + }, nil + } + + status, exists := s.timeWheel.GetTaskStatus(taskID) + if !exists { + return &GetTaskStatusResponse{ + Exists: false, + Status: TaskStatusCompleted, + Message: "任务不存在或已完成", + }, nil + } + + // 转换状态 + var taskStatus TaskStatus + switch status { + case timewheel.TaskStatusPending: + taskStatus = TaskStatusPending + case timewheel.TaskStatusRunning: + taskStatus = TaskStatusRunning + case timewheel.TaskStatusCompleted: + taskStatus = TaskStatusCompleted + case timewheel.TaskStatusCancelled: + taskStatus = TaskStatusCancelled + default: + taskStatus = TaskStatusPending + } + + return &GetTaskStatusResponse{ + Exists: true, + Status: taskStatus, + Message: "查询成功", + }, nil +} + +// GetTaskCount 获取任务数量 +func (s *Service) GetTaskCount() *GetTaskCountResponse { + count := s.timeWheel.TaskCount() + return &GetTaskCountResponse{ + Count: count, + } +} + +// ListTasks 列出所有任务 +func (s *Service) ListTasks(page, pageSize int) *ListTasksResponse { + s.mu.RLock() + defer s.mu.RUnlock() + + // 收集所有任务 + allTasks := make([]TaskInfo, 0, len(s.taskInfos)) + for _, taskInfo := range s.taskInfos { + // 更新状态 + status, exists := s.timeWheel.GetTaskStatus(taskInfo.TaskID) + if exists { + switch status { + case timewheel.TaskStatusPending: + taskInfo.Status = TaskStatusPending + case timewheel.TaskStatusRunning: + taskInfo.Status = TaskStatusRunning + case timewheel.TaskStatusCompleted: + taskInfo.Status = TaskStatusCompleted + case timewheel.TaskStatusCancelled: + taskInfo.Status = TaskStatusCancelled + } + } + allTasks = append(allTasks, *taskInfo) + } + + total := len(allTasks) + + // 分页 + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + start := (page - 1) * pageSize + end := start + pageSize + + if start >= total { + return &ListTasksResponse{ + Tasks: []TaskInfo{}, + Total: total, + Page: page, + PageSize: pageSize, + } + } + + if end > total { + end = total + } + + tasks := allTasks[start:end] + + return &ListTasksResponse{ + Tasks: tasks, + Total: total, + Page: page, + PageSize: pageSize, + } +} + +// executeTask 执行任务 +func (s *Service) executeTask(req *AddTaskRequest, taskID string) { + startTime := time.Now() + + // 如果有回调URL,发送HTTP请求 + if req.CallbackURL != "" { + s.sendHttpCallback(req, taskID, startTime) + } else { + log.Printf("✅ 任务执行完成(无回调URL): %s", taskID) + } + + // 更新任务状态 + s.mu.Lock() + if taskInfo, exists := s.taskInfos[taskID]; exists { + // 如果是一次性任务,执行后删除 + if req.TaskType == TaskTypeOnce { + taskInfo.Status = TaskStatusCompleted + delete(s.taskInfos, taskID) + } else { + // 循环任务,保持运行状态 + taskInfo.Status = TaskStatusRunning + } + } + s.mu.Unlock() +} + +// sendHttpCallback 发送HTTP回调请求 +func (s *Service) sendHttpCallback(req *AddTaskRequest, taskID string, startTime time.Time) { + log.Printf("🔔 开始HTTP回调: %s", req.CallbackURL) + + // 创建HTTP客户端(设置超时) + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // 发送GET请求 + resp, err := client.Get(req.CallbackURL) + if err != nil { + log.Printf("❌ HTTP回调失败: %s, 错误: %v", req.CallbackURL, err) + return + } + defer resp.Body.Close() + + // 读取响应内容 + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("❌ 读取响应失败: %v", err) + return + } + + // 打印响应结果 + log.Printf("✅ HTTP回调成功!") + log.Printf(" URL: %s", req.CallbackURL) + log.Printf(" 状态码: %d", resp.StatusCode) + log.Printf(" 响应数据: %s", string(body)) +} diff --git a/internal/scheduler/types.go b/internal/scheduler/types.go new file mode 100644 index 0000000..66ff5d7 --- /dev/null +++ b/internal/scheduler/types.go @@ -0,0 +1,85 @@ +package scheduler + +// TaskType 任务类型 +type TaskType string + +const ( + TaskTypeOnce TaskType = "once" // 一次性任务 + TaskTypeCyclic TaskType = "cyclic" // 循环任务 +) + +// TaskStatus 任务状态 +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" // 等待中 + TaskStatusRunning TaskStatus = "running" // 运行中 + TaskStatusCompleted TaskStatus = "completed" // 已完成 + TaskStatusCancelled TaskStatus = "cancelled" // 已取消 +) + +// AddTaskRequest 添加任务请求 +type AddTaskRequest struct { + TaskID string `json:"task_id,omitempty"` // 任务ID(可选,不传则自动生成) + BusinessKey string `json:"business_key,omitempty"` // 业务键 + TaskType TaskType `json:"task_type"` // 任务类型 + DelayMs int64 `json:"delay_ms"` // 延迟时间(毫秒) + IntervalMs int64 `json:"interval_ms,omitempty"` // 循环间隔(毫秒,仅循环任务使用) + CallbackURL string `json:"callback_url,omitempty"` // 回调URL + Metadata map[string]string `json:"metadata,omitempty"` // 任务元数据 +} + +// AddTaskResponse 添加任务响应 +type AddTaskResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + TaskID string `json:"task_id"` + BusinessKey string `json:"business_key,omitempty"` +} + +// RemoveTaskRequest 删除任务请求 +type RemoveTaskRequest struct { + TaskID string `json:"task_id"` +} + +// RemoveTaskResponse 删除任务响应 +type RemoveTaskResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetTaskStatusResponse 查询任务状态响应 +type GetTaskStatusResponse struct { + Exists bool `json:"exists"` + Status TaskStatus `json:"status"` + Message string `json:"message"` +} + +// GetTaskCountResponse 获取任务数量响应 +type GetTaskCountResponse struct { + Count int `json:"count"` +} + +// TaskInfo 任务信息 +type TaskInfo struct { + TaskID string `json:"task_id"` + TaskType TaskType `json:"task_type"` + Status TaskStatus `json:"status"` + DelayMs int64 `json:"delay_ms"` + IntervalMs int64 `json:"interval_ms,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ListTasksRequest 列出任务请求 +type ListTasksRequest struct { + Page int `json:"page" query:"page"` // 页码(从1开始) + PageSize int `json:"page_size" query:"page_size"` // 每页数量 +} + +// ListTasksResponse 列出任务响应 +type ListTasksResponse struct { + Tasks []TaskInfo `json:"tasks"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/internal/wire/providers.go b/internal/wire/providers.go new file mode 100644 index 0000000..16daf06 --- /dev/null +++ b/internal/wire/providers.go @@ -0,0 +1,891 @@ +package wire + +import ( + "fmt" + "log" + "time" + + "dd_fiber_api/config" + "dd_fiber_api/internal/admin" + "dd_fiber_api/internal/admin/statistics" + admin_auth_dao "dd_fiber_api/internal/admin_auth/dao" + admin_auth_handler "dd_fiber_api/internal/admin_auth/handler" + admin_auth_service "dd_fiber_api/internal/admin_auth/service" + "dd_fiber_api/internal/api" + "dd_fiber_api/internal/camp/dao" + "dd_fiber_api/internal/camp/handler" + "dd_fiber_api/internal/camp/service" + document_dao "dd_fiber_api/internal/document/dao" + document_handler "dd_fiber_api/internal/document/handler" + document_service "dd_fiber_api/internal/document/service" + order_dao "dd_fiber_api/internal/order/dao" + order_handler "dd_fiber_api/internal/order/handler" + order_service "dd_fiber_api/internal/order/service" + "dd_fiber_api/internal/oss" + "dd_fiber_api/internal/payment" + question_dao "dd_fiber_api/internal/question/dao" + question_handler "dd_fiber_api/internal/question/handler" + question_service "dd_fiber_api/internal/question/service" + "dd_fiber_api/internal/scheduler" + "dd_fiber_api/pkg/database" + "dd_fiber_api/pkg/snowflake" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/recover" +) + +// App 应用结构 +type App struct { + AdminApp *AdminApp + APIApp *APIApp + Config *config.Config + MySQLClient *database.MySQLClient + MongoDBClient *database.MongoDBClient + RedisClient *database.RedisClient +} + +// NewApp 创建应用 +func NewApp( + adminApp *AdminApp, + apiApp *APIApp, + cfg *config.Config, + mysqlClient *database.MySQLClient, + mongodbClient *database.MongoDBClient, + redisClient *database.RedisClient, +) *App { + return &App{ + AdminApp: adminApp, + APIApp: apiApp, + Config: cfg, + MySQLClient: mysqlClient, + MongoDBClient: mongodbClient, + RedisClient: redisClient, + } +} + +// Close 关闭应用资源 +func (a *App) Close() { + if a.MySQLClient != nil { + a.MySQLClient.Close() + } + if a.MongoDBClient != nil { + a.MongoDBClient.Close() + } + if a.RedisClient != nil { + a.RedisClient.Close() + } +} + +// NewMySQLClient 创建 MySQL 客户端 +func NewMySQLClient(cfg *config.Config) (*database.MySQLClient, error) { + if cfg.MySQL.Database == "" { + return nil, nil + } + client, err := database.NewMySQLClient(&cfg.MySQL) + if err != nil { + return nil, err + } + log.Println(" ✅ MySQL 连接成功") + return client, nil +} + +// NewRedisClient 创建 Redis 客户端 +func NewRedisClient(cfg *config.Config) (*database.RedisClient, error) { + if cfg.Redis.Host == "" { + return nil, nil + } + client, err := database.NewRedisClient(&cfg.Redis) + if err != nil { + log.Printf(" ⚠️ Redis 连接失败,将不使用缓存: %v", err) + return nil, nil // 返回 nil 而不是错误,允许应用继续运行 + } + log.Println(" ✅ Redis 连接成功") + return client, nil +} + +// NewMongoDBClient 创建 MongoDB 客户端 +func NewMongoDBClient(cfg *config.Config) (*database.MongoDBClient, error) { + log.Println(" 🔄 正在连接 MongoDB...") + if cfg.MongoDB.URI == "" { + log.Println(" ⚠️ MongoDB URI 未配置,跳过连接") + return nil, nil + } + log.Printf(" 📍 MongoDB URI: %s", maskURI(cfg.MongoDB.URI)) + log.Printf(" 📍 MongoDB Database: %s", cfg.MongoDB.Database) + client, err := database.NewMongoDBClient(&cfg.MongoDB) + if err != nil { + log.Printf(" ❌ MongoDB 连接失败: %v", err) + return nil, err + } + log.Printf(" ✅ MongoDB 连接成功 (数据库: %s)", cfg.MongoDB.Database) + return client, nil +} + +// NewOSSService 创建 OSS 服务 +func NewOSSService(cfg *config.Config, redisClient *database.RedisClient) *oss.Service { + if cfg.OSS.AccessKeyID == "" { + return nil + } + service := oss.NewService(&cfg.OSS, redisClient) + log.Println(" ✅ OSS 服务初始化成功") + return service +} + +// NewOSSHandler 创建 OSS 处理器 +func NewOSSHandler(ossService *oss.Service) *oss.Handler { + if ossService == nil { + return nil + } + return oss.NewHandler(ossService) +} + +// NewWechatPayV3Service 创建微信支付 V3 服务 +func NewWechatPayV3Service(cfg *config.Config) (*payment.WechatPayV3Service, error) { + if cfg.Wechat.APIKeyV3 == "" || (cfg.Wechat.PrivateKey == "" && cfg.Wechat.PrivateKeyPath == "") { + return nil, nil + } + service, err := payment.NewWechatPayV3Service(&cfg.Wechat) + if err != nil { + log.Printf(" ⚠️ 微信支付 V3 服务初始化失败: %v", err) + return nil, nil + } + log.Println(" ✅ 微信支付 V3 服务初始化成功") + return service, nil +} + +// NewPaymentHandler 创建支付处理器 +func NewPaymentHandler( + wechatPayV3Service *payment.WechatPayV3Service, + orderDAO *order_dao.OrderDAO, + accessDAO *dao.UserSectionAccessDAO, + userCampDAO *dao.UserCampDAO, +) *payment.Handler { + if wechatPayV3Service == nil { + return nil + } + paymentHandler := payment.NewHandler(wechatPayV3Service) + if orderDAO != nil { + paymentHandler.SetOrderDAO(orderDAO) + } + if accessDAO != nil { + paymentHandler.SetAccessDAO(accessDAO) + } + if userCampDAO != nil { + paymentHandler.SetUserCampDAO(userCampDAO) + } + return paymentHandler +} + +// NewSchedulerService 创建调度器服务 +func NewSchedulerService(cfg *config.Config) *scheduler.Service { + if cfg.Scheduler.SlotNum <= 0 { + return nil + } + + // 初始化雪花算法ID生成器 + if err := snowflake.InitDefault(1, 1); err != nil { + log.Printf("⚠️ 雪花算法ID生成器初始化失败: %v", err) + return nil + } + + // 解析时间轮配置 + tickInterval, err := time.ParseDuration(cfg.Scheduler.TickInterval) + if err != nil { + log.Printf("⚠️ 时间轮配置解析失败,使用默认值: %v", err) + tickInterval = 100 * time.Millisecond + } + + slotNum := cfg.Scheduler.SlotNum + if slotNum <= 0 { + slotNum = 3600 + } + + service := scheduler.NewService(tickInterval, slotNum) + log.Printf(" ✅ 调度器服务初始化成功 (tick=%v, slots=%d)", tickInterval, slotNum) + return service +} + +// NewSchedulerHandler 创建调度器处理器 +func NewSchedulerHandler(schedulerService *scheduler.Service) *scheduler.Handler { + if schedulerService == nil { + return nil + } + return scheduler.NewHandler(schedulerService) +} + +// NewCategoryDAO 创建分类 DAO +func NewCategoryDAO(mysqlClient *database.MySQLClient) *dao.CategoryDAO { + if mysqlClient == nil { + return nil + } + return dao.NewCategoryDAO(mysqlClient) +} + +// NewCategoryService 创建分类服务 +func NewCategoryService(categoryDAO *dao.CategoryDAO, campDAO *dao.CampDAO) *service.CategoryService { + if categoryDAO == nil { + return nil + } + return service.NewCategoryService(categoryDAO, campDAO) +} + +// NewCategoryHandler 创建分类处理器 +func NewCategoryHandler(categoryService *service.CategoryService) *handler.CategoryHandler { + if categoryService == nil { + return nil + } + return handler.NewCategoryHandler(categoryService) +} + +// NewCampDAO 创建打卡营 DAO +func NewCampDAO(mysqlClient *database.MySQLClient) *dao.CampDAO { + if mysqlClient == nil { + return nil + } + return dao.NewCampDAO(mysqlClient) +} + +// NewCampService 创建打卡营服务 +func NewCampService( + campDAO *dao.CampDAO, + sectionDAO *dao.SectionDAO, + taskDAO *dao.TaskDAO, + progressDAO *dao.ProgressDAO, + userCampDAO *dao.UserCampDAO, + orderDAO *order_dao.OrderDAO, + answerRecordService *question_service.AnswerRecordService, + paperService *question_service.PaperService, + orderService *order_service.OrderService, +) *service.CampService { + if campDAO == nil { + return nil + } + campService := service.NewCampService(campDAO) + if sectionDAO != nil && taskDAO != nil && progressDAO != nil && userCampDAO != nil && orderDAO != nil { + campService.SetDependencies(sectionDAO, taskDAO, progressDAO, userCampDAO, orderDAO) + } + if answerRecordService != nil { + campService.SetAnswerRecordService(answerRecordService) + } + if paperService != nil { + campService.SetPaperService(paperService) + } + if orderService != nil { + campService.SetOrderService(orderService) + } + return campService +} + +// NewCampHandler 创建打卡营处理器(需传入 userCampService 以便列表接口返回 is_joined) +func NewCampHandler(campService *service.CampService, userCampService *service.UserCampService) *handler.CampHandler { + if campService == nil { + return nil + } + h := handler.NewCampHandler(campService, userCampService) + log.Println(" ✅ Camp 服务初始化成功(分类、打卡营、小节、任务、进度、用户打卡营、订单)") + return h +} + +// NewSectionDAO 创建小节 DAO +func NewSectionDAO(mysqlClient *database.MySQLClient) *dao.SectionDAO { + if mysqlClient == nil { + return nil + } + return dao.NewSectionDAO(mysqlClient) +} + +// NewSectionService 创建小节服务 +func NewSectionService(sectionDAO *dao.SectionDAO, campDAO *dao.CampDAO) *service.SectionService { + if sectionDAO == nil || campDAO == nil { + return nil + } + return service.NewSectionService(sectionDAO, campDAO) +} + +// NewSectionHandler 创建小节处理器 +func NewSectionHandler( + sectionService *service.SectionService, + orderService *order_service.OrderService, + orderDAO *order_dao.OrderDAO, + userCampDAO *dao.UserCampDAO, +) *handler.SectionHandler { + if sectionService == nil { + return nil + } + sectionHandler := handler.NewSectionHandler(sectionService) + if orderService != nil { + sectionHandler.SetOrderService(orderService) + } + if orderDAO != nil { + sectionHandler.SetOrderDAO(orderDAO) + } + if userCampDAO != nil { + sectionHandler.SetUserCampDAO(userCampDAO) + } + return sectionHandler +} + +// NewTaskDAO 创建任务 DAO +func NewTaskDAO(mysqlClient *database.MySQLClient) *dao.TaskDAO { + if mysqlClient == nil { + return nil + } + return dao.NewTaskDAO(mysqlClient) +} + +// NewTaskService 创建任务服务 +func NewTaskService(taskDAO *dao.TaskDAO) *service.TaskService { + if taskDAO == nil { + return nil + } + return service.NewTaskService(taskDAO) +} + +// NewTaskHandler 创建任务处理器 +func NewTaskHandler(taskService *service.TaskService) *handler.TaskHandler { + if taskService == nil { + return nil + } + return handler.NewTaskHandler(taskService) +} + +// NewProgressDAO 创建进度 DAO +func NewProgressDAO(mysqlClient *database.MySQLClient) *dao.ProgressDAO { + if mysqlClient == nil { + return nil + } + return dao.NewProgressDAO(mysqlClient) +} + +// NewProgressService 创建进度服务 +func NewProgressService(progressDAO *dao.ProgressDAO, taskDAO *dao.TaskDAO, userCampDAO *dao.UserCampDAO, answerRecordService *question_service.AnswerRecordService, campService *service.CampService) *service.ProgressService { + if progressDAO == nil || taskDAO == nil { + return nil + } + return service.NewProgressService(progressDAO, taskDAO, userCampDAO, answerRecordService, campService) +} + +// NewProgressHandler 创建进度处理器 +func NewProgressHandler(progressService *service.ProgressService) *handler.ProgressHandler { + if progressService == nil { + return nil + } + return handler.NewProgressHandler(progressService) +} + +// NewUserCampDAO 创建用户打卡营 DAO +func NewUserCampDAO(mysqlClient *database.MySQLClient) *dao.UserCampDAO { + if mysqlClient == nil { + return nil + } + return dao.NewUserCampDAO(mysqlClient) +} + +// NewResetHistoryDAO 创建打卡营重置历史 DAO +func NewResetHistoryDAO(mysqlClient *database.MySQLClient) *dao.ResetHistoryDAO { + if mysqlClient == nil { + return nil + } + return dao.NewResetHistoryDAO(mysqlClient) +} + +// NewUserCampService 创建用户打卡营服务 +func NewUserCampService( + userCampDAO *dao.UserCampDAO, + sectionDAO *dao.SectionDAO, + progressDAO *dao.ProgressDAO, + taskDAO *dao.TaskDAO, + resetHistoryDAO *dao.ResetHistoryDAO, + answerRecordDAO question_dao.AnswerRecordDAOInterface, +) *service.UserCampService { + if userCampDAO == nil || sectionDAO == nil || progressDAO == nil { + return nil + } + return service.NewUserCampService(userCampDAO, sectionDAO, progressDAO, taskDAO, resetHistoryDAO, answerRecordDAO) +} + +// NewUserCampHandler 创建用户打卡营处理器 +func NewUserCampHandler(userCampService *service.UserCampService) *handler.UserCampHandler { + if userCampService == nil { + return nil + } + return handler.NewUserCampHandler(userCampService) +} + +// NewUserSectionAccessDAO 创建用户小节访问记录 DAO +func NewUserSectionAccessDAO(mysqlClient *database.MySQLClient) *dao.UserSectionAccessDAO { + if mysqlClient == nil { + return nil + } + return dao.NewUserSectionAccessDAO(mysqlClient) +} + +// NewOrderDAO 创建订单 DAO +func NewOrderDAO(mysqlClient *database.MySQLClient) *order_dao.OrderDAO { + if mysqlClient == nil { + return nil + } + return order_dao.NewOrderDAO(mysqlClient) +} + +// NewOrderService 创建订单服务 +func NewOrderService( + orderDAO *order_dao.OrderDAO, + sectionDAO *dao.SectionDAO, + wechatPayV3Service *payment.WechatPayV3Service, + accessDAO *dao.UserSectionAccessDAO, + userCampDAO *dao.UserCampDAO, + schedulerService *scheduler.Service, + cfg *config.Config, +) *order_service.OrderService { + if orderDAO == nil || sectionDAO == nil { + return nil + } + orderService := order_service.NewOrderService(orderDAO, sectionDAO) + if wechatPayV3Service != nil { + orderService.SetPaymentService(wechatPayV3Service) + } + if accessDAO != nil { + orderService.SetAccessDAO(accessDAO) + } + if userCampDAO != nil { + orderService.SetUserCampDAO(userCampDAO) + } + if schedulerService != nil { + orderService.SetSchedulerService(schedulerService) + } + // 构建 API base URL(用于定时任务回调) + if cfg != nil { + apiBaseURL := fmt.Sprintf("http://%s:%d", cfg.Service.Host, cfg.Service.APIPort) + orderService.SetAPIBaseURL(apiBaseURL) + } + return orderService +} + +// NewOrderHandler 创建订单处理器 +func NewOrderHandler(orderService *order_service.OrderService) *order_handler.OrderHandler { + if orderService == nil { + return nil + } + return order_handler.NewOrderHandler(orderService) +} + +// NewQuestionDAO 创建题目 DAO(MongoDB) +func NewQuestionDAO(mongodbClient *database.MongoDBClient) question_dao.QuestionDAOInterface { + if mongodbClient == nil { + return nil + } + dao := question_dao.NewQuestionDAOMongo(mongodbClient) + // 创建索引 + if err := dao.CreateIndexes(); err != nil { + log.Printf(" ⚠️ 创建题目索引失败: %v", err) + } + return dao +} + +// NewQuestionService 创建题目服务 +func NewQuestionService(questionDAO question_dao.QuestionDAOInterface) *question_service.QuestionService { + if questionDAO == nil { + return nil + } + return question_service.NewQuestionService(questionDAO) +} + +// NewQuestionHandler 创建题目处理器 +func NewQuestionHandler( + questionService *question_service.QuestionService, + knowledgeTreeService *question_service.KnowledgeTreeService, +) *question_handler.QuestionHandler { + if questionService == nil { + return nil + } + return question_handler.NewQuestionHandler(questionService, knowledgeTreeService) +} + +// NewPaperDAO 创建试卷 DAO(MongoDB) +func NewPaperDAO(mongodbClient *database.MongoDBClient, questionDAO question_dao.QuestionDAOInterface) question_dao.PaperDAOInterface { + if mongodbClient == nil || questionDAO == nil { + return nil + } + dao := question_dao.NewPaperDAOMongo(mongodbClient, questionDAO) + // 创建索引 + if err := dao.CreateIndexes(); err != nil { + log.Printf(" ⚠️ 创建试卷索引失败: %v", err) + } + return dao +} + +// NewPaperService 创建试卷服务 +func NewPaperService(paperDAO question_dao.PaperDAOInterface, materialService *question_service.MaterialService) *question_service.PaperService { + if paperDAO == nil { + return nil + } + return question_service.NewPaperService(paperDAO, materialService) +} + +// NewPaperHandler 创建试卷处理器 +func NewPaperHandler(paperService *question_service.PaperService) *question_handler.PaperHandler { + if paperService == nil { + return nil + } + return question_handler.NewPaperHandler(paperService) +} + +// NewAnswerRecordDAO 创建答题记录 DAO(MongoDB) +func NewAnswerRecordDAO(mongodbClient *database.MongoDBClient) question_dao.AnswerRecordDAOInterface { + if mongodbClient == nil { + return nil + } + dao := question_dao.NewAnswerRecordDAOMongo(mongodbClient) + // 创建索引 + if err := dao.CreateIndexes(); err != nil { + log.Printf(" ⚠️ 创建答题记录索引失败: %v", err) + } + return dao +} + +// NewAnswerRecordService 创建答题记录服务 +func NewAnswerRecordService(answerRecordDAO question_dao.AnswerRecordDAOInterface, questionDAO question_dao.QuestionDAOInterface) *question_service.AnswerRecordService { + if answerRecordDAO == nil || questionDAO == nil { + return nil + } + return question_service.NewAnswerRecordService(answerRecordDAO, questionDAO) +} + +// NewAnswerRecordHandler 创建答题记录处理器 +func NewAnswerRecordHandler(answerRecordService *question_service.AnswerRecordService) *question_handler.AnswerRecordHandler { + if answerRecordService == nil { + return nil + } + handler := question_handler.NewAnswerRecordHandler(answerRecordService) + log.Println(" ✅ Question 服务初始化成功(题目、试卷、答题记录)") + return handler +} + +// NewMaterialDAO 创建材料 DAO(MongoDB) +func NewMaterialDAO(mongodbClient *database.MongoDBClient) question_dao.MaterialDAOInterface { + if mongodbClient == nil { + return nil + } + dao := question_dao.NewMaterialDAOMongo(mongodbClient) + // 创建索引 + if err := dao.CreateIndexes(); err != nil { + log.Printf(" ⚠️ 创建材料索引失败: %v", err) + } + return dao +} + +// NewMaterialService 创建材料服务 +func NewMaterialService(materialDAO question_dao.MaterialDAOInterface) *question_service.MaterialService { + if materialDAO == nil { + return nil + } + return question_service.NewMaterialService(materialDAO) +} + +// NewMaterialHandler 创建材料处理器 +func NewMaterialHandler(materialService *question_service.MaterialService) *question_handler.MaterialHandler { + if materialService == nil { + return nil + } + handler := question_handler.NewMaterialHandler(materialService) + log.Println(" ✅ Material 服务初始化成功") + return handler +} + +// NewKnowledgeTreeDAO 创建知识树 DAO(MongoDB) +func NewKnowledgeTreeDAO(mongodbClient *database.MongoDBClient) question_dao.KnowledgeTreeDAOInterface { + if mongodbClient == nil { + return nil + } + dao := question_dao.NewKnowledgeTreeDAOMongo(mongodbClient) + return dao +} + +// NewKnowledgeTreeService 创建知识树服务 +func NewKnowledgeTreeService(knowledgeTreeDAO question_dao.KnowledgeTreeDAOInterface) *question_service.KnowledgeTreeService { + if knowledgeTreeDAO == nil { + return nil + } + return question_service.NewKnowledgeTreeService(knowledgeTreeDAO) +} + +// NewKnowledgeTreeHandler 创建知识树处理器 +func NewKnowledgeTreeHandler(knowledgeTreeService *question_service.KnowledgeTreeService) *question_handler.KnowledgeTreeHandler { + if knowledgeTreeService == nil { + return nil + } + handler := question_handler.NewKnowledgeTreeHandler(knowledgeTreeService) + log.Println(" ✅ KnowledgeTree 服务初始化成功") + return handler +} + +// AdminApp Admin 应用类型 +type AdminApp struct { + *fiber.App +} + +// APIApp API 应用类型 +type APIApp struct { + *fiber.App +} + +// NewAdminUserDAO 创建管理员用户 DAO +func NewAdminUserDAO(mysqlClient *database.MySQLClient) *admin_auth_dao.AdminUserDAO { + if mysqlClient == nil { + return nil + } + return admin_auth_dao.NewAdminUserDAO(mysqlClient) +} + +// NewAuthService 创建认证服务 +func NewAuthService(adminUserDAO *admin_auth_dao.AdminUserDAO, cfg *config.Config) *admin_auth_service.AuthService { + if adminUserDAO == nil { + return nil + } + // 解析 JWT 过期时间 + jwtExpiresIn := 24 * time.Hour // 默认 24 小时 + if cfg.Admin.JWTExpiresIn != "" { + if parsed, err := time.ParseDuration(cfg.Admin.JWTExpiresIn); err == nil { + jwtExpiresIn = parsed + } + } + // 获取 JWT 密钥 + jwtSecret := cfg.Admin.JWTSecret + if jwtSecret == "" { + jwtSecret = "your-secret-key-change-in-production" + } + service := admin_auth_service.NewAuthService(adminUserDAO, jwtSecret, jwtExpiresIn) + log.Println(" ✅ Admin 认证服务初始化成功") + return service +} + +// NewAuthHandler 创建认证处理器 +func NewAuthHandler(authService *admin_auth_service.AuthService) *admin_auth_handler.AuthHandler { + if authService == nil { + return nil + } + return admin_auth_handler.NewAuthHandler(authService) +} + +// NewAdminUserService 创建管理员用户服务 +func NewAdminUserService(adminUserDAO *admin_auth_dao.AdminUserDAO) *admin_auth_service.AdminUserService { + if adminUserDAO == nil { + return nil + } + return admin_auth_service.NewAdminUserService(adminUserDAO) +} + +// NewAdminUserHandler 创建管理员用户处理器 +func NewAdminUserHandler(adminUserService *admin_auth_service.AdminUserService) *admin_auth_handler.AdminUserHandler { + if adminUserService == nil { + return nil + } + return admin_auth_handler.NewAdminUserHandler(adminUserService) +} + +// NewRoleDAO 创建角色 DAO +func NewRoleDAO(mysqlClient *database.MySQLClient) *admin_auth_dao.RoleDAO { + if mysqlClient == nil { + return nil + } + return admin_auth_dao.NewRoleDAO(mysqlClient) +} + +// NewRoleService 创建角色服务 +func NewRoleService(roleDAO *admin_auth_dao.RoleDAO) *admin_auth_service.RoleService { + if roleDAO == nil { + return nil + } + return admin_auth_service.NewRoleService(roleDAO) +} + +// NewRoleHandler 创建角色处理器 +func NewRoleHandler(roleService *admin_auth_service.RoleService) *admin_auth_handler.RoleHandler { + if roleService == nil { + return nil + } + return admin_auth_handler.NewRoleHandler(roleService) +} + +// NewPermissionDAO 创建权限 DAO +func NewPermissionDAO(mysqlClient *database.MySQLClient) *admin_auth_dao.PermissionDAO { + if mysqlClient == nil { + return nil + } + return admin_auth_dao.NewPermissionDAO(mysqlClient) +} + +// NewPermissionService 创建权限服务 +func NewPermissionService(permissionDAO *admin_auth_dao.PermissionDAO) *admin_auth_service.PermissionService { + if permissionDAO == nil { + return nil + } + return admin_auth_service.NewPermissionService(permissionDAO) +} + +// NewPermissionHandler 创建权限处理器 +func NewPermissionHandler(permissionService *admin_auth_service.PermissionService) *admin_auth_handler.PermissionHandler { + if permissionService == nil { + return nil + } + return admin_auth_handler.NewPermissionHandler(permissionService) +} + +// NewStatisticsService 创建统计服务 +func NewStatisticsService( + mysqlClient *database.MySQLClient, + campDAO *dao.CampDAO, + questionDAO question_dao.QuestionDAOInterface, + paperDAO question_dao.PaperDAOInterface, +) *statistics.Service { + if mysqlClient == nil || campDAO == nil || questionDAO == nil || paperDAO == nil { + return nil + } + return statistics.NewService(mysqlClient, campDAO, questionDAO, paperDAO) +} + +// NewStatisticsHandler 创建统计处理器 +func NewStatisticsHandler(statisticsService *statistics.Service) *statistics.Handler { + if statisticsService == nil { + return nil + } + return statistics.NewHandler(statisticsService) +} + +// Document 文档管理 +func NewFolderDAO(mysqlClient *database.MySQLClient) *document_dao.FolderDAO { + if mysqlClient == nil { + return nil + } + return document_dao.NewFolderDAO(mysqlClient) +} + +func NewFileDAO(mysqlClient *database.MySQLClient) *document_dao.FileDAO { + if mysqlClient == nil { + return nil + } + return document_dao.NewFileDAO(mysqlClient) +} + +func NewDocumentService(folderDAO *document_dao.FolderDAO, fileDAO *document_dao.FileDAO) *document_service.DocumentService { + if folderDAO == nil || fileDAO == nil { + return nil + } + return document_service.NewDocumentService(folderDAO, fileDAO) +} + +func NewDocumentHandler(documentService *document_service.DocumentService) *document_handler.Handler { + if documentService == nil { + return nil + } + return document_handler.NewHandler(documentService) +} + +// NewAdminApp 创建 Admin 应用 +func NewAdminApp( + cfg *config.Config, + ossHandler *oss.Handler, + paymentHandler *payment.Handler, + schedulerHandler *scheduler.Handler, + campCategoryHandler *handler.CategoryHandler, + campHandler *handler.CampHandler, + sectionHandler *handler.SectionHandler, + taskHandler *handler.TaskHandler, + progressHandler *handler.ProgressHandler, + userCampHandler *handler.UserCampHandler, + orderHandler *order_handler.OrderHandler, + questionHandler *question_handler.QuestionHandler, + paperHandler *question_handler.PaperHandler, + answerRecordHandler *question_handler.AnswerRecordHandler, + authHandler *admin_auth_handler.AuthHandler, + authService *admin_auth_service.AuthService, + statisticsHandler *statistics.Handler, + adminUserHandler *admin_auth_handler.AdminUserHandler, + roleHandler *admin_auth_handler.RoleHandler, + permissionHandler *admin_auth_handler.PermissionHandler, + materialHandler *question_handler.MaterialHandler, + knowledgeTreeHandler *question_handler.KnowledgeTreeHandler, + documentHandler *document_handler.Handler, +) *AdminApp { + app := createFiberApp("Admin", cfg) + admin.SetupRoutes(app, ossHandler, paymentHandler, schedulerHandler, campCategoryHandler, campHandler, sectionHandler, taskHandler, progressHandler, userCampHandler, orderHandler, questionHandler, paperHandler, answerRecordHandler, materialHandler, knowledgeTreeHandler, documentHandler, authHandler, authService, statisticsHandler, adminUserHandler, roleHandler, permissionHandler) + return &AdminApp{App: app} +} + +// NewAPIApp 创建 API 应用 +func NewAPIApp( + cfg *config.Config, + ossHandler *oss.Handler, + paymentHandler *payment.Handler, + schedulerHandler *scheduler.Handler, + campCategoryHandler *handler.CategoryHandler, + campHandler *handler.CampHandler, + sectionHandler *handler.SectionHandler, + taskHandler *handler.TaskHandler, + progressHandler *handler.ProgressHandler, + userCampHandler *handler.UserCampHandler, + orderHandler *order_handler.OrderHandler, + questionHandler *question_handler.QuestionHandler, + paperHandler *question_handler.PaperHandler, + answerRecordHandler *question_handler.AnswerRecordHandler, +) *APIApp { + app := createFiberApp("API", cfg) + api.SetupRoutes(app, ossHandler, paymentHandler, schedulerHandler, campCategoryHandler, campHandler, sectionHandler, taskHandler, progressHandler, userCampHandler, orderHandler, questionHandler, paperHandler, answerRecordHandler) + return &APIApp{App: app} +} + +// createFiberApp 创建 Fiber 应用 +func createFiberApp(name string, cfg *config.Config) *fiber.App { + app := fiber.New(fiber.Config{ + AppName: cfg.Service.Name + " - " + name, + Prefork: false, + CaseSensitive: false, + StrictRouting: false, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + }) + + // 中间件 + app.Use(recover.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", + AllowHeaders: "Origin,Content-Type,Accept,Authorization", + })) + + return app +} + +// maskURI 隐藏 URI 中的敏感信息(密码) +func maskURI(uri string) string { + // 简单的 URI 掩码:隐藏密码部分 + // mongodb://username:password@host:port/database + // 转换为: mongodb://username:***@host:port/database + if len(uri) > 20 { + // 查找 @ 符号的位置 + atIndex := -1 + for i := 0; i < len(uri); i++ { + if uri[i] == '@' { + atIndex = i + break + } + } + if atIndex > 0 { + // 查找最后一个 : 在 @ 之前的位置 + colonIndex := -1 + for i := atIndex - 1; i >= 0; i-- { + if uri[i] == ':' { + colonIndex = i + break + } + } + if colonIndex > 0 { + return uri[:colonIndex+1] + "***" + uri[atIndex:] + } + } + } + return uri +} diff --git a/internal/wire/wire.go b/internal/wire/wire.go new file mode 100644 index 0000000..d56f601 --- /dev/null +++ b/internal/wire/wire.go @@ -0,0 +1,108 @@ +//go:build wireinject +// +build wireinject + +package wire + +import ( + "dd_fiber_api/config" + + "github.com/google/wire" +) + +// AppSet 应用依赖集合 +var AppSet = wire.NewSet( + // 数据库客户端 + NewMySQLClient, + NewMongoDBClient, + NewRedisClient, + + // OSS 服务 + NewOSSService, + NewOSSHandler, + + // 支付服务 + NewWechatPayV3Service, + NewPaymentHandler, + + // 调度器服务 + NewSchedulerService, + NewSchedulerHandler, + + // Camp 相关 + NewCategoryDAO, + NewCategoryService, + NewCategoryHandler, + NewCampDAO, + NewCampService, + NewCampHandler, + NewSectionDAO, + NewSectionService, + NewSectionHandler, + NewTaskDAO, + NewTaskService, + NewTaskHandler, + NewProgressDAO, + NewProgressService, + NewProgressHandler, + NewUserCampDAO, + NewResetHistoryDAO, + NewUserCampService, + NewUserCampHandler, + NewUserSectionAccessDAO, + + // 订单相关 + NewOrderDAO, + NewOrderService, + NewOrderHandler, + + // Question 相关 + NewQuestionDAO, + NewQuestionService, + NewQuestionHandler, + NewPaperDAO, + NewPaperService, + NewPaperHandler, + NewAnswerRecordDAO, + NewAnswerRecordService, + NewAnswerRecordHandler, + NewMaterialDAO, + NewMaterialService, + NewMaterialHandler, + NewKnowledgeTreeDAO, + NewKnowledgeTreeService, + NewKnowledgeTreeHandler, + + // Admin Auth 相关 + NewAdminUserDAO, + NewAuthService, + NewAuthHandler, + NewAdminUserService, + NewAdminUserHandler, + NewRoleDAO, + NewRoleService, + NewRoleHandler, + NewPermissionDAO, + NewPermissionService, + NewPermissionHandler, + + // Statistics 相关 + NewStatisticsService, + NewStatisticsHandler, + + // Document 文档管理 + NewFolderDAO, + NewFileDAO, + NewDocumentService, + NewDocumentHandler, +) + +// InitializeApp 初始化应用 +func InitializeApp(cfg *config.Config) (*App, error) { + wire.Build( + AppSet, + NewAdminApp, + NewAPIApp, + NewApp, + ) + return nil, nil +} diff --git a/internal/wire/wire_gen.go b/internal/wire/wire_gen.go new file mode 100644 index 0000000..4c6cf29 --- /dev/null +++ b/internal/wire/wire_gen.go @@ -0,0 +1,178 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package wire + +import ( + "dd_fiber_api/config" + "github.com/google/wire" +) + +// Injectors from wire.go: + +// InitializeApp 初始化应用 +func InitializeApp(cfg *config.Config) (*App, error) { + redisClient, err := NewRedisClient(cfg) + if err != nil { + return nil, err + } + service := NewOSSService(cfg, redisClient) + handler := NewOSSHandler(service) + wechatPayV3Service, err := NewWechatPayV3Service(cfg) + if err != nil { + return nil, err + } + mySQLClient, err := NewMySQLClient(cfg) + if err != nil { + return nil, err + } + orderDAO := NewOrderDAO(mySQLClient) + userSectionAccessDAO := NewUserSectionAccessDAO(mySQLClient) + userCampDAO := NewUserCampDAO(mySQLClient) + paymentHandler := NewPaymentHandler(wechatPayV3Service, orderDAO, userSectionAccessDAO, userCampDAO) + schedulerService := NewSchedulerService(cfg) + schedulerHandler := NewSchedulerHandler(schedulerService) + categoryDAO := NewCategoryDAO(mySQLClient) + campDAO := NewCampDAO(mySQLClient) + categoryService := NewCategoryService(categoryDAO, campDAO) + categoryHandler := NewCategoryHandler(categoryService) + sectionDAO := NewSectionDAO(mySQLClient) + taskDAO := NewTaskDAO(mySQLClient) + progressDAO := NewProgressDAO(mySQLClient) + mongoDBClient, err := NewMongoDBClient(cfg) + if err != nil { + return nil, err + } + answerRecordDAOInterface := NewAnswerRecordDAO(mongoDBClient) + questionDAOInterface := NewQuestionDAO(mongoDBClient) + answerRecordService := NewAnswerRecordService(answerRecordDAOInterface, questionDAOInterface) + paperDAOInterface := NewPaperDAO(mongoDBClient, questionDAOInterface) + materialDAOInterface := NewMaterialDAO(mongoDBClient) + materialService := NewMaterialService(materialDAOInterface) + paperService := NewPaperService(paperDAOInterface, materialService) + orderService := NewOrderService(orderDAO, sectionDAO, wechatPayV3Service, userSectionAccessDAO, userCampDAO, schedulerService, cfg) + campService := NewCampService(campDAO, sectionDAO, taskDAO, progressDAO, userCampDAO, orderDAO, answerRecordService, paperService, orderService) + resetHistoryDAO := NewResetHistoryDAO(mySQLClient) + userCampService := NewUserCampService(userCampDAO, sectionDAO, progressDAO, taskDAO, resetHistoryDAO, answerRecordDAOInterface) + campHandler := NewCampHandler(campService, userCampService) + sectionService := NewSectionService(sectionDAO, campDAO) + sectionHandler := NewSectionHandler(sectionService, orderService, orderDAO, userCampDAO) + taskService := NewTaskService(taskDAO) + taskHandler := NewTaskHandler(taskService) + progressService := NewProgressService(progressDAO, taskDAO, userCampDAO, answerRecordService, campService) + progressHandler := NewProgressHandler(progressService) + userCampHandler := NewUserCampHandler(userCampService) + orderHandler := NewOrderHandler(orderService) + questionService := NewQuestionService(questionDAOInterface) + knowledgeTreeDAOInterface := NewKnowledgeTreeDAO(mongoDBClient) + knowledgeTreeService := NewKnowledgeTreeService(knowledgeTreeDAOInterface) + questionHandler := NewQuestionHandler(questionService, knowledgeTreeService) + paperHandler := NewPaperHandler(paperService) + answerRecordHandler := NewAnswerRecordHandler(answerRecordService) + adminUserDAO := NewAdminUserDAO(mySQLClient) + authService := NewAuthService(adminUserDAO, cfg) + authHandler := NewAuthHandler(authService) + statisticsService := NewStatisticsService(mySQLClient, campDAO, questionDAOInterface, paperDAOInterface) + statisticsHandler := NewStatisticsHandler(statisticsService) + adminUserService := NewAdminUserService(adminUserDAO) + adminUserHandler := NewAdminUserHandler(adminUserService) + roleDAO := NewRoleDAO(mySQLClient) + roleService := NewRoleService(roleDAO) + roleHandler := NewRoleHandler(roleService) + permissionDAO := NewPermissionDAO(mySQLClient) + permissionService := NewPermissionService(permissionDAO) + permissionHandler := NewPermissionHandler(permissionService) + materialHandler := NewMaterialHandler(materialService) + knowledgeTreeHandler := NewKnowledgeTreeHandler(knowledgeTreeService) + folderDAO := NewFolderDAO(mySQLClient) + fileDAO := NewFileDAO(mySQLClient) + documentService := NewDocumentService(folderDAO, fileDAO) + handlerHandler := NewDocumentHandler(documentService) + adminApp := NewAdminApp(cfg, handler, paymentHandler, schedulerHandler, categoryHandler, campHandler, sectionHandler, taskHandler, progressHandler, userCampHandler, orderHandler, questionHandler, paperHandler, answerRecordHandler, authHandler, authService, statisticsHandler, adminUserHandler, roleHandler, permissionHandler, materialHandler, knowledgeTreeHandler, handlerHandler) + apiApp := NewAPIApp(cfg, handler, paymentHandler, schedulerHandler, categoryHandler, campHandler, sectionHandler, taskHandler, progressHandler, userCampHandler, orderHandler, questionHandler, paperHandler, answerRecordHandler) + app := NewApp(adminApp, apiApp, cfg, mySQLClient, mongoDBClient, redisClient) + return app, nil +} + +// wire.go: + +// AppSet 应用依赖集合 +var AppSet = wire.NewSet( + + NewMySQLClient, + NewMongoDBClient, + NewRedisClient, + + NewOSSService, + NewOSSHandler, + + NewWechatPayV3Service, + NewPaymentHandler, + + NewSchedulerService, + NewSchedulerHandler, + + NewCategoryDAO, + NewCategoryService, + NewCategoryHandler, + NewCampDAO, + NewCampService, + NewCampHandler, + NewSectionDAO, + NewSectionService, + NewSectionHandler, + NewTaskDAO, + NewTaskService, + NewTaskHandler, + NewProgressDAO, + NewProgressService, + NewProgressHandler, + NewUserCampDAO, + NewResetHistoryDAO, + NewUserCampService, + NewUserCampHandler, + NewUserSectionAccessDAO, + + NewOrderDAO, + NewOrderService, + NewOrderHandler, + + NewQuestionDAO, + NewQuestionService, + NewQuestionHandler, + NewPaperDAO, + NewPaperService, + NewPaperHandler, + NewAnswerRecordDAO, + NewAnswerRecordService, + NewAnswerRecordHandler, + NewMaterialDAO, + NewMaterialService, + NewMaterialHandler, + NewKnowledgeTreeDAO, + NewKnowledgeTreeService, + NewKnowledgeTreeHandler, + + NewAdminUserDAO, + NewAuthService, + NewAuthHandler, + NewAdminUserService, + NewAdminUserHandler, + NewRoleDAO, + NewRoleService, + NewRoleHandler, + NewPermissionDAO, + NewPermissionService, + NewPermissionHandler, + + NewStatisticsService, + NewStatisticsHandler, + + NewFolderDAO, + NewFileDAO, + NewDocumentService, + NewDocumentHandler, +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..6fe7a1c --- /dev/null +++ b/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "dd_fiber_api/config" + "dd_fiber_api/internal/wire" +) + +func main() { + // 解析命令行参数 + configPath := flag.String("config", "config.yaml", "配置文件路径") + flag.Parse() + + // 加载配置 + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + + log.Printf("🚀 启动 %s v%s", cfg.Service.Name, cfg.Service.Version) + log.Println() + + // 使用 Wire 初始化应用 + log.Println("📦 正在初始化应用...") + app, err := wire.InitializeApp(cfg) + if err != nil { + log.Fatalf("❌ 应用初始化失败: %v", err) + } + + // 确保资源关闭 + defer app.Close() + + log.Println() + log.Println("=========================================") + log.Println(" ✅ 应用初始化完成") + if app.MySQLClient != nil { + log.Println(" ✅ MySQL: 已连接") + } else { + log.Println(" ⚠️ MySQL: 未配置") + } + if app.MongoDBClient != nil { + log.Printf(" ✅ MongoDB: 已连接 (数据库: %s)", cfg.MongoDB.Database) + } else { + log.Println(" ⚠️ MongoDB: 未连接") + } + if app.RedisClient != nil { + log.Println(" ✅ Redis: 已连接") + } else { + log.Println(" ⚠️ Redis: 未配置或连接失败") + } + log.Println("=========================================") + log.Println() + + // 启动Admin服务器 + go func() { + adminAddr := fmt.Sprintf("%s:%d", cfg.Service.Host, cfg.Service.AdminPort) + log.Printf("🌐 Admin服务启动,监听地址: %s", adminAddr) + if err := app.AdminApp.Listen(adminAddr); err != nil { + log.Fatalf("Admin服务启动失败: %v", err) + } + }() + + // 启动API服务器 + go func() { + apiAddr := fmt.Sprintf("%s:%d", cfg.Service.Host, cfg.Service.APIPort) + log.Printf("🌐 API服务启动,监听地址: %s", apiAddr) + if err := app.APIApp.Listen(apiAddr); err != nil { + log.Fatalf("API服务启动失败: %v", err) + } + }() + + // 等待中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("👋 正在关闭服务器...") +} diff --git a/pkg/database/mongodb.go b/pkg/database/mongodb.go new file mode 100644 index 0000000..4ce1a78 --- /dev/null +++ b/pkg/database/mongodb.go @@ -0,0 +1,77 @@ +package database + +import ( + "context" + "fmt" + "time" + + "dd_fiber_api/config" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// MongoDBClient MongoDB 客户端 +type MongoDBClient struct { + Client *mongo.Client + Database *mongo.Database +} + +// NewMongoDBClient 创建 MongoDB 客户端 +func NewMongoDBClient(cfg *config.MongoDBConfig) (*MongoDBClient, error) { + if cfg.URI == "" { + return nil, fmt.Errorf("MongoDB URI 不能为空") + } + if cfg.Database == "" { + return nil, fmt.Errorf("MongoDB 数据库名不能为空") + } + + // 解析超时时间 + var timeout time.Duration + if cfg.Timeout != "" { + var err error + timeout, err = time.ParseDuration(cfg.Timeout) + if err != nil { + timeout = 10 * time.Second + } + } else { + timeout = 10 * time.Second + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // 连接选项 + clientOptions := options.Client().ApplyURI(cfg.URI) + + // 创建客户端 + client, err := mongo.Connect(ctx, clientOptions) + if err != nil { + return nil, fmt.Errorf("连接 MongoDB 失败: %v", err) + } + + // 测试连接 + if err := client.Ping(ctx, nil); err != nil { + return nil, fmt.Errorf("MongoDB 连接测试失败: %v", err) + } + + database := client.Database(cfg.Database) + + return &MongoDBClient{ + Client: client, + Database: database, + }, nil +} + +// Close 关闭 MongoDB 连接 +func (c *MongoDBClient) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return c.Client.Disconnect(ctx) +} + +// Collection 获取集合 +func (c *MongoDBClient) Collection(name string) *mongo.Collection { + return c.Database.Collection(name) +} + diff --git a/pkg/database/mysql.go b/pkg/database/mysql.go new file mode 100644 index 0000000..9ed1337 --- /dev/null +++ b/pkg/database/mysql.go @@ -0,0 +1,71 @@ +package database + +import ( + "context" + "fmt" + "time" + + "dd_fiber_api/config" + + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" +) + +// MySQLClient MySQL客户端封装(使用sqlx) +type MySQLClient struct { + DB *sqlx.DB +} + +// NewMySQLClient 创建MySQL客户端 +func NewMySQLClient(cfg *config.MySQLConfig) (*MySQLClient, error) { + if cfg.Database == "" { + return nil, fmt.Errorf("MySQL数据库名不能为空") + } + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", + cfg.Username, + cfg.Password, + cfg.Host, + cfg.Port, + cfg.Database, + cfg.Charset, + ) + + db, err := sqlx.Connect("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("连接MySQL失败: %v", err) + } + + // 设置连接池参数 + if cfg.MaxOpenConns > 0 { + db.SetMaxOpenConns(cfg.MaxOpenConns) + } + if cfg.MaxIdleConns > 0 { + db.SetMaxIdleConns(cfg.MaxIdleConns) + } + + // 解析连接最大生命周期 + if cfg.ConnMaxLifetime != "" { + if duration, err := time.ParseDuration(cfg.ConnMaxLifetime); err == nil { + db.SetConnMaxLifetime(duration) + } + } + + return &MySQLClient{DB: db}, nil +} + +// Close 关闭数据库连接 +func (c *MySQLClient) Close() error { + if c.DB != nil { + return c.DB.Close() + } + return nil +} + +// Ping 测试数据库连接 +func (c *MySQLClient) Ping() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return c.DB.PingContext(ctx) +} + diff --git a/pkg/database/redis.go b/pkg/database/redis.go new file mode 100644 index 0000000..ff62546 --- /dev/null +++ b/pkg/database/redis.go @@ -0,0 +1,86 @@ +package database + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "dd_fiber_api/config" + + "github.com/redis/go-redis/v9" +) + +// RedisClient Redis客户端 +type RedisClient struct { + rdb *redis.Client +} + +// NewRedisClient 创建Redis客户端 +func NewRedisClient(cfg *config.RedisConfig) (*RedisClient, error) { + rdb := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConns, + }) + + // 测试连接 + ctx := context.Background() + if err := rdb.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("Redis连接失败: %w", err) + } + + return &RedisClient{rdb: rdb}, nil +} + +// Ping 测试Redis连接 +func (c *RedisClient) Ping(ctx context.Context) error { + return c.rdb.Ping(ctx).Err() +} + +// Set 设置键值对,带过期时间 +func (c *RedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + jsonData, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("序列化数据失败: %w", err) + } + return c.rdb.Set(ctx, key, jsonData, expiration).Err() +} + +// Get 获取键值对 +func (c *RedisClient) Get(ctx context.Context, key string, dest interface{}) error { + val, err := c.rdb.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return fmt.Errorf("键不存在: %s", key) + } + return fmt.Errorf("获取键值失败: %w", err) + } + + if err := json.Unmarshal([]byte(val), dest); err != nil { + return fmt.Errorf("反序列化数据失败: %w", err) + } + + return nil +} + +// Exists 检查键是否存在 +func (c *RedisClient) Exists(ctx context.Context, key string) (bool, error) { + count, err := c.rdb.Exists(ctx, key).Result() + if err != nil { + return false, fmt.Errorf("检查键存在性失败: %w", err) + } + return count > 0, nil +} + +// Delete 删除键 +func (c *RedisClient) Delete(ctx context.Context, key string) error { + return c.rdb.Del(ctx, key).Err() +} + +// Close 关闭Redis连接 +func (c *RedisClient) Close() error { + return c.rdb.Close() +} diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go new file mode 100644 index 0000000..49dc294 --- /dev/null +++ b/pkg/jwt/jwt.go @@ -0,0 +1,58 @@ +package jwt + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + // ErrTokenExpired token 已过期 + ErrTokenExpired = errors.New("token已过期") + // ErrTokenInvalid token 无效 + ErrTokenInvalid = errors.New("token无效") +) + +// JWT JWT工具 +type JWT struct { + secretKey []byte + expiresIn time.Duration +} + +// NewJWT 创建JWT实例 +func NewJWT(secretKey string, expiresIn time.Duration) *JWT { + return &JWT{ + secretKey: []byte(secretKey), + expiresIn: expiresIn, + } +} + +// GenerateToken 生成token +func (j *JWT) GenerateToken(claims jwt.Claims) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(j.secretKey) +} + +// ParseToken 解析token +func (j *JWT) ParseToken(tokenString string, claims jwt.Claims) error { + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return j.secretKey, nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return ErrTokenExpired + } + return ErrTokenInvalid + } + + if !token.Valid { + return ErrTokenInvalid + } + + return nil +} diff --git a/pkg/snowflake/snowflake.go b/pkg/snowflake/snowflake.go new file mode 100644 index 0000000..b400702 --- /dev/null +++ b/pkg/snowflake/snowflake.go @@ -0,0 +1,124 @@ +package snowflake + +import ( + "fmt" + "sync" + "time" +) + +// Snowflake 雪花算法ID生成器 +type Snowflake struct { + mutex sync.Mutex + startTime int64 // 起始时间戳(毫秒) + machineID int64 // 机器ID + datacenterID int64 // 数据中心ID + sequence int64 // 序列号 +} + +const ( + // 雪花算法各个部分的位数 + workerIDBits = 10 // 机器ID位数 + datacenterIDBits = 10 // 数据中心ID位数 + sequenceBits = 12 // 序列号位数 + + // 最大值 + maxWorkerID = -1 ^ (-1 << workerIDBits) // 1023 + maxDatacenterID = -1 ^ (-1 << datacenterIDBits) // 1023 + maxSequence = -1 ^ (-1 << sequenceBits) // 4095 + + // 位移 + workerIDShift = sequenceBits + datacenterIDShift = sequenceBits + workerIDBits + timestampShift = sequenceBits + workerIDBits + datacenterIDBits +) + +var ( + // 全局雪花算法实例 + instance *Snowflake + once sync.Once +) + +// NewSnowflake 创建新的雪花算法实例 +func NewSnowflake(machineID, datacenterID int64) (*Snowflake, error) { + if machineID < 0 || machineID > maxWorkerID { + return nil, fmt.Errorf("machineID必须在0-%d之间", maxWorkerID) + } + if datacenterID < 0 || datacenterID > maxDatacenterID { + return nil, fmt.Errorf("datacenterID必须在0-%d之间", maxDatacenterID) + } + + return &Snowflake{ + startTime: 1640995200000, // 2022-01-01 00:00:00 UTC + machineID: machineID, + datacenterID: datacenterID, + }, nil +} + +// InitDefault 初始化默认实例 +func InitDefault(machineID, datacenterID int64) error { + var err error + once.Do(func() { + instance, err = NewSnowflake(machineID, datacenterID) + }) + return err +} + +// GetInstance 获取默认实例 +func GetInstance() *Snowflake { + if instance == nil { + // 默认初始化 + instance, _ = NewSnowflake(1, 1) + } + return instance +} + +// NextID 生成下一个ID(返回正数) +func (s *Snowflake) NextID() int64 { + s.mutex.Lock() + defer s.mutex.Unlock() + + now := time.Now().UnixNano() / 1e6 // 当前时间戳(毫秒) + + if now < s.startTime { + panic("时钟回拨,拒绝生成ID") + } + + if now == s.startTime { + // 同一毫秒内 + s.sequence = (s.sequence + 1) & maxSequence + if s.sequence == 0 { + // 序列号溢出,等待下一毫秒 + for now <= s.startTime { + now = time.Now().UnixNano() / 1e6 + } + } + } else { + // 新的毫秒,重置序列号 + s.sequence = 0 + } + + s.startTime = now + + // 生成ID - 确保返回正数 + id := ((now - 1640995200000) << timestampShift) | + (s.datacenterID << datacenterIDShift) | + (s.machineID << workerIDShift) | + s.sequence + + // 确保ID为正数(移除符号位) + if id < 0 { + id = id & 0x7FFFFFFFFFFFFFFF // 移除符号位 + } + + return id +} + +// NextIDString 生成下一个ID(字符串格式) +func (s *Snowflake) NextIDString() string { + return fmt.Sprintf("%d", s.NextID()) +} + +// GenerateID 全局函数,生成唯一ID +func GenerateID() string { + return GetInstance().NextIDString() +} diff --git a/pkg/timewheel/timewheel.go b/pkg/timewheel/timewheel.go new file mode 100644 index 0000000..f451d8d --- /dev/null +++ b/pkg/timewheel/timewheel.go @@ -0,0 +1,260 @@ +package timewheel + +import ( + "sync" + "time" +) + +// TaskStatus 任务状态 +type TaskStatus int + +const ( + TaskStatusPending TaskStatus = iota // 等待中 + TaskStatusRunning // 运行中 + TaskStatusCompleted // 已完成 + TaskStatusCancelled // 已取消 +) + +// Task 任务结构 +type Task struct { + ID string + Delay time.Duration + Interval time.Duration // 循环任务间隔 + Callback func() + IsCyclic bool + Status TaskStatus + mu sync.RWMutex +} + +// TimeWheel 时间轮 +type TimeWheel struct { + tickInterval time.Duration // 时间轮刻度间隔 + slotNum int // 槽位数量 + slots []*slot // 槽位数组 + currentSlot int // 当前槽位 + ticker *time.Ticker + stopCh chan struct{} + tasks map[string]*Task // 任务映射 + mu sync.RWMutex +} + +// slot 槽位 +type slot struct { + tasks map[string]*Task + mu sync.RWMutex +} + +// New 创建时间轮 +func New(tickInterval time.Duration, slotNum int) *TimeWheel { + tw := &TimeWheel{ + tickInterval: tickInterval, + slotNum: slotNum, + slots: make([]*slot, slotNum), + currentSlot: 0, + stopCh: make(chan struct{}), + tasks: make(map[string]*Task), + } + + // 初始化槽位 + for i := 0; i < slotNum; i++ { + tw.slots[i] = &slot{ + tasks: make(map[string]*Task), + } + } + + return tw +} + +// Start 启动时间轮 +func (tw *TimeWheel) Start() { + tw.ticker = time.NewTicker(tw.tickInterval) + go tw.run() +} + +// Stop 停止时间轮 +func (tw *TimeWheel) Stop() { + close(tw.stopCh) + if tw.ticker != nil { + tw.ticker.Stop() + } +} + +// run 运行时间轮 +func (tw *TimeWheel) run() { + for { + select { + case <-tw.ticker.C: + tw.tick() + case <-tw.stopCh: + return + } + } +} + +// tick 时间轮转动 +func (tw *TimeWheel) tick() { + tw.mu.Lock() + slot := tw.slots[tw.currentSlot] + tw.currentSlot = (tw.currentSlot + 1) % tw.slotNum + tw.mu.Unlock() + + // 执行当前槽位的任务 + slot.mu.Lock() + tasksToExecute := make([]*Task, 0, len(slot.tasks)) + for _, task := range slot.tasks { + tasksToExecute = append(tasksToExecute, task) + } + slot.mu.Unlock() + + // 执行任务 + for _, task := range tasksToExecute { + task.mu.Lock() + if task.Status == TaskStatusPending || task.Status == TaskStatusRunning { + task.Status = TaskStatusRunning + task.mu.Unlock() + + // 执行回调 + if task.Callback != nil { + go task.Callback() + } + + // 如果是循环任务,重新添加 + if task.IsCyclic { + task.mu.Lock() + task.Status = TaskStatusPending + task.mu.Unlock() + // 重新添加到时间轮 + tw.addTaskToSlot(task, task.Interval) + } else { + // 一次性任务,标记为完成 + task.mu.Lock() + task.Status = TaskStatusCompleted + task.mu.Unlock() + // 从槽位中移除 + slot.mu.Lock() + delete(slot.tasks, task.ID) + slot.mu.Unlock() + // 从任务映射中移除 + tw.mu.Lock() + delete(tw.tasks, task.ID) + tw.mu.Unlock() + } + } else { + task.mu.Unlock() + } + } +} + +// AddTask 添加一次性任务 +func (tw *TimeWheel) AddTask(taskID string, delay time.Duration, callback func()) error { + if delay < 0 { + delay = 0 + } + + task := &Task{ + ID: taskID, + Delay: delay, + Callback: callback, + IsCyclic: false, + Status: TaskStatusPending, + } + + tw.mu.Lock() + tw.tasks[taskID] = task + tw.mu.Unlock() + + tw.addTaskToSlot(task, delay) + return nil +} + +// AddCyclicTask 添加循环任务 +func (tw *TimeWheel) AddCyclicTask(taskID string, interval time.Duration, callback func()) error { + if interval <= 0 { + interval = tw.tickInterval + } + + task := &Task{ + ID: taskID, + Interval: interval, + Callback: callback, + IsCyclic: true, + Status: TaskStatusPending, + } + + tw.mu.Lock() + tw.tasks[taskID] = task + tw.mu.Unlock() + + tw.addTaskToSlot(task, interval) + return nil +} + +// addTaskToSlot 将任务添加到对应槽位 +func (tw *TimeWheel) addTaskToSlot(task *Task, delay time.Duration) { + // 计算需要等待的tick数 + ticks := int(delay / tw.tickInterval) + if ticks < 1 { + ticks = 1 + } + + // 计算目标槽位 + tw.mu.RLock() + targetSlot := (tw.currentSlot + ticks) % tw.slotNum + tw.mu.RUnlock() + + // 添加到目标槽位 + slot := tw.slots[targetSlot] + slot.mu.Lock() + slot.tasks[task.ID] = task + slot.mu.Unlock() +} + +// RemoveTask 移除任务 +func (tw *TimeWheel) RemoveTask(taskID string) { + tw.mu.Lock() + task, exists := tw.tasks[taskID] + if exists { + delete(tw.tasks, taskID) + } + tw.mu.Unlock() + + if !exists { + return + } + + // 从所有槽位中移除 + for _, slot := range tw.slots { + slot.mu.Lock() + delete(slot.tasks, taskID) + slot.mu.Unlock() + } + + // 标记为已取消 + task.mu.Lock() + task.Status = TaskStatusCancelled + task.mu.Unlock() +} + +// GetTaskStatus 获取任务状态 +func (tw *TimeWheel) GetTaskStatus(taskID string) (TaskStatus, bool) { + tw.mu.RLock() + task, exists := tw.tasks[taskID] + tw.mu.RUnlock() + + if !exists { + return TaskStatusCompleted, false + } + + task.mu.RLock() + status := task.Status + task.mu.RUnlock() + + return status, true +} + +// TaskCount 获取任务数量 +func (tw *TimeWheel) TaskCount() int { + tw.mu.RLock() + defer tw.mu.RUnlock() + return len(tw.tasks) +} diff --git a/pkg/utils/routes.go b/pkg/utils/routes.go new file mode 100644 index 0000000..bb73fb1 --- /dev/null +++ b/pkg/utils/routes.go @@ -0,0 +1,92 @@ +package utils + +import ( + "fmt" + "os" + "sort" + + "github.com/gofiber/fiber/v2" + "github.com/olekukonko/tablewriter" +) + +// RouteInfo 路由信息 +type RouteInfo struct { + Method string + Path string + Name string +} + +// PrintRoutes 打印所有路由(使用 tablewriter 美化输出) +func PrintRoutes(app *fiber.App, serviceName string) { + stack := app.Stack() + + // 收集所有路由 + var routes []RouteInfo + for _, routeGroup := range stack { + for _, route := range routeGroup { + method := route.Method + path := route.Path + if path == "" { + path = "/" + } + + // 过滤掉 Fiber 自动生成的默认路由 + // 1. 过滤掉 HEAD 方法(通常是自动生成的) + if method == "HEAD" { + continue + } + + // 2. 完全过滤掉根路径 `/` 的所有路由 + // 这些是 Fiber 自动注册的默认路由,通常没有实际意义 + if path == "/" { + continue + } + + routes = append(routes, RouteInfo{ + Method: method, + Path: path, + Name: route.Name, + }) + } + } + + // 按路径和方法排序 + sort.Slice(routes, func(i, j int) bool { + if routes[i].Path != routes[j].Path { + return routes[i].Path < routes[j].Path + } + return routes[i].Method < routes[j].Method + }) + + // 打印标题 + fmt.Printf("\n📋 %s 路由列表\n", serviceName) + + if len(routes) == 0 { + fmt.Println(" 暂无路由") + return + } + + // 创建表格(使用新版本 API) + table := tablewriter.NewWriter(os.Stdout) + + // 设置表头 + table.Header("METHOD", "PATH", "NAME") + + // 添加数据行 + for _, route := range routes { + method := route.Method + path := route.Path + name := route.Name + if name == "" { + name = "-" + } + + table.Append(method, path, name) + } + + // 渲染表格 + if err := table.Render(); err != nil { + fmt.Printf("渲染表格失败: %v\n", err) + } + fmt.Printf(" 共 %d 个路由\n\n", len(routes)) +} diff --git a/pkg/utils/timeutil.go b/pkg/utils/timeutil.go new file mode 100644 index 0000000..ff66b9e --- /dev/null +++ b/pkg/utils/timeutil.go @@ -0,0 +1,23 @@ +package utils + +import ( + "database/sql" + "time" +) + +// FormatNullTimeToStd 将 sql.NullTime 转换为标准时间字符串(RFC3339格式) +func FormatNullTimeToStd(nullTime sql.NullTime) string { + if !nullTime.Valid { + return "" + } + return nullTime.Time.Format(time.RFC3339) +} + +// ParseTimeFromStd 将标准时间字符串(RFC3339格式)转换为 time.Time +func ParseTimeFromStd(timeStr string) (time.Time, error) { + if timeStr == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339, timeStr) +} + diff --git a/scripts/README_permissions.md b/scripts/README_permissions.md new file mode 100644 index 0000000..bb554a6 --- /dev/null +++ b/scripts/README_permissions.md @@ -0,0 +1,101 @@ +# 权限初始化说明 + +## 概述 + +本目录包含权限初始化脚本,用于将系统中的所有功能权限自动导入到 `admin_permissions` 表中。 + +## 权限分类 + +系统权限按模块分类,共包含以下模块: + +### 1. 管理员管理 (admin) +- 管理员用户管理:创建、查看、更新、删除 +- 角色管理:创建、查看、更新、删除、设置权限 +- 权限管理:创建、查看、更新、删除 + +### 2. 打卡营管理 (camp) +- 分类管理:创建、查看、更新、删除 +- 打卡营管理:创建、查看、更新、删除 +- 小节管理:创建、查看、更新、删除 +- 任务管理:创建、查看、更新、删除 +- 用户进度管理:查看、更新、重置 +- 用户打卡营管理:查看、加入、重置进度 + +### 3. 订单管理 (order) +- 订单:创建、查看、更新状态 + +### 4. 题目试卷管理 (question) +- 题目管理:创建、查看、更新、删除、批量删除 +- 试卷管理:创建、查看、更新、删除、批量删除、添加题目、移除题目 +- 答题记录管理:创建、查看、删除、统计 + +### 5. 统计 (statistics) +- 仪表盘统计:查看 + +### 6. OSS (oss) +- OSS上传:获取上传凭证 + +### 7. 支付 (payment) +- 微信支付:创建订单、处理通知 + +### 8. 调度器 (scheduler) +- 调度任务:创建、查看、删除 + +## 使用方法 + +### 方法一:使用 Go 脚本(推荐) + +```bash +cd dd_fiber_api +go run scripts/init_permissions.go +``` + +或者指定配置文件路径: + +```bash +go run scripts/init_permissions.go config.yaml +``` + +### 方法二:使用 SQL 脚本 + +直接执行 SQL 文件: + +```bash +mysql -u root -p duidui_db < docs/init_permissions.sql +``` + +或者在 MySQL 客户端中执行: + +```sql +source docs/init_permissions.sql; +``` + +## 权限代码格式 + +权限代码采用 `资源:操作` 的格式,例如: +- `admin:user:create` - 管理员模块的用户创建操作 +- `camp:camp:read` - 打卡营模块的打卡营查看操作 +- `question:paper:update` - 题目模块的试卷更新操作 + +## 注意事项 + +1. 脚本使用 `ON DUPLICATE KEY UPDATE`,如果权限已存在,会更新其信息 +2. 权限 ID 从 1001 开始,按模块分段分配: + - 1000-1999: 管理员管理 + - 2000-2999: 打卡营管理 + - 3000-3999: 订单管理 + - 4000-4999: 题目试卷管理 + - 5000-5999: 统计 + - 6000-6999: OSS + - 7000-7999: 支付 + - 8000-8999: 调度器 +3. 执行脚本前请确保数据库连接配置正确 +4. 建议在首次部署或权限结构变更时执行此脚本 + +## 后续操作 + +权限初始化完成后,可以: +1. 创建角色并分配权限 +2. 为管理员用户分配角色 +3. 在路由中使用权限中间件进行权限控制 + diff --git a/scripts/init_permissions.go b/scripts/init_permissions.go new file mode 100644 index 0000000..b6b05c8 --- /dev/null +++ b/scripts/init_permissions.go @@ -0,0 +1,188 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "time" + + _ "github.com/go-sql-driver/mysql" + "gopkg.in/yaml.v3" +) + +// Config 配置结构 +type Config struct { + MySQL struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Database string `yaml:"database"` + Charset string `yaml:"charset"` + } `yaml:"mysql"` +} + +// Permission 权限结构 +type Permission struct { + ID int64 + Name string + Code string + Resource string + Action string + Description string +} + +// 权限列表 +var permissions = []Permission{ + // 管理员管理权限 + {ID: 1001, Name: "创建管理员", Code: "admin:user:create", Resource: "admin", Action: "create", Description: "创建管理员用户"}, + {ID: 1002, Name: "查看管理员", Code: "admin:user:read", Resource: "admin", Action: "read", Description: "查看管理员用户列表和详情"}, + {ID: 1003, Name: "更新管理员", Code: "admin:user:update", Resource: "admin", Action: "update", Description: "更新管理员用户信息"}, + {ID: 1004, Name: "删除管理员", Code: "admin:user:delete", Resource: "admin", Action: "delete", Description: "删除管理员用户"}, + {ID: 1005, Name: "创建角色", Code: "admin:role:create", Resource: "admin", Action: "create", Description: "创建角色"}, + {ID: 1006, Name: "查看角色", Code: "admin:role:read", Resource: "admin", Action: "read", Description: "查看角色列表和详情"}, + {ID: 1007, Name: "更新角色", Code: "admin:role:update", Resource: "admin", Action: "update", Description: "更新角色信息"}, + {ID: 1008, Name: "删除角色", Code: "admin:role:delete", Resource: "admin", Action: "delete", Description: "删除角色"}, + {ID: 1009, Name: "设置角色权限", Code: "admin:role:permission", Resource: "admin", Action: "update", Description: "设置角色的权限"}, + {ID: 1010, Name: "创建权限", Code: "admin:permission:create", Resource: "admin", Action: "create", Description: "创建权限"}, + {ID: 1011, Name: "查看权限", Code: "admin:permission:read", Resource: "admin", Action: "read", Description: "查看权限列表和详情"}, + {ID: 1012, Name: "更新权限", Code: "admin:permission:update", Resource: "admin", Action: "update", Description: "更新权限信息"}, + {ID: 1013, Name: "删除权限", Code: "admin:permission:delete", Resource: "admin", Action: "delete", Description: "删除权限"}, + + // 打卡营管理权限 + {ID: 2001, Name: "创建分类", Code: "camp:category:create", Resource: "camp", Action: "create", Description: "创建打卡营分类"}, + {ID: 2002, Name: "查看分类", Code: "camp:category:read", Resource: "camp", Action: "read", Description: "查看打卡营分类列表和详情"}, + {ID: 2003, Name: "更新分类", Code: "camp:category:update", Resource: "camp", Action: "update", Description: "更新打卡营分类信息"}, + {ID: 2004, Name: "删除分类", Code: "camp:category:delete", Resource: "camp", Action: "delete", Description: "删除打卡营分类"}, + {ID: 2005, Name: "创建打卡营", Code: "camp:camp:create", Resource: "camp", Action: "create", Description: "创建打卡营"}, + {ID: 2006, Name: "查看打卡营", Code: "camp:camp:read", Resource: "camp", Action: "read", Description: "查看打卡营列表和详情"}, + {ID: 2007, Name: "更新打卡营", Code: "camp:camp:update", Resource: "camp", Action: "update", Description: "更新打卡营信息"}, + {ID: 2008, Name: "删除打卡营", Code: "camp:camp:delete", Resource: "camp", Action: "delete", Description: "删除打卡营"}, + {ID: 2009, Name: "创建小节", Code: "camp:section:create", Resource: "camp", Action: "create", Description: "创建打卡营小节"}, + {ID: 2010, Name: "查看小节", Code: "camp:section:read", Resource: "camp", Action: "read", Description: "查看打卡营小节列表和详情"}, + {ID: 2011, Name: "更新小节", Code: "camp:section:update", Resource: "camp", Action: "update", Description: "更新打卡营小节信息"}, + {ID: 2012, Name: "删除小节", Code: "camp:section:delete", Resource: "camp", Action: "delete", Description: "删除打卡营小节"}, + {ID: 2013, Name: "创建任务", Code: "camp:task:create", Resource: "camp", Action: "create", Description: "创建打卡营任务"}, + {ID: 2014, Name: "查看任务", Code: "camp:task:read", Resource: "camp", Action: "read", Description: "查看打卡营任务列表和详情"}, + {ID: 2015, Name: "更新任务", Code: "camp:task:update", Resource: "camp", Action: "update", Description: "更新打卡营任务信息"}, + {ID: 2016, Name: "删除任务", Code: "camp:task:delete", Resource: "camp", Action: "delete", Description: "删除打卡营任务"}, + {ID: 2017, Name: "查看用户进度", Code: "camp:progress:read", Resource: "camp", Action: "read", Description: "查看用户打卡营进度"}, + {ID: 2018, Name: "更新用户进度", Code: "camp:progress:update", Resource: "camp", Action: "update", Description: "更新用户打卡营进度"}, + {ID: 2019, Name: "重置任务进度", Code: "camp:progress:reset", Resource: "camp", Action: "delete", Description: "重置用户任务进度"}, + {ID: 2020, Name: "查看用户打卡营", Code: "camp:user-camp:read", Resource: "camp", Action: "read", Description: "查看用户打卡营列表和状态"}, + {ID: 2021, Name: "加入打卡营", Code: "camp:user-camp:join", Resource: "camp", Action: "create", Description: "用户加入打卡营"}, + {ID: 2022, Name: "重置打卡营进度", Code: "camp:user-camp:reset", Resource: "camp", Action: "delete", Description: "重置用户打卡营进度"}, + + // 订单管理权限 + {ID: 3001, Name: "创建订单", Code: "order:create", Resource: "order", Action: "create", Description: "创建订单"}, + {ID: 3002, Name: "查看订单", Code: "order:read", Resource: "order", Action: "read", Description: "查看订单列表和详情"}, + {ID: 3003, Name: "更新订单状态", Code: "order:update", Resource: "order", Action: "update", Description: "更新订单状态"}, + + // 题目试卷管理权限 + {ID: 4001, Name: "创建题目", Code: "question:create", Resource: "question", Action: "create", Description: "创建题目"}, + {ID: 4002, Name: "查看题目", Code: "question:read", Resource: "question", Action: "read", Description: "查看题目列表和详情"}, + {ID: 4003, Name: "更新题目", Code: "question:update", Resource: "question", Action: "update", Description: "更新题目信息"}, + {ID: 4004, Name: "删除题目", Code: "question:delete", Resource: "question", Action: "delete", Description: "删除题目"}, + {ID: 4005, Name: "批量删除题目", Code: "question:batch_delete", Resource: "question", Action: "delete", Description: "批量删除题目"}, + {ID: 4006, Name: "创建试卷", Code: "paper:create", Resource: "question", Action: "create", Description: "创建试卷"}, + {ID: 4007, Name: "查看试卷", Code: "paper:read", Resource: "question", Action: "read", Description: "查看试卷列表和详情"}, + {ID: 4008, Name: "更新试卷", Code: "paper:update", Resource: "question", Action: "update", Description: "更新试卷信息"}, + {ID: 4009, Name: "删除试卷", Code: "paper:delete", Resource: "question", Action: "delete", Description: "删除试卷"}, + {ID: 4010, Name: "批量删除试卷", Code: "paper:batch_delete", Resource: "question", Action: "delete", Description: "批量删除试卷"}, + {ID: 4011, Name: "添加题目到试卷", Code: "paper:add_question", Resource: "question", Action: "update", Description: "添加题目到试卷"}, + {ID: 4012, Name: "从试卷移除题目", Code: "paper:remove_question", Resource: "question", Action: "update", Description: "从试卷移除题目"}, + {ID: 4013, Name: "创建答题记录", Code: "answer_record:create", Resource: "question", Action: "create", Description: "创建答题记录"}, + {ID: 4014, Name: "查看答题记录", Code: "answer_record:read", Resource: "question", Action: "read", Description: "查看答题记录列表和详情"}, + {ID: 4015, Name: "删除答题记录", Code: "answer_record:delete", Resource: "question", Action: "delete", Description: "删除答题记录"}, + {ID: 4016, Name: "查看答题统计", Code: "answer_record:statistics", Resource: "question", Action: "read", Description: "查看答题统计数据"}, + + // 统计权限 + {ID: 5001, Name: "查看仪表盘统计", Code: "statistics:dashboard:read", Resource: "statistics", Action: "read", Description: "查看仪表盘统计数据"}, + + // OSS权限 + {ID: 6001, Name: "获取OSS上传凭证", Code: "oss:upload:signature", Resource: "oss", Action: "read", Description: "获取OSS上传凭证"}, + + // 支付权限 + {ID: 7001, Name: "创建支付订单", Code: "payment:wechat:create", Resource: "payment", Action: "create", Description: "创建微信支付订单"}, + {ID: 7002, Name: "处理支付通知", Code: "payment:wechat:notify", Resource: "payment", Action: "update", Description: "处理微信支付通知回调"}, + + // 调度器权限 + {ID: 8001, Name: "添加调度任务", Code: "scheduler:task:create", Resource: "scheduler", Action: "create", Description: "添加调度器任务"}, + {ID: 8002, Name: "查看调度任务", Code: "scheduler:task:read", Resource: "scheduler", Action: "read", Description: "查看调度器任务列表和状态"}, + {ID: 8003, Name: "删除调度任务", Code: "scheduler:task:delete", Resource: "scheduler", Action: "delete", Description: "删除调度器任务"}, +} + +func main() { + // 读取配置文件 + configPath := "config.yaml" + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + configData, err := os.ReadFile(configPath) + if err != nil { + log.Fatalf("读取配置文件失败: %v", err) + } + + var config Config + if err := yaml.Unmarshal(configData, &config); err != nil { + log.Fatalf("解析配置文件失败: %v", err) + } + + // 构建数据库连接字符串 + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", + config.MySQL.Username, + config.MySQL.Password, + config.MySQL.Host, + config.MySQL.Port, + config.MySQL.Database, + config.MySQL.Charset, + ) + + // 连接数据库 + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + defer db.Close() + + // 测试连接 + if err := db.Ping(); err != nil { + log.Fatalf("数据库连接测试失败: %v", err) + } + + log.Println("✅ 数据库连接成功") + + // 插入权限 + now := time.Now() + insertSQL := `INSERT INTO admin_permissions (id, name, code, resource, action, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + code = VALUES(code), + resource = VALUES(resource), + action = VALUES(action), + description = VALUES(description), + updated_at = VALUES(updated_at)` + + stmt, err := db.Prepare(insertSQL) + if err != nil { + log.Fatalf("准备SQL语句失败: %v", err) + } + defer stmt.Close() + + successCount := 0 + for _, perm := range permissions { + _, err := stmt.Exec(perm.ID, perm.Name, perm.Code, perm.Resource, perm.Action, perm.Description, now, now) + if err != nil { + log.Printf("❌ 插入权限失败 [%s]: %v", perm.Code, err) + } else { + successCount++ + log.Printf("✅ 权限已初始化: %s - %s", perm.Code, perm.Name) + } + } + + log.Printf("\n🎉 权限初始化完成!成功: %d/%d", successCount, len(permissions)) +} + diff --git a/storage/1503017331_20251231_cert/apiclient_cert.p12 b/storage/1503017331_20251231_cert/apiclient_cert.p12 new file mode 100644 index 0000000..609ff51 Binary files /dev/null and b/storage/1503017331_20251231_cert/apiclient_cert.p12 differ diff --git a/storage/1503017331_20251231_cert/apiclient_cert.pem b/storage/1503017331_20251231_cert/apiclient_cert.pem new file mode 100644 index 0000000..aad31e6 --- /dev/null +++ b/storage/1503017331_20251231_cert/apiclient_cert.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEKDCCAxCgAwIBAgIUGmrNPydjqBI3EpTJOWYLLvPhcHYwDQYJKoZIhvcNAQEL +BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT +FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg +Q0EwHhcNMjUxMjMxMDgyMzQzWhcNMzAxMjMwMDgyMzQzWjCBgTETMBEGA1UEAwwK +MTUwMzAxNzMzMTEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMS0wKwYDVQQL +DCTmtY7ljZfmgLzmgLzmlZnogrLnp5HmioDmnInpmZDlhazlj7gxCzAJBgNVBAYT +AkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBANUNbBCZRnweMhwQXrervK0j0AQlAU54hW3vPr2j6lXESKdAyzrKOW+i +RPJjNxSCc4cjbdXscFoMyuGHjMy5vzMB5BFV8l/9S4CQ0cv1B2AkaeOYGKfellkz +cbE9OtTdtw1KYiu+KxLtZEZFFfU/r0lO6n7lvzxisRYfpM+bR5+594DJ+52Ddc6f +O4DArT1iIp5twNZGM/1TQNe1WFRZL1fvHMqTJmjSWul/3roe97GUImge9DU8OI4g +pTZohEMD1rpocjoNbdUUtRQxWm9wTPh1AiheReHinnZEzOMfj6crxccQtJ2ZU2hB +Jd8wgkNaLZmtBy85U43GjVDFYb8o4FECAwEAAaOBuTCBtjAJBgNVHRMEAjAAMAsG +A1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2Y2Eu +aXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJDMDRC +MDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJCMjdB +OUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQBKaAPX +XTskyRFZaL0rIHdrHX9m2oGhzq2I2T46JL0sE9YWHKJBw6wu4z2w8CI5Zmjn7CZh +Wa9YX4+p1mhIOOrQP/f6ftU1IcHlkBD4AlWZugFNDyo8UuV1SbNcg4BEwv/n3RiE +c7vyZIztlS0VGB89VQ1OgphSKtTFkeGS93XbDyAH0z+fUB9+aRwj3edjWriRyf2z +OCvv6SRVapxW2SOXiqNcpVbxjegsx1Le+6t7RaygQJUP8vtiiKkh+Hq6FIkFDcHX +HB843/b0eoofIgoFCYC5VpNZ7F/2LqWkn5KH5NUR6/ifzqUqs11eec7t98Rg0iRD +UKebS7c1JDYtF+eX +-----END CERTIFICATE----- diff --git a/storage/1503017331_20251231_cert/apiclient_key.pem b/storage/1503017331_20251231_cert/apiclient_key.pem new file mode 100644 index 0000000..3c1c949 --- /dev/null +++ b/storage/1503017331_20251231_cert/apiclient_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDVDWwQmUZ8HjIc +EF63q7ytI9AEJQFOeIVt7z69o+pVxEinQMs6yjlvokTyYzcUgnOHI23V7HBaDMrh +h4zMub8zAeQRVfJf/UuAkNHL9QdgJGnjmBin3pZZM3GxPTrU3bcNSmIrvisS7WRG +RRX1P69JTup+5b88YrEWH6TPm0efufeAyfudg3XOnzuAwK09YiKebcDWRjP9U0DX +tVhUWS9X7xzKkyZo0lrpf966HvexlCJoHvQ1PDiOIKU2aIRDA9a6aHI6DW3VFLUU +MVpvcEz4dQIoXkXh4p52RMzjH4+nK8XHELSdmVNoQSXfMIJDWi2ZrQcvOVONxo1Q +xWG/KOBRAgMBAAECggEAIvGRoONS4Taep2Wz81ISnx85lgRvw2wXDmHoG3iQDcMk +23HQI3NZmkq2Hj9RoGaJBkg0Upr2Dn78o6L03/szNe2Ad6tuFnpX8N1P27Dzpbwz +NeYTXS0v4a+DXTuas6Etzds+YMhPDkqrYK8iG9h3KoHsCiYqiH/zZZqJEJf6VmMA +bfCz3+jFVKkKlW5e7DanjjzN+qUaB9TUoOpzgyWck/b99UJXy4K8WXfvw2tZj20c +hKi4uWXnNzpPT3knSKYDH4KJQBBIElrIsoXS4QXhNigGJSaSCkB6MP4s7wONZVmZ +OijFd2ieD08sRlzfnfAdkLpYunOYywDhPDo7ZAD21QKBgQDq3PfYp9DYUsZ8Ir87 +cOMj34OiZnEmWq2NeV9C5N3GU3ScRHEuyAooiZexhGRROT8iRfhANkv+MxUc58VU +pkhaFnwty38zQjyIKonFKGgiWeXKzNNyu+ZnOy080ry9btHfRLAePWpz4APjJ5tK +Mv/Av++fChJo8DXI3ytbefpDNwKBgQDoOfRvrk4nIYbisvuZ5C3uLBpjnLMxxBy/ +Kd4KQF7oqI7wKw1vvIn+BZbWv6tupnjGEzZ11TKQr6bA4DDZsC7htCZcVDneY8+Z +spphH6tlnHU/2xWrJDXjULVYvAqYqKUAvJWEDooN2zuswfytSqNEUbQ6CrVU0kEx +HUgCvWXMtwKBgQCCcwNSmjtcu/U049PVvyjaNv6VSFMWm40EJGLt89LeomIFndpD +wqYpx+qylbdmieZwMe7mM4JYCaVzbaRkFQvgxdZpVTssjGC0vPPSx1O3qLkCwGu9 +sXIS6oKA4wgkK5Z0bWFpGnGzNLzUAZ62QsddFv6QFncNREaLcLFTWNfRVwKBgQCs +kayodVIUWCDBRBoeEOdkzxdJIMA04jQuhnE/Evi0UdXueT/B4cx1nTerG8HMNx8W +ql5VD/pEdJMpTzBeBEPCa7n58IkUTv2NjKCWPg+DMdIbgrXGeOEmq+onJ42ERgZf +1sQQ3zEN/PWKeplHOWi8My8H1r9LafcSBV1m09HbXwKBgQDIaqNDXbeOkXbCz4eg +crruIsYMrPm0Wc+udWa+UAHAdQzm1NgQbdu8Bmqt7g/WXc8sWmJD0wzkfzsoQzHE +oMUakSK0yljIe9Sl0E2Xq98jvFDCg+IynvV6Xdc3OLUKrtvFzo1zUX1PBcENqqtP +fZ0zcJ9Sky054PzAky3uQ/7KMQ== +-----END PRIVATE KEY----- diff --git a/storage/payment_notify/notify_20260105_234712_raw.json b/storage/payment_notify/notify_20260105_234712_raw.json new file mode 100644 index 0000000..f054a3a --- /dev/null +++ b/storage/payment_notify/notify_20260105_234712_raw.json @@ -0,0 +1,23 @@ +{ + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip", + "Cache-Control": "no-cache", + "Content-Length": "907", + "Content-Type": "application/json", + "Host": "c7b2eea80773.ngrok-free.app", + "Pragma": "no-cache", + "User-Agent": "Mozilla/4.0", + "Wechatpay-Nonce": "uyg9Tt7GE8ZYxC0vs57O7mzojMMTdK4e", + "Wechatpay-Serial": "269AD56F2A9C0A87E64915D84E5C58F8C63015D8", + "Wechatpay-Signature": "KiGVJ9kTKlxz08tCaZSDiAzax1LV2LKXM7am0P7t21Tj/PqL/B77czZe8eDHkov6aTdkis2B9XgFQzNKhJgfBBDLIPigJAVFxNAspTkWDiws/+vRpSfB+hPevFoo/r+LsVBSPvk5c8Z76ct12cejtwOE4Hd6jfLduI+8dlBeXQDPBfC2WY4ooPvjdRN9/LewIFLKyuqtLS5Tfs1sLCUZ4nZDH2HMAZLhEs7fo6x5HJjNgxgdMTWoF3pSbsnkpAtEQmVl5ZygNRKVFfbbBWzJ9gaRSSdCKH1LXnKkaKgbIXmkAsE9yaafblhevKh9GB7v2uYznD/bMRA5v4sXXlSaZQ==", + "Wechatpay-Signature-Type": "WECHATPAY2-SHA256-RSA2048", + "Wechatpay-Timestamp": "1767628031", + "X-Forwarded-For": "101.32.203.103", + "X-Forwarded-Host": "c7b2eea80773.ngrok-free.app", + "X-Forwarded-Proto": "https" + }, + "raw_body": "{\"id\":\"f7e8bc8d-6035-5132-83a5-51f49b27f95f\",\"create_time\":\"2026-01-05T23:47:11+08:00\",\"resource_type\":\"encrypt-resource\",\"event_type\":\"TRANSACTION.SUCCESS\",\"summary\":\"支付成功\",\"resource\":{\"original_type\":\"transaction\",\"algorithm\":\"AEAD_AES_256_GCM\",\"ciphertext\":\"8rszLxEQebcH6PZ+d9zK6/F6JEN5hTowKBw3I4kKYamyOPPrKo/FtCIb2iwoFTRa4253W68mIltXPFaMj8tTunPlUPRctXSqftdO3NxEQ3Wtc6n0eq7jeDD7t4kUsWvkc7uMLxCl+f+GOfa4TirmD+H/fewymQ2HUVBBpyi7EdJcHaRVg1Wv3rKxw/tXj8rBrplpLY9ZiD/pWyLM41PdsRfJvS5zQAhwsOJNUFsHCPso3smXkqNXkswPiiu54SanzxXi0U933qosEhpt9nkQnz/PGM7Td4G+2qD30LUje+Sst4LiPxFk3Wa3A2YYMqhLpAcB6x0r0ZSpFbDcdaZ4qPmDM0KDYlxVHWWtCseLsMUi1lwwwrV0/uPu8sGrxPQOc5gu863hsjjWk+U4a4ehDbhB5BlJnCHvbiNyJoc0FNgASaqSw6CmNam3F2CsFFOSKKpNa5KtaGxgTfENW0fq5T8pfk8A3PL64rNJWyXbcHVyokOZTE0ER7r3OuIqBg9fygdVqcW64jsNX+1fD2kiqlbNOoWnWc+uAyGKuJV8iR3mBd9251mUDh3T/55zyj1ePvw=\",\"associated_data\":\"transaction\",\"nonce\":\"Cdg6aHp0yju5\"}}", + "status": "raw", + "timestamp": "2026-01-05 23:47:12" +} \ No newline at end of file diff --git a/storage/payment_notify/notify_20260105_234712_success.json b/storage/payment_notify/notify_20260105_234712_success.json new file mode 100644 index 0000000..cd18006 --- /dev/null +++ b/storage/payment_notify/notify_20260105_234712_success.json @@ -0,0 +1,39 @@ +{ + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip", + "Cache-Control": "no-cache", + "Content-Length": "907", + "Content-Type": "application/json", + "Host": "c7b2eea80773.ngrok-free.app", + "Pragma": "no-cache", + "User-Agent": "Mozilla/4.0", + "Wechatpay-Nonce": "uyg9Tt7GE8ZYxC0vs57O7mzojMMTdK4e", + "Wechatpay-Serial": "269AD56F2A9C0A87E64915D84E5C58F8C63015D8", + "Wechatpay-Signature": "KiGVJ9kTKlxz08tCaZSDiAzax1LV2LKXM7am0P7t21Tj/PqL/B77czZe8eDHkov6aTdkis2B9XgFQzNKhJgfBBDLIPigJAVFxNAspTkWDiws/+vRpSfB+hPevFoo/r+LsVBSPvk5c8Z76ct12cejtwOE4Hd6jfLduI+8dlBeXQDPBfC2WY4ooPvjdRN9/LewIFLKyuqtLS5Tfs1sLCUZ4nZDH2HMAZLhEs7fo6x5HJjNgxgdMTWoF3pSbsnkpAtEQmVl5ZygNRKVFfbbBWzJ9gaRSSdCKH1LXnKkaKgbIXmkAsE9yaafblhevKh9GB7v2uYznD/bMRA5v4sXXlSaZQ==", + "Wechatpay-Signature-Type": "WECHATPAY2-SHA256-RSA2048", + "Wechatpay-Timestamp": "1767628031", + "X-Forwarded-For": "101.32.203.103", + "X-Forwarded-Host": "c7b2eea80773.ngrok-free.app", + "X-Forwarded-Proto": "https" + }, + "parsed_request": { + "id": "f7e8bc8d-6035-5132-83a5-51f49b27f95f", + "create_time": "2026-01-05T23:47:11+08:00", + "event_type": "TRANSACTION.SUCCESS", + "resource_type": "encrypt-resource", + "resource": { + "algorithm": "AEAD_AES_256_GCM", + "ciphertext": "8rszLxEQebcH6PZ+d9zK6/F6JEN5hTowKBw3I4kKYamyOPPrKo/FtCIb2iwoFTRa4253W68mIltXPFaMj8tTunPlUPRctXSqftdO3NxEQ3Wtc6n0eq7jeDD7t4kUsWvkc7uMLxCl+f+GOfa4TirmD+H/fewymQ2HUVBBpyi7EdJcHaRVg1Wv3rKxw/tXj8rBrplpLY9ZiD/pWyLM41PdsRfJvS5zQAhwsOJNUFsHCPso3smXkqNXkswPiiu54SanzxXi0U933qosEhpt9nkQnz/PGM7Td4G+2qD30LUje+Sst4LiPxFk3Wa3A2YYMqhLpAcB6x0r0ZSpFbDcdaZ4qPmDM0KDYlxVHWWtCseLsMUi1lwwwrV0/uPu8sGrxPQOc5gu863hsjjWk+U4a4ehDbhB5BlJnCHvbiNyJoc0FNgASaqSw6CmNam3F2CsFFOSKKpNa5KtaGxgTfENW0fq5T8pfk8A3PL64rNJWyXbcHVyokOZTE0ER7r3OuIqBg9fygdVqcW64jsNX+1fD2kiqlbNOoWnWc+uAyGKuJV8iR3mBd9251mUDh3T/55zyj1ePvw=", + "associated_data": "transaction", + "nonce": "Cdg6aHp0yju5", + "original_type": "transaction", + "Plaintext": "{\"mchid\":\"1503017331\",\"appid\":\"wx583d78cc00f29dc1\",\"out_trade_no\":\"8928251364933373952\",\"transaction_id\":\"4200002906202601059189936554\",\"trade_type\":\"JSAPI\",\"trade_state\":\"SUCCESS\",\"trade_state_desc\":\"支付成功\",\"bank_type\":\"OTHERS\",\"attach\":\"\",\"success_time\":\"2026-01-05T23:47:11+08:00\",\"payer\":{\"openid\":\"osAtG49p2u1_hB3cqho5e6RBYtPw\"},\"amount\":{\"total\":1,\"payer_total\":1,\"currency\":\"CNY\",\"payer_currency\":\"CNY\"}}" + }, + "summary": "支付成功", + "RawRequest": null + }, + "raw_body": "{\"id\":\"f7e8bc8d-6035-5132-83a5-51f49b27f95f\",\"create_time\":\"2026-01-05T23:47:11+08:00\",\"resource_type\":\"encrypt-resource\",\"event_type\":\"TRANSACTION.SUCCESS\",\"summary\":\"支付成功\",\"resource\":{\"original_type\":\"transaction\",\"algorithm\":\"AEAD_AES_256_GCM\",\"ciphertext\":\"8rszLxEQebcH6PZ+d9zK6/F6JEN5hTowKBw3I4kKYamyOPPrKo/FtCIb2iwoFTRa4253W68mIltXPFaMj8tTunPlUPRctXSqftdO3NxEQ3Wtc6n0eq7jeDD7t4kUsWvkc7uMLxCl+f+GOfa4TirmD+H/fewymQ2HUVBBpyi7EdJcHaRVg1Wv3rKxw/tXj8rBrplpLY9ZiD/pWyLM41PdsRfJvS5zQAhwsOJNUFsHCPso3smXkqNXkswPiiu54SanzxXi0U933qosEhpt9nkQnz/PGM7Td4G+2qD30LUje+Sst4LiPxFk3Wa3A2YYMqhLpAcB6x0r0ZSpFbDcdaZ4qPmDM0KDYlxVHWWtCseLsMUi1lwwwrV0/uPu8sGrxPQOc5gu863hsjjWk+U4a4ehDbhB5BlJnCHvbiNyJoc0FNgASaqSw6CmNam3F2CsFFOSKKpNa5KtaGxgTfENW0fq5T8pfk8A3PL64rNJWyXbcHVyokOZTE0ER7r3OuIqBg9fygdVqcW64jsNX+1fD2kiqlbNOoWnWc+uAyGKuJV8iR3mBd9251mUDh3T/55zyj1ePvw=\",\"associated_data\":\"transaction\",\"nonce\":\"Cdg6aHp0yju5\"}}", + "status": "success", + "timestamp": "2026-01-05 23:47:12" +} \ No newline at end of file diff --git a/storage/payment_notify/notify_20260106_000832_raw.json b/storage/payment_notify/notify_20260106_000832_raw.json new file mode 100644 index 0000000..90943e8 --- /dev/null +++ b/storage/payment_notify/notify_20260106_000832_raw.json @@ -0,0 +1,23 @@ +{ + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip", + "Cache-Control": "no-cache", + "Content-Length": "907", + "Content-Type": "application/json", + "Host": "c7b2eea80773.ngrok-free.app", + "Pragma": "no-cache", + "User-Agent": "Mozilla/4.0", + "Wechatpay-Nonce": "cPM6DuA5Dn1OaMy3d3eIJP7OVsxVkd6j", + "Wechatpay-Serial": "269AD56F2A9C0A87E64915D84E5C58F8C63015D8", + "Wechatpay-Signature": "a6DvlsLDxTS7MMAUAVaoBRbojYCDcLpKdZVnYBOAXtDPZzj3tRS2Zsg58lCoiek2aZT+PtqRaFY4BMgPq4DisU7Cws7Y8ydP0MPsGR/UDago9miRr56Rq28QPnIx2OBORMBi53ggP4203k1Ri+wupV9G050DvhR4Xd6xs8zyL48HwhFnkRbALsp7vK9tlJeNCs7YWaxz0uwE47UZN09woAp+apC78OCQlO2SQwzEn48HHSePmHpuHr0deFyc8wPwejA9bhP5xZAJ+72AP5pzvmoJBQpfPfgMGLLx+lVmDhhZfYKhhzOmKLA+acyAascIwHZe4LsLE6eOwGQn7Z4YAw==", + "Wechatpay-Signature-Type": "WECHATPAY2-SHA256-RSA2048", + "Wechatpay-Timestamp": "1767629311", + "X-Forwarded-For": "101.32.203.103", + "X-Forwarded-Host": "c7b2eea80773.ngrok-free.app", + "X-Forwarded-Proto": "https" + }, + "raw_body": "{\"id\":\"4749c1c3-87e7-5047-ba00-f56552a521ba\",\"create_time\":\"2026-01-06T00:08:31+08:00\",\"resource_type\":\"encrypt-resource\",\"event_type\":\"TRANSACTION.SUCCESS\",\"summary\":\"支付成功\",\"resource\":{\"original_type\":\"transaction\",\"algorithm\":\"AEAD_AES_256_GCM\",\"ciphertext\":\"NTKNlC2ckVc/Rouz+NrfidjphTbUgNAIHqsXNo9M88Innz/JohXOVUIXPW0yHrYdFAJU9Obo9EZAodGLDuGblEuKt7UaK0kyYTrSKQrp82zhGZaYpoEDAhKesu2VfBdxp+2FN3Og8vl2BeQH6dKF/yvKYLfKttljvF+AV5sdt8vroaqOYJgA5tEXpiH5o6t1ZHlwagy1GuR54z/siu9pKyJRRMCxWtqVHWAoZgjXZ/bHleWqTSKHuqcHLB/mPZcxKvmbqn3GKi9P+nzf7LwDbA4tdc181UG9wJHOZnhdFcnhR/zUqQkkeHIF7VW/rtmuFT+vcTuy585FHq57pxihhIyMPmtN2C7hp2KtJsVwMkRFsyALAUXBha812rRPzy/F1ZjFXUMAbljHBRPWrqmokv22rdjL8p/kYk1oK76wnpr09E0sYA70Yoca5MDqPLB3NOaNEG0ynKQBcz+x+SsYHGflcB9XYduGqE6Tl2QlktOkcyoSekfse9eoNwXeFqiHDJw7Yq9dBQqqmKJZAsQRMPFaNC8SJ4KhrkR6GgpDL4Zx9s96zakmDIHAjzJC/P8m0vg=\",\"associated_data\":\"transaction\",\"nonce\":\"j0OZAgOwYkTv\"}}", + "status": "raw", + "timestamp": "2026-01-06 00:08:32" +} \ No newline at end of file diff --git a/storage/payment_notify/notify_20260106_000832_success.json b/storage/payment_notify/notify_20260106_000832_success.json new file mode 100644 index 0000000..fe7bbd4 --- /dev/null +++ b/storage/payment_notify/notify_20260106_000832_success.json @@ -0,0 +1,39 @@ +{ + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip", + "Cache-Control": "no-cache", + "Content-Length": "907", + "Content-Type": "application/json", + "Host": "c7b2eea80773.ngrok-free.app", + "Pragma": "no-cache", + "User-Agent": "Mozilla/4.0", + "Wechatpay-Nonce": "cPM6DuA5Dn1OaMy3d3eIJP7OVsxVkd6j", + "Wechatpay-Serial": "269AD56F2A9C0A87E64915D84E5C58F8C63015D8", + "Wechatpay-Signature": "a6DvlsLDxTS7MMAUAVaoBRbojYCDcLpKdZVnYBOAXtDPZzj3tRS2Zsg58lCoiek2aZT+PtqRaFY4BMgPq4DisU7Cws7Y8ydP0MPsGR/UDago9miRr56Rq28QPnIx2OBORMBi53ggP4203k1Ri+wupV9G050DvhR4Xd6xs8zyL48HwhFnkRbALsp7vK9tlJeNCs7YWaxz0uwE47UZN09woAp+apC78OCQlO2SQwzEn48HHSePmHpuHr0deFyc8wPwejA9bhP5xZAJ+72AP5pzvmoJBQpfPfgMGLLx+lVmDhhZfYKhhzOmKLA+acyAascIwHZe4LsLE6eOwGQn7Z4YAw==", + "Wechatpay-Signature-Type": "WECHATPAY2-SHA256-RSA2048", + "Wechatpay-Timestamp": "1767629311", + "X-Forwarded-For": "101.32.203.103", + "X-Forwarded-Host": "c7b2eea80773.ngrok-free.app", + "X-Forwarded-Proto": "https" + }, + "parsed_request": { + "id": "4749c1c3-87e7-5047-ba00-f56552a521ba", + "create_time": "2026-01-06T00:08:31+08:00", + "event_type": "TRANSACTION.SUCCESS", + "resource_type": "encrypt-resource", + "resource": { + "algorithm": "AEAD_AES_256_GCM", + "ciphertext": "NTKNlC2ckVc/Rouz+NrfidjphTbUgNAIHqsXNo9M88Innz/JohXOVUIXPW0yHrYdFAJU9Obo9EZAodGLDuGblEuKt7UaK0kyYTrSKQrp82zhGZaYpoEDAhKesu2VfBdxp+2FN3Og8vl2BeQH6dKF/yvKYLfKttljvF+AV5sdt8vroaqOYJgA5tEXpiH5o6t1ZHlwagy1GuR54z/siu9pKyJRRMCxWtqVHWAoZgjXZ/bHleWqTSKHuqcHLB/mPZcxKvmbqn3GKi9P+nzf7LwDbA4tdc181UG9wJHOZnhdFcnhR/zUqQkkeHIF7VW/rtmuFT+vcTuy585FHq57pxihhIyMPmtN2C7hp2KtJsVwMkRFsyALAUXBha812rRPzy/F1ZjFXUMAbljHBRPWrqmokv22rdjL8p/kYk1oK76wnpr09E0sYA70Yoca5MDqPLB3NOaNEG0ynKQBcz+x+SsYHGflcB9XYduGqE6Tl2QlktOkcyoSekfse9eoNwXeFqiHDJw7Yq9dBQqqmKJZAsQRMPFaNC8SJ4KhrkR6GgpDL4Zx9s96zakmDIHAjzJC/P8m0vg=", + "associated_data": "transaction", + "nonce": "j0OZAgOwYkTv", + "original_type": "transaction", + "Plaintext": "{\"mchid\":\"1503017331\",\"appid\":\"wx583d78cc00f29dc1\",\"out_trade_no\":\"8933737605833428992\",\"transaction_id\":\"4200002900202601063889947166\",\"trade_type\":\"JSAPI\",\"trade_state\":\"SUCCESS\",\"trade_state_desc\":\"支付成功\",\"bank_type\":\"OTHERS\",\"attach\":\"\",\"success_time\":\"2026-01-06T00:08:31+08:00\",\"payer\":{\"openid\":\"osAtG49p2u1_hB3cqho5e6RBYtPw\"},\"amount\":{\"total\":1,\"payer_total\":1,\"currency\":\"CNY\",\"payer_currency\":\"CNY\"}}" + }, + "summary": "支付成功", + "RawRequest": null + }, + "raw_body": "{\"id\":\"4749c1c3-87e7-5047-ba00-f56552a521ba\",\"create_time\":\"2026-01-06T00:08:31+08:00\",\"resource_type\":\"encrypt-resource\",\"event_type\":\"TRANSACTION.SUCCESS\",\"summary\":\"支付成功\",\"resource\":{\"original_type\":\"transaction\",\"algorithm\":\"AEAD_AES_256_GCM\",\"ciphertext\":\"NTKNlC2ckVc/Rouz+NrfidjphTbUgNAIHqsXNo9M88Innz/JohXOVUIXPW0yHrYdFAJU9Obo9EZAodGLDuGblEuKt7UaK0kyYTrSKQrp82zhGZaYpoEDAhKesu2VfBdxp+2FN3Og8vl2BeQH6dKF/yvKYLfKttljvF+AV5sdt8vroaqOYJgA5tEXpiH5o6t1ZHlwagy1GuR54z/siu9pKyJRRMCxWtqVHWAoZgjXZ/bHleWqTSKHuqcHLB/mPZcxKvmbqn3GKi9P+nzf7LwDbA4tdc181UG9wJHOZnhdFcnhR/zUqQkkeHIF7VW/rtmuFT+vcTuy585FHq57pxihhIyMPmtN2C7hp2KtJsVwMkRFsyALAUXBha812rRPzy/F1ZjFXUMAbljHBRPWrqmokv22rdjL8p/kYk1oK76wnpr09E0sYA70Yoca5MDqPLB3NOaNEG0ynKQBcz+x+SsYHGflcB9XYduGqE6Tl2QlktOkcyoSekfse9eoNwXeFqiHDJw7Yq9dBQqqmKJZAsQRMPFaNC8SJ4KhrkR6GgpDL4Zx9s96zakmDIHAjzJC/P8m0vg=\",\"associated_data\":\"transaction\",\"nonce\":\"j0OZAgOwYkTv\"}}", + "status": "success", + "timestamp": "2026-01-06 00:08:32" +} \ No newline at end of file diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..902e547 --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main new file mode 100755 index 0000000..544ccaa Binary files /dev/null and b/tmp/main differ