first commit
This commit is contained in:
commit
d9e575e853
45
.air.toml
Normal file
45
.air.toml
Normal 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
604
.gitattributes
vendored
Normal 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
|
||||
# cubemap,unity 贴图
|
||||
*.[cC][uU][bB][eE][mM][aA][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# navmesh,unity
|
||||
*.[nN][aA][vV][mM][eE][sS][hH] filter=lfs diff=lfs merge=binary -text
|
||||
# osm,地理数据
|
||||
*.[oO][sS][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# hip, houdini
|
||||
*.[hH][iI][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# cdr
|
||||
*.[cC][dD][rR] filter=lfs diff=lfs merge=binary -text
|
||||
# raw
|
||||
*.[rR][aA][wW] filter=lfs diff=lfs merge=binary -text
|
||||
# dae
|
||||
*.[dD][aA][eE] filter=lfs diff=lfs merge=binary -text
|
||||
# hda, houdini
|
||||
*.[hH][dD][aA] filter=lfs diff=lfs merge=binary -text
|
||||
# geo, houdini
|
||||
*.[gG][eE][oO] filter=lfs diff=lfs merge=binary -text
|
||||
# bgeo, houdini
|
||||
*.[bB][gG][eE][oO] filter=lfs diff=lfs merge=binary -text
|
||||
# ma, 3dmax
|
||||
*.[mM][aA] filter=lfs diff=lfs merge=binary -text
|
||||
# max, 3dmax
|
||||
*.[mM][aA][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# 3dm, 3d模型
|
||||
*.3[dD][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# blend
|
||||
*.[bB][lL][eE][nN][dD] filter=lfs diff=lfs merge=binary -text
|
||||
# c4d
|
||||
*.[cC]4[dD] filter=lfs diff=lfs merge=binary -text
|
||||
# collada
|
||||
*.[cC][oO][lL][lL][aA][dD][aA] filter=lfs diff=lfs merge=binary -text
|
||||
# dxf
|
||||
*.[dD][xX][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# jas
|
||||
*.[jJ][aA][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# lws
|
||||
*.[lL][wW][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# lxo
|
||||
*.[lL][xX][oO] filter=lfs diff=lfs merge=binary -text
|
||||
# ply
|
||||
*.[pP][lL][yY] filter=lfs diff=lfs merge=binary -text
|
||||
# skp
|
||||
*.[sS][kK][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# stl
|
||||
*.[sS][tT][lL] filter=lfs diff=lfs merge=binary -text
|
||||
# ztl
|
||||
*.[zZ][tT][lL] filter=lfs diff=lfs merge=binary -text
|
||||
# it
|
||||
*.[iI][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# mod
|
||||
*.[mM][oO][dD] filter=lfs diff=lfs merge=binary -text
|
||||
# ogg
|
||||
*.[oO][gG][gG] filter=lfs diff=lfs merge=binary -text
|
||||
# s3m
|
||||
*.[sS]3[mM] filter=lfs diff=lfs merge=binary -text
|
||||
# xm
|
||||
*.[xX][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# glb
|
||||
*.[gG][lL][bB] filter=lfs diff=lfs merge=binary -text
|
||||
# gltf
|
||||
*.[gG][lL][tT][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# off
|
||||
*.[oO][fF][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# wrl
|
||||
*.[wW][rR][lL] filter=lfs diff=lfs merge=binary -text
|
||||
# 3mf
|
||||
*.3[mM][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# amf
|
||||
*.[aA][mM][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# ifc
|
||||
*.[iI][fF][cC] filter=lfs diff=lfs merge=binary -text
|
||||
# brep
|
||||
*.[bB][rR][eE][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# step
|
||||
*.[sS][tT][eE][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# fcstd
|
||||
*.[fF][cC][sS][tT][dD] filter=lfs diff=lfs merge=binary -text
|
||||
# bim
|
||||
*.[bB][iI][mM] filter=lfs diff=lfs merge=binary -text
|
||||
|
||||
# 图像,Image
|
||||
# jpg
|
||||
*.[jJ][pP][gG] filter=lfs diff=lfs merge=binary -text
|
||||
# jpeg
|
||||
*.[jJ][pP][eE][gG] filter=lfs diff=lfs merge=binary -text
|
||||
# tiff
|
||||
*.[tT][iI][fF][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# gif
|
||||
*.[gG][iI][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# svg
|
||||
*.[sS][vV][gG] filter=lfs diff=lfs merge=binary -text
|
||||
# svgz
|
||||
*.[sS][vV][gG][zZ] filter=lfs diff=lfs merge=binary -text
|
||||
# bmp
|
||||
*.[bB][mM][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# png
|
||||
*.[pP][nN][gG] filter=lfs diff=lfs merge=binary -text
|
||||
# tif
|
||||
*.[tT][iI][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# tga
|
||||
*.[tT][gG][aA] filter=lfs diff=lfs merge=binary -text
|
||||
# prj
|
||||
*.[pP][rR][jJ] filter=lfs diff=lfs merge=binary -text
|
||||
# dwg
|
||||
*.[dD][wW][gG] filter=lfs diff=lfs merge=binary -text
|
||||
# flt
|
||||
*.[fF][lL][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# htr
|
||||
*.[hH][tT][rR] filter=lfs diff=lfs merge=binary -text
|
||||
# iges
|
||||
*.[iI][gG][eE][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# igs
|
||||
*.[iI][gG][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# ige
|
||||
*.[iI][gG][eE] filter=lfs diff=lfs merge=binary -text
|
||||
# ipt
|
||||
*.[iI][pP][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# iam
|
||||
*.[iI][aA][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# lp
|
||||
*.[lL][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# ls
|
||||
*.[lL][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# shp
|
||||
*.[sS][hH][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# aep
|
||||
*.[aA][eE][pP] filter=lfs diff=lfs merge=binary -text
|
||||
# psb
|
||||
*.[pP][sS][bB] filter=lfs diff=lfs merge=binary -text
|
||||
# edx
|
||||
*.[eE][dD][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# cds
|
||||
*.[cC][dD][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# exr
|
||||
*.[eE][xX][rR] filter=lfs diff=lfs merge=binary -text
|
||||
# bc
|
||||
*.[bB][cC] filter=lfs diff=lfs merge=binary -text
|
||||
|
||||
|
||||
# 文档,Document
|
||||
# Microsoft Excel
|
||||
# xls
|
||||
*.[xX][lL][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# xlsx
|
||||
*.[xX][lL][sS][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# xslsm
|
||||
*.[xX][sS][lL][sS][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# xlt
|
||||
*.[xX][lL][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# xltx
|
||||
*.[xX][lL][tT][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# xltm
|
||||
*.[xX][lL][tT][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# Microsoft powperpoint
|
||||
# ppt
|
||||
*.[pP][pP][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# pptx
|
||||
*.[pP][pP][tT][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# pps
|
||||
*.[pP][pP][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# ppsx
|
||||
*.[pP][pP][sS][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# ppsm
|
||||
*.[pP][pP][sS][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# pptm
|
||||
*.[pP][pP][tT][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# pot
|
||||
*.[pP][oO][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# potm
|
||||
*.[pP][oO][tT][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# Microsoft word
|
||||
# doc
|
||||
*.[dD][oO][cC] filter=lfs diff=lfs merge=binary -text
|
||||
# docx
|
||||
*.[dD][oO][cC][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# docm
|
||||
*.[dD][oO][cC][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# dot
|
||||
*.[dD][oO][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# dotx
|
||||
*.[dD][oO][tT][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# dotm
|
||||
*.[dD][oO][tT][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# Apple keynotes
|
||||
# key
|
||||
*.[kK][eE][yY] filter=lfs diff=lfs merge=binary -text
|
||||
# Apple pages
|
||||
# pages, apple
|
||||
*.[pP][aA][gG][eE][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# Apple numbers
|
||||
# numbers, apple
|
||||
*.[nN][uU][mM][bB][eE][rR][sS] filter=lfs diff=lfs merge=binary -text
|
||||
|
||||
# 电子书,Book
|
||||
# chm
|
||||
*.[cC][hH][mM] filter=lfs diff=lfs merge=binary -text
|
||||
# mobi
|
||||
*.[mM][oO][bB][iI] filter=lfs diff=lfs merge=binary -text
|
||||
# epub
|
||||
*.[eE][pP][uU][bB] filter=lfs diff=lfs merge=binary -text
|
||||
# azw
|
||||
*.[aA][zZ][wW] filter=lfs diff=lfs merge=binary -text
|
||||
# azw3
|
||||
*.[aA][zZ][wW]3 filter=lfs diff=lfs merge=binary -text
|
||||
# iba
|
||||
*.[iI][bB][aA] filter=lfs diff=lfs merge=binary -text
|
||||
# lrs
|
||||
*.[lL][rR][sS] filter=lfs diff=lfs merge=binary -text
|
||||
# lrf
|
||||
*.[lL][rR][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# lrx
|
||||
*.[lL][rR][xX] filter=lfs diff=lfs merge=binary -text
|
||||
# djvu
|
||||
*.[dD][jJ][vV][uU] filter=lfs diff=lfs merge=binary -text
|
||||
# lit
|
||||
*.[lL][iI][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# rft
|
||||
*.[rR][fF][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# cbr
|
||||
*.[cC][bB][rR] filter=lfs diff=lfs merge=binary -text
|
||||
# cbz
|
||||
*.[cC][bB][zZ] filter=lfs diff=lfs merge=binary -text
|
||||
# cb7
|
||||
*.[cC][bB]7 filter=lfs diff=lfs merge=binary -text
|
||||
# cbt
|
||||
*.[cC][bB][tT] filter=lfs diff=lfs merge=binary -text
|
||||
# cba
|
||||
*.[cC][bB][aA] filter=lfs diff=lfs merge=binary -text
|
||||
# pdb
|
||||
*.[pP][dD][bB] filter=lfs diff=lfs merge=binary -text
|
||||
|
||||
# 字体,font
|
||||
# ttf
|
||||
*.[tT][tT][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# otf
|
||||
*.[oO][tT][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# woff
|
||||
*.[wW][oO][fF][fF] filter=lfs diff=lfs merge=binary -text
|
||||
# woff2
|
||||
*.[wW][oO][fF][fF]2 filter=lfs diff=lfs merge=binary -text
|
||||
|
||||
# 翻译,translate
|
||||
# po
|
||||
*.[pP][oO] filter=lfs diff=lfs merge=binary -text
|
||||
# auto generated by UGit
|
||||
*.tar filter=lfs diff=lfs merge=binary -text
|
||||
*.mod filter=lfs diff=lfs merge=binary -text
|
||||
go.mod !filter=lfs !diff=lfs !merge=binary -text
|
||||
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
21
Dockerfile
Normal 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
106
INSTALL_MONGODB.md
Normal 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 实现优势
|
||||
|
||||
相比 MySQL,MongoDB 在题库系统中的优势:
|
||||
|
||||
1. **文档模型**:天然支持 JSON,题目、试卷等复杂结构更直观
|
||||
2. **数组查询**:标签等数组字段查询更方便
|
||||
3. **全文搜索**:内置文本索引,支持全文搜索
|
||||
4. **灵活扩展**:无需预定义表结构,易于扩展
|
||||
5. **性能优秀**:读性能好,适合读多写少的场景
|
||||
|
||||
## 七、注意事项
|
||||
|
||||
1. **全文搜索**:MongoDB 的文本索引对中文支持有限,可能需要使用 bleve 等第三方库
|
||||
2. **事务**:MongoDB 4.0+ 支持事务,但性能不如 MySQL
|
||||
3. **数据备份**:定期备份 MongoDB 数据
|
||||
|
||||
169
Makefile
Normal file
169
Makefile
Normal 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
74
config.yaml
Normal 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
255
config/config.go
Normal 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
107
deploy/README.md
Normal 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
171
deploy/deploy.sh
Executable 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 ""
|
||||
|
||||
29
docs/migrations/add_doc_tables.sql
Normal file
29
docs/migrations/add_doc_tables.sql
Normal 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 '文件访问 URL(OSS 或相对路径)',
|
||||
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
|
||||
mime_type VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型,如 application/pdf',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_folder_id (folder_id),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档文件';
|
||||
6
docs/migrations/add_essay_review_statuses.sql
Normal file
6
docs/migrations/add_essay_review_statuses.sql
Normal 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"]';
|
||||
6
docs/migrations/add_objective_best_columns.sql
Normal file
6
docs/migrations/add_objective_best_columns.sql
Normal 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 '客观题历史最高对应的总题数';
|
||||
5
docs/migrations/add_prerequisite_task_id.sql
Normal file
5
docs/migrations/add_prerequisite_task_id.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- 任务表增加前置任务ID(解锁关系:需完成前置任务后才能开启本任务)
|
||||
-- 执行前请确认表名与数据库一致
|
||||
|
||||
ALTER TABLE camp_tasks
|
||||
ADD COLUMN prerequisite_task_id VARCHAR(64) NULL DEFAULT NULL COMMENT '前置任务ID,完成后才能开启本任务(递进关系)';
|
||||
5
docs/migrations/add_task_title.sql
Normal file
5
docs/migrations/add_task_title.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- 打卡营任务表增加任务标题字段
|
||||
-- 执行前请确认表名与数据库一致
|
||||
|
||||
ALTER TABLE camp_tasks
|
||||
ADD COLUMN title VARCHAR(255) NULL DEFAULT '' COMMENT '任务标题(用于展示)';
|
||||
65
go.mod
Normal file
65
go.mod
Normal 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
210
go.sum
Normal 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=
|
||||
26
internal/admin/admin_user_routes.go
Normal file
26
internal/admin/admin_user_routes.go
Normal 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("设置用户角色")
|
||||
}
|
||||
77
internal/admin/camp_routes.go
Normal file
77
internal/admin/camp_routes.go
Normal 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("重置打卡营进度")
|
||||
}
|
||||
}
|
||||
27
internal/admin/document_routes.go
Normal file
27
internal/admin/document_routes.go
Normal 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("删除文档")
|
||||
}
|
||||
21
internal/admin/order_routes.go
Normal file
21
internal/admin/order_routes.go
Normal 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("更新订单状态")
|
||||
}
|
||||
23
internal/admin/oss_routes.go
Normal file
23
internal/admin/oss_routes.go
Normal 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上传凭证(模拟)")
|
||||
}
|
||||
23
internal/admin/payment_routes.go
Normal file
23
internal/admin/payment_routes.go
Normal 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("支付通知回调")
|
||||
}
|
||||
24
internal/admin/permission_routes.go
Normal file
24
internal/admin/permission_routes.go
Normal 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("获取资源列表")
|
||||
}
|
||||
|
||||
67
internal/admin/question_routes.go
Normal file
67
internal/admin/question_routes.go
Normal 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("删除知识树节点")
|
||||
}
|
||||
}
|
||||
25
internal/admin/role_routes.go
Normal file
25
internal/admin/role_routes.go
Normal 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
59
internal/admin/routes.go
Normal 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)
|
||||
}
|
||||
24
internal/admin/scheduler_routes.go
Normal file
24
internal/admin/scheduler_routes.go
Normal 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("获取任务数量")
|
||||
}
|
||||
34
internal/admin/statistics/handler.go
Normal file
34
internal/admin/statistics/handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
74
internal/admin/statistics/service.go
Normal file
74
internal/admin/statistics/service.go
Normal 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
|
||||
}
|
||||
18
internal/admin/statistics_routes.go
Normal file
18
internal/admin/statistics_routes.go
Normal 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("获取仪表盘统计数据")
|
||||
}
|
||||
484
internal/admin_auth/dao/admin_user_dao.go
Normal file
484
internal/admin_auth/dao/admin_user_dao.go
Normal 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
|
||||
}
|
||||
263
internal/admin_auth/dao/permission_dao.go
Normal file
263
internal/admin_auth/dao/permission_dao.go
Normal 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
|
||||
}
|
||||
|
||||
304
internal/admin_auth/dao/role_dao.go
Normal file
304
internal/admin_auth/dao/role_dao.go
Normal 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
|
||||
}
|
||||
|
||||
288
internal/admin_auth/handler/admin_user_handler.go
Normal file
288
internal/admin_auth/handler/admin_user_handler.go
Normal 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": "设置用户角色成功",
|
||||
})
|
||||
}
|
||||
99
internal/admin_auth/handler/auth_handler.go
Normal file
99
internal/admin_auth/handler/auth_handler.go
Normal 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": "登出成功",
|
||||
})
|
||||
}
|
||||
245
internal/admin_auth/handler/permission_handler.go
Normal file
245
internal/admin_auth/handler/permission_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
||||
261
internal/admin_auth/handler/role_handler.go
Normal file
261
internal/admin_auth/handler/role_handler.go
Normal 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": "设置角色权限成功",
|
||||
})
|
||||
}
|
||||
|
||||
103
internal/admin_auth/middleware/auth_middleware.go
Normal file
103
internal/admin_auth/middleware/auth_middleware.go
Normal 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()
|
||||
}
|
||||
}
|
||||
93
internal/admin_auth/service/admin_user_service.go
Normal file
93
internal/admin_auth/service/admin_user_service.go
Normal 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)
|
||||
}
|
||||
187
internal/admin_auth/service/auth_service.go
Normal file
187
internal/admin_auth/service/auth_service.go
Normal 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
|
||||
}
|
||||
94
internal/admin_auth/service/permission_service.go
Normal file
94
internal/admin_auth/service/permission_service.go
Normal 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()
|
||||
}
|
||||
|
||||
135
internal/admin_auth/service/role_service.go
Normal file
135
internal/admin_auth/service/role_service.go
Normal 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)
|
||||
}
|
||||
|
||||
181
internal/admin_auth/types.go
Normal file
181
internal/admin_auth/types.go
Normal 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
|
||||
}
|
||||
61
internal/api/camp_routes.go
Normal file
61
internal/api/camp_routes.go
Normal 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("检查用户打卡营状态")
|
||||
}
|
||||
}
|
||||
23
internal/api/order_routes.go
Normal file
23
internal/api/order_routes.go
Normal 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("自动关闭订单")
|
||||
}
|
||||
20
internal/api/oss_routes.go
Normal file
20
internal/api/oss_routes.go
Normal 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上传凭证")
|
||||
}
|
||||
|
||||
21
internal/api/payment_routes.go
Normal file
21
internal/api/payment_routes.go
Normal 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("支付通知回调")
|
||||
}
|
||||
46
internal/api/question_routes.go
Normal file
46
internal/api/question_routes.go
Normal 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
39
internal/api/routes.go
Normal 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)
|
||||
}
|
||||
23
internal/api/scheduler_routes.go
Normal file
23
internal/api/scheduler_routes.go
Normal 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("获取任务数量")
|
||||
}
|
||||
347
internal/camp/dao/camp_dao.go
Normal file
347
internal/camp/dao/camp_dao.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
206
internal/camp/dao/category_dao.go
Normal file
206
internal/camp/dao/category_dao.go
Normal 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
|
||||
}
|
||||
|
||||
917
internal/camp/dao/progress_dao.go
Normal file
917
internal/camp/dao/progress_dao.go
Normal 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,
|
||||
§ionIDResult,
|
||||
&isCompleted,
|
||||
&completedAtTs,
|
||||
&reviewStatusStr,
|
||||
&reviewComment,
|
||||
&reviewImagesJSON,
|
||||
&answerImagesJSON,
|
||||
&essayReviewStatusesJSON,
|
||||
&objectiveBestCorrectCount,
|
||||
&objectiveBestTotalCount,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
progress := &camp.UserProgress{
|
||||
ID: id,
|
||||
UserID: userIDResult,
|
||||
TaskID: taskIDResult,
|
||||
CampID: campIDResult,
|
||||
SectionID: sectionIDResult,
|
||||
IsCompleted: isCompleted,
|
||||
CompletedAt: utils.FormatNullTimeToStd(completedAtTs),
|
||||
ReviewStatus: parseReviewStatus(reviewStatusStr),
|
||||
ReviewComment: reviewComment.String,
|
||||
ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64),
|
||||
ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64),
|
||||
}
|
||||
|
||||
taskIDs[taskIDResult] = struct{}{}
|
||||
|
||||
// 反序列化审核图片数组(过滤空字符串)
|
||||
if reviewImagesJSON.Valid && reviewImagesJSON.String != "" {
|
||||
var reviewImages []string
|
||||
if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil {
|
||||
filtered := make([]string, 0, len(reviewImages))
|
||||
for _, img := range reviewImages {
|
||||
if strings.TrimSpace(img) != "" {
|
||||
filtered = append(filtered, img)
|
||||
}
|
||||
}
|
||||
progress.ReviewImages = filtered
|
||||
}
|
||||
}
|
||||
unmarshalAnswerImages(answerImagesJSON.String, progress)
|
||||
if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" {
|
||||
progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String)
|
||||
}
|
||||
|
||||
progressList = append(progressList, progress)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("遍历用户进度数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 批量查询任务是否需要审核
|
||||
for taskID := range taskIDs {
|
||||
_, _, needReview, err := d.getTaskInfo(taskID)
|
||||
if err == nil {
|
||||
taskNeedReviewCache[taskID] = needReview
|
||||
}
|
||||
}
|
||||
for _, progress := range progressList {
|
||||
if val, ok := taskNeedReviewCache[progress.TaskID]; ok {
|
||||
progress.NeedReview = val
|
||||
}
|
||||
}
|
||||
return progressList, total, nil
|
||||
}
|
||||
|
||||
// ListByUserIDsAndCamp 按用户 ID 列表与打卡营查询进度(不分页,用于管理端矩阵:一页用户的所有进度)
|
||||
func (d *ProgressDAO) ListByUserIDsAndCamp(userIDs []string, campID, sectionID, taskID, reviewStatus string) ([]*camp.UserProgress, error) {
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
table := "camp_user_progress"
|
||||
placeholders := strings.Repeat("?,", len(userIDs))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
|
||||
query := `SELECT id, user_id, task_id, camp_id, section_id, is_completed, completed_at, review_status, review_comment, review_images, answer_images, essay_review_statuses, objective_best_correct_count, objective_best_total_count FROM ` + table + ` WHERE user_id IN (` + placeholders + `) AND camp_id = ?`
|
||||
args := make([]any, 0, len(userIDs)+4)
|
||||
for _, u := range userIDs {
|
||||
args = append(args, u)
|
||||
}
|
||||
args = append(args, campID)
|
||||
if sectionID != "" {
|
||||
query += ` AND section_id = ?`
|
||||
args = append(args, sectionID)
|
||||
}
|
||||
if taskID != "" {
|
||||
query += ` AND task_id = ?`
|
||||
args = append(args, taskID)
|
||||
}
|
||||
if reviewStatus != "" {
|
||||
query += ` AND review_status = ?`
|
||||
args = append(args, reviewStatus)
|
||||
}
|
||||
query += ` ORDER BY section_id, task_id`
|
||||
|
||||
rows, err := d.client.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询进度失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
progressList, taskNeedReviewCache, _, err := d.scanProgressRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, progress := range progressList {
|
||||
if val, ok := taskNeedReviewCache[progress.TaskID]; ok {
|
||||
progress.NeedReview = val
|
||||
}
|
||||
}
|
||||
return progressList, nil
|
||||
}
|
||||
|
||||
// findUserIDsByPhoneKeyword 根据手机号关键词从 users 表查询 user_id 列表(表需含 id、phone 或 mobile 列)
|
||||
// 若 users 表不存在或查询失败,返回 nil, nil,调用方仅用 user_id LIKE 即可
|
||||
func (d *ProgressDAO) findUserIDsByPhoneKeyword(keyword string) ([]string, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// 兼容表名为 users,列名为 phone 或 mobile(常见 C 端用户表)
|
||||
query := `SELECT id FROM users WHERE (phone LIKE ? OR mobile LIKE ?) LIMIT 500`
|
||||
rows, err := d.client.DB.Query(query, "%"+keyword+"%", "%"+keyword+"%")
|
||||
if err != nil {
|
||||
return nil, nil // 表不存在或列名不同时不报错,让上层只用 user_id LIKE
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// listWithUserKeywordOrPhone 使用 (user_id LIKE ? OR user_id IN (...)) 条件查询进度列表与总数
|
||||
func (d *ProgressDAO) listWithUserKeywordOrPhone(table, userKeyword string, phoneUserIDs []string, taskID, sectionID, campID, reviewStatus string, page, pageSize int) ([]*camp.UserProgress, int, error) {
|
||||
inPlaceholders := strings.Repeat("?,", len(phoneUserIDs))
|
||||
if len(inPlaceholders) > 0 {
|
||||
inPlaceholders = inPlaceholders[:len(inPlaceholders)-1]
|
||||
}
|
||||
userCond := "(user_id LIKE ? OR user_id IN (" + inPlaceholders + "))"
|
||||
var conditions []string
|
||||
var vals []interface{}
|
||||
vals = append(vals, "%"+userKeyword+"%")
|
||||
for _, id := range phoneUserIDs {
|
||||
vals = append(vals, id)
|
||||
}
|
||||
conditions = append(conditions, userCond)
|
||||
|
||||
if taskID != "" {
|
||||
conditions = append(conditions, "task_id = ?")
|
||||
vals = append(vals, taskID)
|
||||
}
|
||||
if sectionID != "" {
|
||||
conditions = append(conditions, "section_id = ?")
|
||||
vals = append(vals, sectionID)
|
||||
}
|
||||
if campID != "" {
|
||||
conditions = append(conditions, "camp_id = ?")
|
||||
vals = append(vals, campID)
|
||||
}
|
||||
if reviewStatus != "" {
|
||||
conditions = append(conditions, "review_status = ?")
|
||||
vals = append(vals, reviewStatus)
|
||||
}
|
||||
whereSQL := strings.Join(conditions, " AND ")
|
||||
|
||||
countQuery := "SELECT COUNT(*) as total FROM " + table + " WHERE " + whereSQL
|
||||
var total int
|
||||
if err := d.client.DB.QueryRow(countQuery, vals...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("查询用户进度总数失败: %v", err)
|
||||
}
|
||||
|
||||
selectFields := "id, user_id, task_id, camp_id, section_id, is_completed, completed_at, review_status, review_comment, review_images, answer_images, essay_review_statuses, objective_best_correct_count, objective_best_total_count"
|
||||
offset := (page - 1) * pageSize
|
||||
listQuery := "SELECT " + selectFields + " FROM " + table + " WHERE " + whereSQL + " ORDER BY COALESCE(completed_at, created_at) DESC LIMIT ? OFFSET ?"
|
||||
listVals := append(vals, pageSize, offset)
|
||||
rows, err := d.client.DB.Query(listQuery, listVals...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询用户进度列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
progressList, taskNeedReviewCache, taskIDs, err := d.scanProgressRows(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for taskID := range taskIDs {
|
||||
_, _, needReview, err := d.getTaskInfo(taskID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
taskNeedReviewCache[taskID] = needReview
|
||||
}
|
||||
for _, progress := range progressList {
|
||||
if val, ok := taskNeedReviewCache[progress.TaskID]; ok {
|
||||
progress.NeedReview = val
|
||||
}
|
||||
}
|
||||
return progressList, total, nil
|
||||
}
|
||||
|
||||
// scanProgressRows 将 progress 查询结果扫描为列表,并返回 taskNeedReviewCache、taskIDs
|
||||
func (d *ProgressDAO) scanProgressRows(rows *sql.Rows) ([]*camp.UserProgress, map[string]bool, map[string]struct{}, error) {
|
||||
progressList := make([]*camp.UserProgress, 0)
|
||||
taskNeedReviewCache := make(map[string]bool)
|
||||
taskIDs := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
userIDResult string
|
||||
taskIDResult string
|
||||
campIDResult string
|
||||
sectionIDResult string
|
||||
isCompleted bool
|
||||
completedAtTs sql.NullTime
|
||||
reviewStatusStr string
|
||||
reviewComment sql.NullString
|
||||
reviewImagesJSON sql.NullString
|
||||
answerImagesJSON sql.NullString
|
||||
essayReviewStatusesJSON sql.NullString
|
||||
objectiveBestCorrectCount sql.NullInt64
|
||||
objectiveBestTotalCount sql.NullInt64
|
||||
)
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
&userIDResult,
|
||||
&taskIDResult,
|
||||
&campIDResult,
|
||||
§ionIDResult,
|
||||
&isCompleted,
|
||||
&completedAtTs,
|
||||
&reviewStatusStr,
|
||||
&reviewComment,
|
||||
&reviewImagesJSON,
|
||||
&answerImagesJSON,
|
||||
&essayReviewStatusesJSON,
|
||||
&objectiveBestCorrectCount,
|
||||
&objectiveBestTotalCount,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
progress := &camp.UserProgress{
|
||||
ID: id,
|
||||
UserID: userIDResult,
|
||||
TaskID: taskIDResult,
|
||||
CampID: campIDResult,
|
||||
SectionID: sectionIDResult,
|
||||
IsCompleted: isCompleted,
|
||||
CompletedAt: utils.FormatNullTimeToStd(completedAtTs),
|
||||
ReviewStatus: parseReviewStatus(reviewStatusStr),
|
||||
ReviewComment: reviewComment.String,
|
||||
ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64),
|
||||
ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64),
|
||||
}
|
||||
taskIDs[taskIDResult] = struct{}{}
|
||||
if reviewImagesJSON.Valid && reviewImagesJSON.String != "" {
|
||||
var reviewImages []string
|
||||
if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil {
|
||||
filtered := make([]string, 0, len(reviewImages))
|
||||
for _, img := range reviewImages {
|
||||
if strings.TrimSpace(img) != "" {
|
||||
filtered = append(filtered, img)
|
||||
}
|
||||
}
|
||||
progress.ReviewImages = filtered
|
||||
}
|
||||
}
|
||||
unmarshalAnswerImages(answerImagesJSON.String, progress)
|
||||
if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" {
|
||||
progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String)
|
||||
}
|
||||
progressList = append(progressList, progress)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("遍历用户进度数据失败: %v", err)
|
||||
}
|
||||
return progressList, taskNeedReviewCache, taskIDs, nil
|
||||
}
|
||||
|
||||
// DeleteByUserAndCamp 删除用户在某打卡营下的所有进度
|
||||
func (d *ProgressDAO) DeleteByUserAndCamp(userID, campID string) (int64, error) {
|
||||
query := `DELETE FROM camp_user_progress WHERE user_id = ? AND camp_id = ?`
|
||||
res, err := d.client.DB.Exec(query, userID, campID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("删除用户营内进度失败: %v", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DeleteByUserAndTask 删除用户在某任务下的进度记录
|
||||
func (d *ProgressDAO) DeleteByUserAndTask(userID, taskID string) error {
|
||||
query := `DELETE FROM camp_user_progress WHERE user_id = ? AND task_id = ?`
|
||||
_, err := d.client.DB.Exec(query, userID, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除用户任务进度失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasProgressForTask 判断任务是否已有用户进度
|
||||
func (d *ProgressDAO) HasProgressForTask(taskID string) (bool, error) {
|
||||
query := "SELECT 1 FROM camp_user_progress WHERE task_id = ? LIMIT 1"
|
||||
var dummy int
|
||||
err := d.client.DB.QueryRow(query, taskID).Scan(&dummy)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("查询任务进度失败: %v", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HasProgressForSection 判断小节是否已有用户进度
|
||||
func (d *ProgressDAO) HasProgressForSection(sectionID string) (bool, error) {
|
||||
query := "SELECT 1 FROM camp_user_progress WHERE section_id = ? LIMIT 1"
|
||||
var dummy int
|
||||
err := d.client.DB.QueryRow(query, sectionID).Scan(&dummy)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("查询小节进度失败: %v", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HasProgressForCamp 判断打卡营是否已有用户进度
|
||||
func (d *ProgressDAO) HasProgressForCamp(campID string) (bool, error) {
|
||||
query := `SELECT 1 FROM camp_user_progress WHERE camp_id = ? LIMIT 1`
|
||||
var dummy int
|
||||
err := d.client.DB.QueryRow(query, campID).Scan(&dummy)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("查询打卡营进度失败: %v", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CountCompletedBySection 统计用户在小节下已完成的任务数
|
||||
func (d *ProgressDAO) CountCompletedBySection(userID, sectionID string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM camp_user_progress
|
||||
WHERE user_id = ? AND section_id = ? AND is_completed = 1`
|
||||
var count int
|
||||
err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计用户小节完成任务数失败: %v", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetUserSectionCompletedAt 获取用户在某小节「全部任务完成」的时间(该小节下已完成任务的 completed_at 最大值)
|
||||
// 用于时间间隔解锁的起点:间隔从「上一小节完成时刻」开始计算
|
||||
func (d *ProgressDAO) GetUserSectionCompletedAt(userID, sectionID string) (*time.Time, error) {
|
||||
query := `SELECT MAX(completed_at) FROM camp_user_progress
|
||||
WHERE user_id = ? AND section_id = ? AND is_completed = 1 AND completed_at IS NOT NULL`
|
||||
var completedAt sql.NullTime
|
||||
err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&completedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取小节完成时间失败: %v", err)
|
||||
}
|
||||
if !completedAt.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
t := completedAt.Time
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// GetUserSectionStartedAt 获取用户在某小节「首次产生进度」的时间(该小节下任意进度记录的 created_at 最小值)
|
||||
// 用于时间间隔解锁的起点:间隔从「上一小节开启时」开始计算(用户第一次进入/开始该小节的时间)
|
||||
func (d *ProgressDAO) GetUserSectionStartedAt(userID, sectionID string) (*time.Time, error) {
|
||||
query := `SELECT MIN(created_at) FROM camp_user_progress
|
||||
WHERE user_id = ? AND section_id = ?`
|
||||
var createdAt sql.NullTime
|
||||
err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&createdAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取小节开启时间失败: %v", err)
|
||||
}
|
||||
if !createdAt.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
t := createdAt.Time
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// CountTasksAndCompletedByCamp 统计打卡营下的总任务数和用户已完成的任务数
|
||||
// 用于轻量计算打卡营整体完成状态,避免加载全部小节/任务数据
|
||||
func (d *ProgressDAO) CountTasksAndCompletedByCamp(userID, campID string) (totalTasks int, completedTasks int, err error) {
|
||||
// 1. 查询打卡营下的总任务数(关联 camp_sections 确保只统计未删除的小节下的任务)
|
||||
totalQuery := `SELECT COUNT(*) FROM camp_tasks t
|
||||
INNER JOIN camp_sections s ON t.section_id = s.id AND (s.deleted_at IS NULL OR s.deleted_at = '0001-01-01 00:00:00')
|
||||
WHERE t.camp_id = ? AND (t.deleted_at IS NULL OR t.deleted_at = '0001-01-01 00:00:00')`
|
||||
err = d.client.DB.QueryRow(totalQuery, campID).Scan(&totalTasks)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("统计打卡营总任务数失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 查询用户在该打卡营下已完成的任务数
|
||||
completedQuery := `SELECT COUNT(*) FROM camp_user_progress
|
||||
WHERE user_id = ? AND camp_id = ? AND is_completed = 1`
|
||||
err = d.client.DB.QueryRow(completedQuery, userID, campID).Scan(&completedTasks)
|
||||
if err != nil {
|
||||
return totalTasks, 0, fmt.Errorf("统计用户已完成任务数失败: %v", err)
|
||||
}
|
||||
|
||||
return totalTasks, completedTasks, nil
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
// convertReviewStatus 将 ReviewStatus 转换为数据库 ENUM 字符串
|
||||
func convertReviewStatus(status camp.ReviewStatus) string {
|
||||
switch status {
|
||||
case camp.ReviewStatusApproved:
|
||||
return "APPROVED"
|
||||
case camp.ReviewStatusRejected:
|
||||
return "REJECTED"
|
||||
default:
|
||||
return "PENDING"
|
||||
}
|
||||
}
|
||||
|
||||
// parseReviewStatus 将数据库 ENUM 字符串转换为 ReviewStatus
|
||||
func parseReviewStatus(statusStr string) camp.ReviewStatus {
|
||||
switch statusStr {
|
||||
case "APPROVED":
|
||||
return camp.ReviewStatusApproved
|
||||
case "REJECTED":
|
||||
return camp.ReviewStatusRejected
|
||||
default:
|
||||
return camp.ReviewStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
// getTaskInfo 通过任务ID获取打卡营ID、小节ID与审核标记
|
||||
func (d *ProgressDAO) getTaskInfo(taskID string) (string, string, bool, error) {
|
||||
var (
|
||||
campID string
|
||||
sectionID string
|
||||
taskTypeStr string
|
||||
conditionJSON sql.NullString
|
||||
)
|
||||
|
||||
query := "SELECT camp_id, section_id, task_type, `condition` FROM camp_tasks WHERE id = ? AND deleted_at IS NULL"
|
||||
err := d.client.DB.QueryRow(query, taskID).Scan(&campID, §ionID, &taskTypeStr, &conditionJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", "", false, fmt.Errorf("任务不存在: %s", taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
needReview := false
|
||||
if conditionJSON.Valid && conditionJSON.String != "" {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(conditionJSON.String), &raw); err == nil {
|
||||
if val, ok := raw["need_review"]; ok {
|
||||
if boolVal, ok := val.(bool); ok {
|
||||
needReview = boolVal
|
||||
}
|
||||
} else if subjectiveRaw, ok := raw["subjective"].(map[string]any); ok {
|
||||
if val, ok := subjectiveRaw["need_review"]; ok {
|
||||
if boolVal, ok := val.(bool); ok {
|
||||
needReview = boolVal
|
||||
}
|
||||
}
|
||||
} else if essayRaw, ok := raw["essay"].(map[string]any); ok {
|
||||
if val, ok := essayRaw["need_review"]; ok {
|
||||
if boolVal, ok := val.(bool); ok {
|
||||
needReview = boolVal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return campID, sectionID, needReview, nil
|
||||
}
|
||||
|
||||
// normalizeCompletedAt 将 completed_at 字符串统一转换为数据库可接受的时间值
|
||||
func normalizeCompletedAt(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
// 尝试 Unix 秒
|
||||
if sec, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return time.Unix(sec, 0).In(time.Local).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
// 尝试 RFC3339 / RFC3339Nano
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t.In(time.Local).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t.In(time.Local).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
// 尝试已是标准格式
|
||||
if _, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil {
|
||||
return s
|
||||
}
|
||||
// 无法解析,回退为 NULL
|
||||
return nil
|
||||
}
|
||||
|
||||
39
internal/camp/dao/reset_history_dao.go
Normal file
39
internal/camp/dao/reset_history_dao.go
Normal 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
|
||||
}
|
||||
353
internal/camp/dao/section_dao.go
Normal file
353
internal/camp/dao/section_dao.go
Normal 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(
|
||||
§ion.ID,
|
||||
§ion.CampID,
|
||||
§ion.Title,
|
||||
§ion.SectionNumber,
|
||||
§ion.PriceFen,
|
||||
§ion.RequirePreviousSection,
|
||||
&timeIntervalTypeStr,
|
||||
§ion.TimeIntervalValue,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("小节不存在: %s", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询小节失败: %v", err)
|
||||
}
|
||||
|
||||
section.TimeIntervalType = parseTimeIntervalType(timeIntervalTypeStr)
|
||||
section.DeletedAt = utils.FormatNullTimeToStd(deletedAt)
|
||||
|
||||
return §ion, nil
|
||||
}
|
||||
|
||||
// Update 更新小节
|
||||
func (d *SectionDAO) Update(section *camp.Section) error {
|
||||
table := "camp_sections"
|
||||
where := map[string]any{
|
||||
"id": section.ID,
|
||||
}
|
||||
data := map[string]any{
|
||||
"camp_id": section.CampID,
|
||||
"title": section.Title,
|
||||
"section_number": section.SectionNumber,
|
||||
"price_fen": section.PriceFen,
|
||||
"require_previous_section": section.RequirePreviousSection,
|
||||
"time_interval_type": convertTimeIntervalType(section.TimeIntervalType),
|
||||
"time_interval_value": section.TimeIntervalValue,
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildUpdate(table, where, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建更新语句失败: %v", err)
|
||||
}
|
||||
cond += " AND deleted_at IS NULL"
|
||||
|
||||
result, err := d.client.DB.Exec(cond, vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新小节失败: %v", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取影响行数失败: %v", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("小节不存在: %s", section.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除小节(软删除)
|
||||
func (d *SectionDAO) Delete(id string) error {
|
||||
table := "camp_sections"
|
||||
where := map[string]any{
|
||||
"id": id,
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"deleted_at": time.Now(),
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildUpdate(table, where, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建删除语句失败: %v", err)
|
||||
}
|
||||
cond += " AND deleted_at IS NULL"
|
||||
|
||||
result, err := d.client.DB.Exec(cond, vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除小节失败: %v", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取影响行数失败: %v", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("小节不存在: %s", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出小节(支持关键词搜索、按打卡营ID筛选)
|
||||
func (d *SectionDAO) List(keyword, campID string, page, pageSize int) ([]*camp.Section, int, error) {
|
||||
table := "camp_sections"
|
||||
|
||||
// 构建查询条件
|
||||
where := map[string]any{}
|
||||
|
||||
if campID != "" {
|
||||
where["camp_id"] = campID
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
where["_or"] = []map[string]any{
|
||||
{"title like": "%" + keyword + "%"},
|
||||
{"id like": "%" + keyword + "%"},
|
||||
}
|
||||
}
|
||||
|
||||
// 查询总数
|
||||
countCond, countVals, err := builder.BuildSelect(table, where, []string{"count(*) as total"})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("构建统计查询失败: %v", err)
|
||||
}
|
||||
if strings.Contains(countCond, "WHERE") {
|
||||
countCond += " AND deleted_at IS NULL"
|
||||
} else {
|
||||
countCond += " WHERE deleted_at IS NULL"
|
||||
}
|
||||
|
||||
var total int
|
||||
err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询小节总数失败: %v", err)
|
||||
}
|
||||
|
||||
// 查询数据
|
||||
selectFields := []string{"id", "camp_id", "title", "section_number", "price_fen", "require_previous_section", "time_interval_type", "time_interval_value", "deleted_at"}
|
||||
cond, vals, err := builder.BuildSelect(table, where, selectFields)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("构建查询失败: %v", err)
|
||||
}
|
||||
if strings.Contains(cond, "WHERE") {
|
||||
cond += " AND deleted_at IS NULL"
|
||||
} else {
|
||||
cond += " WHERE deleted_at IS NULL"
|
||||
}
|
||||
|
||||
// 添加排序和分页
|
||||
offset := (page - 1) * pageSize
|
||||
cond += " ORDER BY section_number ASC LIMIT ? OFFSET ?"
|
||||
vals = append(vals, pageSize, offset)
|
||||
|
||||
rows, err := d.client.DB.Query(cond, vals...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询小节列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sections := make([]*camp.Section, 0)
|
||||
for rows.Next() {
|
||||
var section camp.Section
|
||||
var timeIntervalTypeStr string
|
||||
var deletedAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
§ion.ID,
|
||||
§ion.CampID,
|
||||
§ion.Title,
|
||||
§ion.SectionNumber,
|
||||
§ion.PriceFen,
|
||||
§ion.RequirePreviousSection,
|
||||
&timeIntervalTypeStr,
|
||||
§ion.TimeIntervalValue,
|
||||
&deletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
section.TimeIntervalType = parseTimeIntervalType(timeIntervalTypeStr)
|
||||
section.DeletedAt = utils.FormatNullTimeToStd(deletedAt)
|
||||
sections = append(sections, §ion)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("遍历小节数据失败: %v", err)
|
||||
}
|
||||
|
||||
return sections, total, nil
|
||||
}
|
||||
|
||||
// CountActiveByCamp 统计打卡营下未删除的小节数量(与 List 条件一致,兼容 deleted_at 为 NULL 或 0001-01-01)
|
||||
func (d *SectionDAO) CountActiveByCamp(campID string) (int, error) {
|
||||
query := "SELECT COUNT(*) FROM camp_sections WHERE camp_id = ? AND (deleted_at IS NULL OR deleted_at = '0001-01-01 00:00:00')"
|
||||
var count int
|
||||
err := d.client.DB.QueryRow(query, campID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计小节数量失败: %v", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CountByCampIDs 批量统计多个打卡营下未删除的小节数量,返回 map[campID]count(用于列表展示时校正 section_count)
|
||||
func (d *SectionDAO) CountByCampIDs(campIDs []string) (map[string]int, error) {
|
||||
if len(campIDs) == 0 {
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
placeholders := strings.Repeat("?,", len(campIDs))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
query := "SELECT camp_id, COUNT(*) FROM camp_sections WHERE (deleted_at IS NULL OR deleted_at = '0001-01-01 00:00:00') AND camp_id IN (" + placeholders + ") GROUP BY camp_id"
|
||||
args := make([]any, len(campIDs))
|
||||
for i, id := range campIDs {
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := d.client.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量统计小节数量失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make(map[string]int)
|
||||
for _, id := range campIDs {
|
||||
result[id] = 0
|
||||
}
|
||||
for rows.Next() {
|
||||
var campID string
|
||||
var count int
|
||||
if err := rows.Scan(&campID, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
result[campID] = count
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("遍历小节统计结果失败: %v", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFirstSectionID 获取打卡营中 section_number 最小的小节ID
|
||||
func (d *SectionDAO) GetFirstSectionID(campID string) (string, error) {
|
||||
query := "SELECT id FROM camp_sections WHERE camp_id = ? AND deleted_at IS NULL ORDER BY section_number ASC LIMIT 1"
|
||||
var sectionID string
|
||||
err := d.client.DB.QueryRow(query, campID).Scan(§ionID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil // 没有小节,返回空字符串
|
||||
}
|
||||
return "", fmt.Errorf("获取第一个小节ID失败: %v", err)
|
||||
}
|
||||
return sectionID, nil
|
||||
}
|
||||
|
||||
// convertTimeIntervalType 将 TimeIntervalType 转换为数据库字符串
|
||||
func convertTimeIntervalType(timeIntervalType camp.TimeIntervalType) string {
|
||||
switch timeIntervalType {
|
||||
case camp.TimeIntervalTypeHour:
|
||||
return "HOUR_INTERVAL"
|
||||
case camp.TimeIntervalTypeNaturalDay:
|
||||
return "NATURAL_DAY"
|
||||
case camp.TimeIntervalTypePaid:
|
||||
return "PAID"
|
||||
default:
|
||||
return "NONE"
|
||||
}
|
||||
}
|
||||
|
||||
// parseTimeIntervalType 将数据库字符串转换为 TimeIntervalType(大小写不敏感)
|
||||
func parseTimeIntervalType(timeIntervalTypeStr string) camp.TimeIntervalType {
|
||||
s := strings.TrimSpace(strings.ToUpper(timeIntervalTypeStr))
|
||||
switch s {
|
||||
case "HOUR_INTERVAL", "HOUR":
|
||||
return camp.TimeIntervalTypeHour
|
||||
case "NATURAL_DAY":
|
||||
return camp.TimeIntervalTypeNaturalDay
|
||||
case "PAID":
|
||||
return camp.TimeIntervalTypePaid
|
||||
default:
|
||||
return camp.TimeIntervalTypeNone
|
||||
}
|
||||
}
|
||||
|
||||
441
internal/camp/dao/task_dao.go
Normal file
441
internal/camp/dao/task_dao.go
Normal 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,
|
||||
§ionID,
|
||||
&taskTypeStr,
|
||||
&title,
|
||||
&contentJSON,
|
||||
&conditionJSON,
|
||||
&prerequisiteTaskID,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("任务不存在: %s", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询任务失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析任务类型
|
||||
taskType := parseTaskType(taskTypeStr)
|
||||
|
||||
// 构建 Task 对象
|
||||
task := &camp.Task{
|
||||
ID: taskID,
|
||||
CampID: campID,
|
||||
SectionID: sectionID,
|
||||
TaskType: taskType,
|
||||
Title: title.String,
|
||||
PrerequisiteTaskID: prerequisiteTaskID.String,
|
||||
DeletedAt: utils.FormatNullTimeToStd(deletedAt),
|
||||
}
|
||||
|
||||
// 处理 Content
|
||||
if contentJSON.Valid && contentJSON.String != "" {
|
||||
// 验证 JSON 格式
|
||||
if json.Valid([]byte(contentJSON.String)) {
|
||||
task.Content = json.RawMessage(contentJSON.String)
|
||||
} else {
|
||||
task.Content = json.RawMessage("{}")
|
||||
}
|
||||
} else {
|
||||
task.Content = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
// 处理 Condition
|
||||
if conditionJSON.Valid && conditionJSON.String != "" {
|
||||
// 验证 JSON 格式
|
||||
if json.Valid([]byte(conditionJSON.String)) {
|
||||
task.Condition = json.RawMessage(conditionJSON.String)
|
||||
} else {
|
||||
task.Condition = json.RawMessage("{}")
|
||||
}
|
||||
} else {
|
||||
task.Condition = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// Update 更新任务
|
||||
func (d *TaskDAO) Update(task *camp.Task) error {
|
||||
table := "camp_tasks"
|
||||
|
||||
// 更新前检查是否存在
|
||||
existsWhere := map[string]any{
|
||||
"id": task.ID,
|
||||
}
|
||||
existsCond, existsVals, err := builder.BuildSelect(table, existsWhere, []string{"id"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建校验查询失败: %v", err)
|
||||
}
|
||||
existsCond += " AND deleted_at IS NULL"
|
||||
|
||||
var dummyID string
|
||||
if err := d.client.DB.QueryRow(existsCond, existsVals...).Scan(&dummyID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("任务不存在: %s", task.ID)
|
||||
}
|
||||
return fmt.Errorf("查询任务失败: %v", err)
|
||||
}
|
||||
|
||||
where := map[string]any{
|
||||
"id": task.ID,
|
||||
}
|
||||
|
||||
// Content 和 Condition 已经是 JSON 格式,直接使用
|
||||
contentJSON := string(task.Content)
|
||||
if contentJSON == "" {
|
||||
contentJSON = "{}"
|
||||
}
|
||||
conditionJSON := string(task.Condition)
|
||||
if conditionJSON == "" {
|
||||
conditionJSON = "{}"
|
||||
}
|
||||
|
||||
// 使用反引号包裹 condition 字段名(避免 MySQL 保留字冲突)
|
||||
data := map[string]any{
|
||||
"camp_id": task.CampID,
|
||||
"section_id": task.SectionID,
|
||||
"task_type": convertTaskType(task.TaskType),
|
||||
"title": task.Title,
|
||||
"content": contentJSON,
|
||||
"`condition`": conditionJSON, // 反引号包裹保留字
|
||||
"prerequisite_task_id": nullString(task.PrerequisiteTaskID),
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildUpdate(table, where, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建更新语句失败: %v", err)
|
||||
}
|
||||
cond += " AND deleted_at IS NULL"
|
||||
|
||||
if _, err := d.client.DB.Exec(cond, vals...); err != nil {
|
||||
return fmt.Errorf("更新任务失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除任务(软删除)
|
||||
func (d *TaskDAO) Delete(id string) error {
|
||||
table := "camp_tasks"
|
||||
where := map[string]any{
|
||||
"id": id,
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"deleted_at": time.Now(),
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildUpdate(table, where, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建删除语句失败: %v", err)
|
||||
}
|
||||
cond += " AND deleted_at IS NULL"
|
||||
|
||||
result, err := d.client.DB.Exec(cond, vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除任务失败: %v", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取影响行数失败: %v", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("任务不存在: %s", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出任务(支持关键词搜索、按打卡营/小节ID和任务类型筛选)
|
||||
func (d *TaskDAO) List(keyword, campID, sectionID string, taskType camp.TaskType, page, pageSize int) ([]*camp.Task, int, error) {
|
||||
table := "camp_tasks"
|
||||
baseWhere := "(deleted_at IS NULL OR deleted_at = '0001-01-01 00:00:00')"
|
||||
|
||||
// 手写构建 WHERE 与参数,确保 camp_id 等筛选生效(不依赖 gendry where map)
|
||||
var conditions []string
|
||||
var args []any
|
||||
conditions = append(conditions, baseWhere)
|
||||
if campID != "" {
|
||||
conditions = append(conditions, "camp_id = ?")
|
||||
args = append(args, campID)
|
||||
}
|
||||
if sectionID != "" {
|
||||
conditions = append(conditions, "section_id = ?")
|
||||
args = append(args, sectionID)
|
||||
}
|
||||
if taskType != camp.TaskTypeUnknown {
|
||||
conditions = append(conditions, "task_type = ?")
|
||||
args = append(args, convertTaskType(taskType))
|
||||
}
|
||||
if keyword != "" {
|
||||
conditions = append(conditions, "id LIKE ?")
|
||||
args = append(args, "%"+keyword+"%")
|
||||
}
|
||||
whereClause := strings.Join(conditions, " AND ")
|
||||
|
||||
// 查询总数
|
||||
countQuery := "SELECT COUNT(*) FROM " + table + " WHERE " + whereClause
|
||||
var total int
|
||||
err := d.client.DB.QueryRow(countQuery, args...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询任务总数失败: %v", err)
|
||||
}
|
||||
|
||||
// 查询数据(分页)
|
||||
offset := (page - 1) * pageSize
|
||||
dataQuery := "SELECT id, camp_id, section_id, task_type, title, content, `condition`, prerequisite_task_id, deleted_at FROM " + table + " WHERE " + whereClause + " ORDER BY id ASC LIMIT ? OFFSET ?"
|
||||
dataArgs := append(append([]any{}, args...), pageSize, offset)
|
||||
|
||||
rows, err := d.client.DB.Query(dataQuery, dataArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询任务列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tasks := make([]*camp.Task, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
taskID string
|
||||
campID string
|
||||
sectionID string
|
||||
taskTypeStr string
|
||||
title sql.NullString
|
||||
contentJSON sql.NullString
|
||||
conditionJSON sql.NullString
|
||||
prerequisiteTaskID sql.NullString
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&taskID,
|
||||
&campID,
|
||||
§ionID,
|
||||
&taskTypeStr,
|
||||
&title,
|
||||
&contentJSON,
|
||||
&conditionJSON,
|
||||
&prerequisiteTaskID,
|
||||
&deletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
taskType := parseTaskType(taskTypeStr)
|
||||
task := &camp.Task{
|
||||
ID: taskID,
|
||||
CampID: campID,
|
||||
SectionID: sectionID,
|
||||
TaskType: taskType,
|
||||
Title: title.String,
|
||||
PrerequisiteTaskID: prerequisiteTaskID.String,
|
||||
DeletedAt: utils.FormatNullTimeToStd(deletedAt),
|
||||
}
|
||||
|
||||
// 处理 JSON 字段
|
||||
if contentJSON.Valid && contentJSON.String != "" && json.Valid([]byte(contentJSON.String)) {
|
||||
task.Content = json.RawMessage(contentJSON.String)
|
||||
} else {
|
||||
task.Content = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
if conditionJSON.Valid && conditionJSON.String != "" && json.Valid([]byte(conditionJSON.String)) {
|
||||
task.Condition = json.RawMessage(conditionJSON.String)
|
||||
} else {
|
||||
task.Condition = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("遍历任务数据失败: %v", err)
|
||||
}
|
||||
|
||||
return tasks, total, nil
|
||||
}
|
||||
|
||||
// CountActiveBySection 统计小节下未删除的任务数量
|
||||
func (d *TaskDAO) CountActiveBySection(sectionID string) (int, error) {
|
||||
query := "SELECT COUNT(*) FROM camp_tasks WHERE section_id = ? AND deleted_at IS NULL"
|
||||
var count int
|
||||
err := d.client.DB.QueryRow(query, sectionID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计任务数量失败: %v", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// MinTaskIDBySection 返回小节内任务 ID 最小的任务 ID(用于判断“第一个任务”)
|
||||
func (d *TaskDAO) MinTaskIDBySection(sectionID string) (string, error) {
|
||||
query := "SELECT id FROM camp_tasks WHERE section_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT 1"
|
||||
var minID string
|
||||
err := d.client.DB.QueryRow(query, sectionID).Scan(&minID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil // 小节内无任务
|
||||
}
|
||||
return "", fmt.Errorf("查询小节最小任务ID失败: %v", err)
|
||||
}
|
||||
return minID, nil
|
||||
}
|
||||
|
||||
// CountActiveByCamp 统计打卡营下未删除的任务数量
|
||||
func (d *TaskDAO) CountActiveByCamp(campID string) (int, error) {
|
||||
query := "SELECT COUNT(*) FROM camp_tasks WHERE camp_id = ? AND deleted_at IS NULL"
|
||||
var count int
|
||||
err := d.client.DB.QueryRow(query, campID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计任务数量失败: %v", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ========== 类型转换函数 ==========
|
||||
|
||||
// convertTaskType 将 TaskType 转换为数据库 ENUM 字符串
|
||||
func convertTaskType(taskType camp.TaskType) string {
|
||||
switch taskType {
|
||||
case camp.TaskTypeImageText:
|
||||
return "IMAGE_TEXT"
|
||||
case camp.TaskTypeVideo:
|
||||
return "VIDEO"
|
||||
case camp.TaskTypeObjective:
|
||||
return "OBJECTIVE"
|
||||
case camp.TaskTypeSubjective:
|
||||
return "SUBJECTIVE"
|
||||
case camp.TaskTypeEssay:
|
||||
return "ESSAY"
|
||||
default:
|
||||
return "IMAGE_TEXT"
|
||||
}
|
||||
}
|
||||
|
||||
// parseTaskType 将数据库 ENUM 字符串转换为 TaskType
|
||||
func parseTaskType(taskTypeStr string) camp.TaskType {
|
||||
switch taskTypeStr {
|
||||
case "IMAGE_TEXT":
|
||||
return camp.TaskTypeImageText
|
||||
case "VIDEO":
|
||||
return camp.TaskTypeVideo
|
||||
case "OBJECTIVE":
|
||||
return camp.TaskTypeObjective
|
||||
case "SUBJECTIVE":
|
||||
return camp.TaskTypeSubjective
|
||||
case "ESSAY":
|
||||
return camp.TaskTypeEssay
|
||||
default:
|
||||
return camp.TaskTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// nullString 空字符串返回 nil,便于写入 NULL
|
||||
func nullString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
244
internal/camp/dao/user_camp_dao.go
Normal file
244
internal/camp/dao/user_camp_dao.go
Normal 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, ¤tSectionID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return false, sql.NullTime{}, sql.NullString{}, nil // 用户未加入
|
||||
}
|
||||
return false, sql.NullTime{}, sql.NullString{}, fmt.Errorf("查询用户打卡营状态失败: %v", err)
|
||||
}
|
||||
|
||||
return true, joinedAt, currentSectionID, nil
|
||||
}
|
||||
|
||||
// UpdateCurrentSection 更新用户在当前打卡营中的当前小节
|
||||
func (d *UserCampDAO) UpdateCurrentSection(userID, campID, sectionID string) error {
|
||||
table := "camp_user_camps"
|
||||
|
||||
where := map[string]any{
|
||||
"user_id": userID,
|
||||
"camp_id": campID,
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"current_section_id": sectionID,
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildUpdate(table, where, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建更新语句失败: %v", err)
|
||||
}
|
||||
|
||||
_, err = d.client.DB.Exec(cond, vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新当前小节失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentSection 获取用户在当前打卡营中的当前小节ID
|
||||
func (d *UserCampDAO) GetCurrentSection(userID, campID string) (string, error) {
|
||||
table := "camp_user_camps"
|
||||
|
||||
where := map[string]any{
|
||||
"user_id": userID,
|
||||
"camp_id": campID,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildSelect(table, where, []string{"current_section_id"})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("构建查询语句失败: %v", err)
|
||||
}
|
||||
|
||||
var currentSectionID sql.NullString
|
||||
err = d.client.DB.QueryRow(cond, vals...).Scan(¤tSectionID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil // 用户未加入
|
||||
}
|
||||
return "", fmt.Errorf("查询当前小节失败: %v", err)
|
||||
}
|
||||
|
||||
if !currentSectionID.Valid {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return currentSectionID.String, nil
|
||||
}
|
||||
|
||||
// ListUserJoinedCamps 获取用户已加入的打卡营列表
|
||||
func (d *UserCampDAO) ListUserJoinedCamps(userID string, page, pageSize int) ([]*camp.UserJoinedCamp, int, error) {
|
||||
table := "camp_user_camps"
|
||||
|
||||
// 查询总数
|
||||
countCond, countVals, err := builder.BuildSelect(table, map[string]any{
|
||||
"user_id": userID,
|
||||
"status": "ACTIVE",
|
||||
}, []string{"COUNT(*) as total"})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("构建计数查询失败: %v", err)
|
||||
}
|
||||
|
||||
var total int
|
||||
err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询总数失败: %v", err)
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
where := map[string]any{
|
||||
"user_id": userID,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildSelect(table, where, []string{"camp_id", "joined_at", "status"})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("构建查询语句失败: %v", err)
|
||||
}
|
||||
|
||||
// 添加分页
|
||||
offset := (page - 1) * pageSize
|
||||
cond += " ORDER BY joined_at DESC LIMIT ? OFFSET ?"
|
||||
vals = append(vals, pageSize, offset)
|
||||
|
||||
rows, err := d.client.DB.Query(cond, vals...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询用户打卡营列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var camps []*camp.UserJoinedCamp
|
||||
for rows.Next() {
|
||||
var camp camp.UserJoinedCamp
|
||||
var joinedAt sql.NullTime
|
||||
err := rows.Scan(&camp.CampID, &joinedAt, &camp.Status)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("扫描行数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
camp.JoinedAt = utils.FormatNullTimeToStd(joinedAt)
|
||||
|
||||
camps = append(camps, &camp)
|
||||
}
|
||||
|
||||
return camps, total, nil
|
||||
}
|
||||
|
||||
// ListUserIDsByCamp 分页列出已加入指定打卡营的用户 ID(用于管理端进度矩阵:展示所有开启过该营的用户)
|
||||
// userKeyword 可选,对 user_id 做 LIKE 模糊匹配
|
||||
func (d *UserCampDAO) ListUserIDsByCamp(campID, userKeyword string, page, pageSize int) ([]string, int, error) {
|
||||
table := "camp_user_camps"
|
||||
|
||||
where := map[string]any{
|
||||
"camp_id": campID,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
if userKeyword != "" {
|
||||
where["user_id like"] = "%" + userKeyword + "%"
|
||||
}
|
||||
|
||||
// 总数
|
||||
countCond, countVals, err := builder.BuildSelect(table, where, []string{"COUNT(*) as total"})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("构建计数查询失败: %v", err)
|
||||
}
|
||||
var total int
|
||||
err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询总数失败: %v", err)
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildSelect(table, where, []string{"user_id"})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("构建查询失败: %v", err)
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
cond += " ORDER BY joined_at DESC LIMIT ? OFFSET ?"
|
||||
vals = append(vals, pageSize, offset)
|
||||
|
||||
rows, err := d.client.DB.Query(cond, vals...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询用户列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []string
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
return userIDs, total, nil
|
||||
}
|
||||
|
||||
84
internal/camp/dao/user_section_access_dao.go
Normal file
84
internal/camp/dao/user_section_access_dao.go
Normal 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
|
||||
}
|
||||
|
||||
427
internal/camp/handler/camp_handler.go
Normal file
427
internal/camp/handler/camp_handler.go
Normal 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_recommended(true/false)而非 recommend_filter
|
||||
if req.RecommendFilter == "" || req.RecommendFilter == camp.RecommendFilterAll {
|
||||
switch c.Query("is_recommended") {
|
||||
case "true", "1":
|
||||
req.RecommendFilter = camp.RecommendFilterOnlyTrue
|
||||
case "false", "0":
|
||||
req.RecommendFilter = camp.RecommendFilterOnlyFalse
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 RecommendFilter
|
||||
if req.RecommendFilter == "" {
|
||||
req.RecommendFilter = camp.RecommendFilterAll
|
||||
}
|
||||
|
||||
resp, err := h.campService.ListCamps(&req)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "获取打卡营列表失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// 保证每个营都带上 is_joined:若服务层未填充且提供了 user_id,则在此用 UserCampService 补全
|
||||
if req.UserID != "" && len(resp.Camps) > 0 && h.userCampService != nil {
|
||||
for _, campItem := range resp.Camps {
|
||||
if campItem.IsJoined != nil {
|
||||
continue
|
||||
}
|
||||
statusResp, _ := h.userCampService.CheckUserCampStatus(req.UserID, campItem.ID)
|
||||
if statusResp != nil {
|
||||
joined := statusResp.IsJoined
|
||||
campItem.IsJoined = &joined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(resp)
|
||||
}
|
||||
|
||||
// GetCampDetailWithStatus 获取打卡营详情及状态(聚合接口)
|
||||
func (h *CampHandler) GetCampDetailWithStatus(c *fiber.Ctx) error {
|
||||
var req camp.GetCampDetailWithStatusRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "请求参数解析失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.CampID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "打卡营ID不能为空",
|
||||
})
|
||||
}
|
||||
if req.UserID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "用户ID不能为空",
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := h.campService.GetCampDetailWithStatus(c.Context(), &req)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "获取打卡营详情失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(resp)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(resp)
|
||||
}
|
||||
|
||||
// CanUnlockSection 检查是否可开启下一小节(后端查库:上一小节是否完成等)
|
||||
func (h *CampHandler) CanUnlockSection(c *fiber.Ctx) error {
|
||||
var req camp.CanUnlockSectionRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "请求参数解析失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
if req.CampID == "" || req.SectionID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "打卡营ID和小节ID不能为空",
|
||||
})
|
||||
}
|
||||
if req.UserID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "用户ID不能为空",
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := h.campService.CanUnlockSection(&req)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "检查失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(resp)
|
||||
}
|
||||
|
||||
// CanStartTask 检查用户是否可以开始指定任务(前置任务已完成)
|
||||
func (h *CampHandler) CanStartTask(c *fiber.Ctx) error {
|
||||
var req camp.CanStartTaskRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "请求参数解析失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
if req.UserID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "用户ID不能为空",
|
||||
})
|
||||
}
|
||||
if req.TaskID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "任务ID不能为空",
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := h.campService.CanStartTask(&req)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "检查失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(resp)
|
||||
}
|
||||
182
internal/camp/handler/category_handler.go
Normal file
182
internal/camp/handler/category_handler.go
Normal 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)
|
||||
}
|
||||
|
||||
182
internal/camp/handler/progress_handler.go
Normal file
182
internal/camp/handler/progress_handler.go
Normal 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)
|
||||
}
|
||||
|
||||
473
internal/camp/handler/section_handler.go
Normal file
473
internal/camp/handler/section_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
422
internal/camp/handler/task_handler.go
Normal file
422
internal/camp/handler/task_handler.go
Normal 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)
|
||||
}
|
||||
192
internal/camp/handler/user_camp_handler.go
Normal file
192
internal/camp/handler/user_camp_handler.go
Normal 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)
|
||||
}
|
||||
|
||||
1290
internal/camp/service/camp_service.go
Normal file
1290
internal/camp/service/camp_service.go
Normal file
File diff suppressed because it is too large
Load Diff
150
internal/camp/service/category_service.go
Normal file
150
internal/camp/service/category_service.go
Normal 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
|
||||
}
|
||||
|
||||
511
internal/camp/service/progress_service.go
Normal file
511
internal/camp/service/progress_service.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
202
internal/camp/service/section_service.go
Normal file
202
internal/camp/service/section_service.go
Normal 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
|
||||
}
|
||||
|
||||
50
internal/camp/service/task_content.go
Normal file
50
internal/camp/service/task_content.go
Normal 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 ""
|
||||
}
|
||||
215
internal/camp/service/task_service.go
Normal file
215
internal/camp/service/task_service.go
Normal 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
|
||||
}
|
||||
|
||||
223
internal/camp/service/user_camp_service.go
Normal file
223
internal/camp/service/user_camp_service.go
Normal 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
636
internal/camp/types.go
Normal 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"`
|
||||
}
|
||||
150
internal/document/dao/file_dao.go
Normal file
150
internal/document/dao/file_dao.go
Normal 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
|
||||
}
|
||||
128
internal/document/dao/folder_dao.go
Normal file
128
internal/document/dao/folder_dao.go
Normal 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()
|
||||
}
|
||||
123
internal/document/handler/document_handler.go
Normal file
123
internal/document/handler/document_handler.go
Normal 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})
|
||||
}
|
||||
105
internal/document/service/document_service.go
Normal file
105
internal/document/service/document_service.go
Normal 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)
|
||||
}
|
||||
52
internal/document/types.go
Normal file
52
internal/document/types.go
Normal 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"`
|
||||
}
|
||||
621
internal/order/dao/order_dao.go
Normal file
621
internal/order/dao/order_dao.go
Normal 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, §ionID); err != nil {
|
||||
continue
|
||||
}
|
||||
if orderID.Valid {
|
||||
result[orderID.String] = &OrderBusinessData{
|
||||
OrderID: orderID.String,
|
||||
CampID: campID.String,
|
||||
SectionID: sectionID.String,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateOrderStatus 更新订单状态(使用新的 orders 表)
|
||||
func (d *OrderDAO) UpdateOrderStatus(
|
||||
orderID, orderNo string,
|
||||
orderStatus order.OrderStatus,
|
||||
paymentMethod order.PaymentMethod,
|
||||
thirdPartyOrderNo string,
|
||||
paymentTime *time.Time,
|
||||
) error {
|
||||
table := "orders"
|
||||
where := make(map[string]any)
|
||||
if orderID != "" {
|
||||
where["order_id"] = orderID
|
||||
} else if orderNo != "" {
|
||||
where["order_id"] = orderNo // 新表使用 order_id
|
||||
} else {
|
||||
return fmt.Errorf("订单ID和订单号不能同时为空")
|
||||
}
|
||||
|
||||
data := make(map[string]any)
|
||||
if orderStatus != order.OrderStatusUnknown && orderStatus != "" {
|
||||
data["status"] = string(orderStatus)
|
||||
}
|
||||
if paymentMethod != order.PaymentMethodUnknown && paymentMethod != "" {
|
||||
data["payment_method"] = string(paymentMethod)
|
||||
}
|
||||
if thirdPartyOrderNo != "" {
|
||||
data["transaction_id"] = thirdPartyOrderNo
|
||||
}
|
||||
if paymentTime != nil {
|
||||
data["payment_time"] = *paymentTime
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("没有需要更新的字段")
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildUpdate(table, where, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建更新语句失败: %v", err)
|
||||
}
|
||||
_, err = d.client.DB.Exec(cond, vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新订单状态失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefundOrder 退款订单(使用新的 orders 表,注意:新表没有 refund_amount_fen, refund_reason, refund_time 字段)
|
||||
// 退款操作只需要更新订单状态为 REFUNDED
|
||||
func (d *OrderDAO) RefundOrder(
|
||||
orderID, orderNo string,
|
||||
refundAmountFen int32,
|
||||
refundReason string,
|
||||
) error {
|
||||
table := "orders"
|
||||
where := make(map[string]any)
|
||||
if orderID != "" {
|
||||
where["order_id"] = orderID
|
||||
} else if orderNo != "" {
|
||||
where["order_id"] = orderNo // 新表使用 order_id
|
||||
} else {
|
||||
return fmt.Errorf("订单ID和订单号不能同时为空")
|
||||
}
|
||||
|
||||
// 新表只有 status 字段,退款时更新为 REFUNDED
|
||||
// 注意:refund_amount_fen, refund_reason, refund_time 字段在新表中不存在
|
||||
// 如果需要记录这些信息,可能需要额外的退款记录表
|
||||
data := map[string]any{
|
||||
"status": string(order.OrderStatusRefunded),
|
||||
}
|
||||
|
||||
cond, vals, err := builder.BuildUpdate(table, where, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建更新语句失败: %v", err)
|
||||
}
|
||||
_, err = d.client.DB.Exec(cond, vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("退款订单失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
// convertOrderStatus 转换订单状态枚举为数据库值
|
||||
func convertOrderStatus(status order.OrderStatus) *string {
|
||||
switch status {
|
||||
case order.OrderStatusPending:
|
||||
s := "PENDING"
|
||||
return &s
|
||||
case order.OrderStatusPaid:
|
||||
s := "PAID"
|
||||
return &s
|
||||
case order.OrderStatusFailed:
|
||||
s := "FAILED"
|
||||
return &s
|
||||
case order.OrderStatusRefunded:
|
||||
s := "REFUNDED"
|
||||
return &s
|
||||
case order.OrderStatusCancelled:
|
||||
s := "CANCELLED"
|
||||
return &s
|
||||
default:
|
||||
return nil // NULL
|
||||
}
|
||||
}
|
||||
|
||||
// parseOrderStatus 解析订单状态字符串为枚举
|
||||
func parseOrderStatus(s string) order.OrderStatus {
|
||||
switch s {
|
||||
case "PENDING":
|
||||
return order.OrderStatusPending
|
||||
case "PAID":
|
||||
return order.OrderStatusPaid
|
||||
case "FAILED":
|
||||
return order.OrderStatusFailed
|
||||
case "REFUNDED":
|
||||
return order.OrderStatusRefunded
|
||||
case "CANCELLED":
|
||||
return order.OrderStatusCancelled
|
||||
default:
|
||||
return order.OrderStatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// convertPaymentMethod 转换支付方式枚举为数据库值
|
||||
func convertPaymentMethod(method order.PaymentMethod) *string {
|
||||
switch method {
|
||||
case order.PaymentMethodWechat:
|
||||
s := "WECHAT"
|
||||
return &s
|
||||
case order.PaymentMethodAlipay:
|
||||
s := "ALIPAY"
|
||||
return &s
|
||||
case order.PaymentMethodBalance:
|
||||
s := "BALANCE"
|
||||
return &s
|
||||
default:
|
||||
return nil // NULL
|
||||
}
|
||||
}
|
||||
|
||||
// parsePaymentMethod 解析支付方式字符串为枚举
|
||||
func parsePaymentMethod(s string) order.PaymentMethod {
|
||||
switch s {
|
||||
case "WECHAT":
|
||||
return order.PaymentMethodWechat
|
||||
case "ALIPAY":
|
||||
return order.PaymentMethodAlipay
|
||||
case "BALANCE":
|
||||
return order.PaymentMethodBalance
|
||||
default:
|
||||
return order.PaymentMethodUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// parseAccessSource 解析访问来源字符串为枚举
|
||||
func parseAccessSource(s string) order.AccessSource {
|
||||
switch s {
|
||||
case "GRANT":
|
||||
return order.AccessSourceGrant
|
||||
case "PURCHASE":
|
||||
return order.AccessSourcePurchase
|
||||
default:
|
||||
return order.AccessSourceUnknown
|
||||
}
|
||||
}
|
||||
237
internal/order/handler/order_handler.go
Normal file
237
internal/order/handler/order_handler.go
Normal 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)
|
||||
}
|
||||
460
internal/order/service/order_service.go
Normal file
460
internal/order/service/order_service.go
Normal 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
153
internal/order/types.go
Normal 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
43
internal/oss/handler.go
Normal 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
197
internal/oss/service.go
Normal 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,
|
||||
}
|
||||
|
||||
// 将凭证缓存到Redis(1小时过期)
|
||||
if s.Redis != nil {
|
||||
_ = s.Redis.Set(ctx, cacheKey, policyToken, time.Hour)
|
||||
}
|
||||
|
||||
return policyToken, nil
|
||||
}
|
||||
|
||||
// GetMockPolicyToken 生成模拟凭证(用于测试)
|
||||
func (s *Service) GetMockPolicyToken(dir string) *PolicyToken {
|
||||
host := fmt.Sprintf("https://%s.oss-%s.aliyuncs.com", s.BucketName, s.Region)
|
||||
|
||||
return &PolicyToken{
|
||||
Policy: "mock_policy",
|
||||
SecurityToken: "mock_security_token",
|
||||
SignatureVersion: "OSS4-HMAC-SHA256",
|
||||
Credential: "mock_credential",
|
||||
Date: "20241022T150000Z",
|
||||
Signature: "mock_signature",
|
||||
Host: host,
|
||||
Dir: dir,
|
||||
}
|
||||
}
|
||||
421
internal/payment/handler.go
Normal file
421
internal/payment/handler.go
Normal 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
291
internal/payment/service.go
Normal 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
|
||||
}
|
||||
467
internal/question/dao/answer_record_dao_mongo.go
Normal file
467
internal/question/dao/answer_record_dao_mongo.go
Normal 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
|
||||
}
|
||||
|
||||
62
internal/question/dao/interfaces.go
Normal file
62
internal/question/dao/interfaces.go
Normal 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) // 获取完整树形结构
|
||||
}
|
||||
272
internal/question/dao/knowledge_tree_dao_mongo.go
Normal file
272
internal/question/dao/knowledge_tree_dao_mongo.go
Normal 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
|
||||
}
|
||||
247
internal/question/dao/material_dao_mongo.go
Normal file
247
internal/question/dao/material_dao_mongo.go
Normal 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
|
||||
}
|
||||
|
||||
449
internal/question/dao/paper_dao_mongo.go
Normal file
449
internal/question/dao/paper_dao_mongo.go
Normal 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
|
||||
}
|
||||
344
internal/question/dao/question_dao_mongo.go
Normal file
344
internal/question/dao/question_dao_mongo.go
Normal 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
|
||||
}
|
||||
174
internal/question/handler/answer_record_handler.go
Normal file
174
internal/question/handler/answer_record_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
||||
253
internal/question/handler/knowledge_tree_handler.go
Normal file
253
internal/question/handler/knowledge_tree_handler.go
Normal 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": "删除成功",
|
||||
})
|
||||
}
|
||||
169
internal/question/handler/material_handler.go
Normal file
169
internal/question/handler/material_handler.go
Normal 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": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
285
internal/question/handler/paper_handler.go
Normal file
285
internal/question/handler/paper_handler.go
Normal 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 字段
|
||||
})
|
||||
}
|
||||
245
internal/question/handler/question_handler.go
Normal file
245
internal/question/handler/question_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
292
internal/question/service/answer_record_service.go
Normal file
292
internal/question/service/answer_record_service.go
Normal 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"`
|
||||
}
|
||||
137
internal/question/service/knowledge_tree_service.go
Normal file
137
internal/question/service/knowledge_tree_service.go
Normal 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
|
||||
}
|
||||
120
internal/question/service/material_service.go
Normal file
120
internal/question/service/material_service.go
Normal 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
Loading…
Reference in New Issue
Block a user