first commit

This commit is contained in:
well 2026-03-27 10:34:03 +08:00
commit d9e575e853
129 changed files with 23279 additions and 0 deletions

45
.air.toml Normal file
View File

@ -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

604
.gitattributes vendored Normal file
View File

@ -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
# cubemapunity 贴图
*.[cC][uU][bB][eE][mM][aA][pP] filter=lfs diff=lfs merge=binary -text
# navmeshunity
*.[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

44
.gitignore vendored Normal file
View File

@ -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/

21
Dockerfile Normal file
View File

@ -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"]

106
INSTALL_MONGODB.md Normal file
View File

@ -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 实现优势
相比 MySQLMongoDB 在题库系统中的优势:
1. **文档模型**:天然支持 JSON题目、试卷等复杂结构更直观
2. **数组查询**:标签等数组字段查询更方便
3. **全文搜索**:内置文本索引,支持全文搜索
4. **灵活扩展**:无需预定义表结构,易于扩展
5. **性能优秀**:读性能好,适合读多写少的场景
## 七、注意事项
1. **全文搜索**MongoDB 的文本索引对中文支持有限,可能需要使用 bleve 等第三方库
2. **事务**MongoDB 4.0+ 支持事务,但性能不如 MySQL
3. **数据备份**:定期备份 MongoDB 数据

169
Makefile Normal file
View File

@ -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 "✅ 清理完成"

74
config.yaml Normal file
View File

@ -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" # 超级管理员邮箱

255
config/config.go Normal file
View File

@ -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")
}

107
deploy/README.md Normal file
View File

@ -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

171
deploy/deploy.sh Executable file
View File

@ -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 ""

View File

@ -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 '文件访问 URLOSS 或相对路径)',
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='文档文件';

View File

@ -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"]';

View File

@ -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 '客观题历史最高对应的总题数';

View File

@ -0,0 +1,5 @@
-- 任务表增加前置任务ID解锁关系需完成前置任务后才能开启本任务
-- 执行前请确认表名与数据库一致
ALTER TABLE camp_tasks
ADD COLUMN prerequisite_task_id VARCHAR(64) NULL DEFAULT NULL COMMENT '前置任务ID完成后才能开启本任务递进关系';

View File

@ -0,0 +1,5 @@
-- 打卡营任务表增加任务标题字段
-- 执行前请确认表名与数据库一致
ALTER TABLE camp_tasks
ADD COLUMN title VARCHAR(255) NULL DEFAULT '' COMMENT '任务标题(用于展示)';

65
go.mod Normal file
View File

@ -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
)

210
go.sum Normal file
View File

@ -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=

View File

@ -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("设置用户角色")
}

View File

@ -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("重置打卡营进度")
}
}

View File

@ -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("删除文档")
}

View File

@ -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("更新订单状态")
}

View File

@ -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上传凭证模拟")
}

View File

@ -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("支付通知回调")
}

View File

@ -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("获取资源列表")
}

View File

@ -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("删除知识树节点")
}
}

View File

@ -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("设置角色权限")
}

59
internal/admin/routes.go Normal file
View File

@ -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)
}

View File

@ -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("获取任务数量")
}

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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("获取仪表盘统计数据")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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": "设置用户角色成功",
})
}

View File

@ -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": "登出成功",
})
}

View File

@ -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,
})
}

View File

@ -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": "设置角色权限成功",
})
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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("检查用户打卡营状态")
}
}

View File

@ -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("自动关闭订单")
}

View File

@ -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上传凭证")
}

View File

@ -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("支付通知回调")
}

View File

@ -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) // 删除答题记录
}
}

39
internal/api/routes.go Normal file
View File

@ -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)
}

View File

@ -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("获取任务数量")
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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,
&sectionIDResult,
&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,
&sectionIDResult,
&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, &sectionID, &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
}

View File

@ -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
}

View File

@ -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(
&section.ID,
&section.CampID,
&section.Title,
&section.SectionNumber,
&section.PriceFen,
&section.RequirePreviousSection,
&timeIntervalTypeStr,
&section.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 &section, 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(
&section.ID,
&section.CampID,
&section.Title,
&section.SectionNumber,
&section.PriceFen,
&section.RequirePreviousSection,
&timeIntervalTypeStr,
&section.TimeIntervalValue,
&deletedAt,
)
if err != nil {
continue
}
section.TimeIntervalType = parseTimeIntervalType(timeIntervalTypeStr)
section.DeletedAt = utils.FormatNullTimeToStd(deletedAt)
sections = append(sections, &section)
}
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(&sectionID)
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
}
}

View File

@ -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,
&sectionID,
&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,
&sectionID,
&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
}

View File

@ -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, &currentSectionID)
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(&currentSectionID)
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
}

View File

@ -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
}

View File

@ -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_recommendedtrue/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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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)
}

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -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
}

View File

@ -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
}

636
internal/camp/types.go Normal file
View File

@ -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"`
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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})
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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, &sectionID); 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
}
}

View File

@ -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)
}

View File

@ -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
}

153
internal/order/types.go Normal file
View File

@ -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"`
}

43
internal/oss/handler.go Normal file
View File

@ -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)
}

197
internal/oss/service.go Normal file
View File

@ -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,
}
// 将凭证缓存到Redis1小时过期
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,
}
}

421
internal/payment/handler.go Normal file
View File

@ -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)
}

291
internal/payment/service.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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) // 获取完整树形结构
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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": "删除成功",
})
}

View File

@ -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": "删除成功",
})
}

View File

@ -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 字段
})
}

View File

@ -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,
})
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

Some files were not shown because too many files have changed in this diff Show More