init: 第一次提交

- 以实现登录获取个人信息和课程
- 实现了获取视频记录
- 实现了学习接口
This commit is contained in:
2026-03-25 22:39:44 +08:00
commit 858c29a799
19 changed files with 1541 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
Taskfile.yml

20
cmd/agent/main.go Normal file
View File

@@ -0,0 +1,20 @@
package main
import (
"ckwk/internal/router"
"ckwk/pkg/log"
"net/http"
"go.uber.org/zap"
)
func main() {
router := router.SetupRouter()
if err := http.ListenAndServe(
":8080",
router,
); err != nil {
log.Error("服务启动失败", zap.Error(err))
}
}

5
cmd/server/main.go Normal file
View File

@@ -0,0 +1,5 @@
package main
func main() {
}

47
go.mod Normal file
View File

@@ -0,0 +1,47 @@
module ckwk
go 1.25.0
require (
github.com/antchfx/htmlquery v1.3.6
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
resty.dev/v3 v3.0.0-beta.6
)
require (
github.com/antchfx/xpath v1.3.6 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

172
go.sum Normal file
View File

@@ -0,0 +1,172 @@
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
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=
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=

402
internal/ckwk/api.go Normal file
View File

@@ -0,0 +1,402 @@
package ckwk
import (
"ckwk/pkg/common"
"ckwk/pkg/log"
"ckwk/pkg/request"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/antchfx/htmlquery"
"go.uber.org/zap"
"resty.dev/v3"
)
type WK struct {
Username string `json:"username"`
Password string `json:"password"`
Host string `json:"host"`
Req *resty.Client
Cookies []*http.Cookie
LoginRegexp *regexp.Regexp
CourseIDRegexp *regexp.Regexp
TimeRegexp *regexp.Regexp
}
func NewWK(username, password, host string, cookies []*http.Cookie) *WK {
hasAuth := (username != "" && password != "") || len(cookies) > 0
if host == "" || !hasAuth {
return nil
}
req := request.NewClient(&request.Config{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
Proxy: "http://127.0.0.1:9000",
VerifySSL: false,
})
if len(cookies) > 0 {
req.SetCookies(cookies)
}
wk := &WK{
Username: username,
Password: password,
Host: host,
Req: req,
Cookies: cookies,
LoginRegexp: regexp.MustCompile("var data =(.*?);"),
CourseIDRegexp: regexp.MustCompile(`\?courseId=(\d+)`),
TimeRegexp: regexp.MustCompile(`\d{4}-\d{2}-\d{2}`),
}
if len(cookies) == 0 && username != "" {
wk.Login()
}
return wk
}
// Cookies: returns cookies
func (wk *WK) Cookie() []*http.Cookie {
return wk.Cookies
}
// SetCookies: returns cookies
func (wk *WK) SetCookies(cs []*http.Cookie) {
wk.Cookies = cs
wk.Req.SetCookies(cs)
}
// Code: Get Verify Code
func (wk *WK) Code() (string, error) {
resp, err := wk.Req.
R().
SetQueryParam("r", fmt.Sprint(common.RandFloat64())).
Get(fmt.Sprintf("https://%s/service/code", wk.Host))
if err != nil {
return "", fmt.Errorf("获取验证码失败: %w", err)
}
if resp.StatusCode() != 200 {
return "", fmt.Errorf("获取验证码失败: code: %d", resp.StatusCode())
}
var result CodeResp
_, err = wk.Req.
R().
SetFormData(map[string]string{
"image": base64.StdEncoding.EncodeToString(resp.Bytes()),
"probability": "false",
"png_fix": "false",
}).
SetResult(&result).
Post("http://localhost:8000/ocr")
if err != nil {
return "", fmt.Errorf("获取验证码验证结果失败: %w", err)
}
if result.Code != 200 {
return "", fmt.Errorf("ocr 失败: code: %d, message: %s", result.Code, result.Message)
}
return result.Data, nil
}
// Login: Login WebSite
func (wk *WK) Login() (bool, error) {
yzm := ""
for i := 1; i <= 3; i++ {
yzm, _ = wk.Code()
if yzm != "" {
break
}
log.Warnf("第 %d 次获取验证码失败, 正在重试...\n", i)
}
if yzm == "" {
return false, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。")
}
resp, err := wk.Req.
R().
SetFormData(map[string]string{
"username": wk.Username,
"password": wk.Password,
"code": yzm,
"redirect": "",
}).
Post(fmt.Sprintf("https://%s/user/login", wk.Host))
if err != nil {
return false, fmt.Errorf("请求登录失败: %w", err)
}
matchs := wk.LoginRegexp.FindStringSubmatch(string(resp.Bytes()))
if len(matchs) <= 1 {
return false, fmt.Errorf("没有找到匹配字符串")
}
var result LoginResp
err = json.Unmarshal([]byte(matchs[1]), &result)
if err != nil {
return false, fmt.Errorf("解析 json data 失败: %w", err)
}
if !result.Status {
return false, fmt.Errorf("登录失败: %s", result.Msg)
}
wk.SetCookies(resp.Cookies())
log.Info("登录成功", zap.Any("cookies", wk.Cookies))
return true, nil
}
// CourseParse: 课程解析
func (wk *WK) CourseParse(content string) ([]Course, error) {
courses := make([]Course, 0)
doc, err := htmlquery.Parse(strings.NewReader(content))
if err != nil {
return courses, fmt.Errorf("解析 HTML 失败: %w", err)
}
itemsNode := htmlquery.Find(doc, `//div[@class="user-course"]/div[@class="item"]`)
if len(itemsNode) < 1 {
return courses, nil
// return courses, fmt.Errorf("未解析到课程")
}
for _, node := range itemsNode {
aNode := htmlquery.FindOne(node, `.//div[@class="name"]/a`)
if aNode == nil {
continue
}
href := htmlquery.SelectAttr(aNode, "href")
match := wk.CourseIDRegexp.FindStringSubmatch(href)
var id int
if len(match) > 1 {
idVal, err := strconv.Atoi(match[1])
if err == nil {
id = idVal
} else {
log.Warn("ID 转换失败", zap.Error(err))
}
}
tagsNode := htmlquery.FindOne(node, `.//div[@class="tags"]/span`)
progressNode := htmlquery.FindOne(node, `.//div[@class="progress"]/div[@class="txt"]`)
timeNode := htmlquery.FindOne(node, `.//div[@class="time"]`)
var startTime, stopTime string
if timeNode != nil {
fullText := htmlquery.InnerText(timeNode)
dates := wk.TimeRegexp.FindAllString(fullText, -1)
if len(dates) >= 2 {
startTime = dates[0]
stopTime = dates[1]
}
}
var credit float32
creditNode := htmlquery.FindOne(node, `.//div[@class="note"]/div[@class="number"]/span`)
creditStr := strings.TrimSpace(htmlquery.InnerText(creditNode))
creditVal, err := strconv.ParseFloat(creditStr, 32)
if err == nil {
credit = float32(creditVal)
} else {
log.Warn("课程学分转换失败", zap.Error(err))
}
typeNode := htmlquery.FindOne(node, `.//div[@class="note"]/div[@class="kind"]/span`)
course := Course{
ID: id,
Name: htmlquery.InnerText(aNode),
Teacher: htmlquery.InnerText(tagsNode),
Progress: htmlquery.InnerText(progressNode),
StartTime: startTime,
StopTime: stopTime,
Credit: credit,
Type: htmlquery.InnerText(typeNode),
}
courses = append(courses, course)
}
return courses, nil
}
// CourseGet: 课程获取
func (wk *WK) CourseGet(kind CourseKind) ([]Course, error) {
var courses []Course
resp, err := wk.Req.
R().
SetQueryParam("kind", string(kind)).
Get(fmt.Sprintf("https://%s/user/index", wk.Host))
if err != nil {
return courses, fmt.Errorf("获取课程失败: %w", err)
}
courses, err = wk.CourseParse(string(resp.Bytes()))
if err != nil {
return courses, fmt.Errorf("解析课程失败: %w", err)
}
return courses, nil
}
// UserParse: 用户信息解析
func (wk *WK) UserInfoParse(content string) (User, error) {
var user User
doc, err := htmlquery.Parse(strings.NewReader(content))
if err != nil {
return user, fmt.Errorf("解析用户信息失败: %w", err)
}
userInfoNode := htmlquery.FindOne(doc, `//div[@class="useredata-form"]`)
if userInfoNode == nil {
return user, fmt.Errorf("未解析到用户信息")
}
nameNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][1]/div[@class="txt"]/text()`)
if nameNode != nil {
user.Name = strings.TrimSpace(htmlquery.InnerText(nameNode))
}
idNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][2]/div[@class="txt"]`)
if idNode != nil {
user.ID = strings.TrimSpace(htmlquery.InnerText(idNode))
}
deptNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][3]/div[@class="txt"]`)
if deptNode != nil {
user.Dept = strings.TrimSpace(htmlquery.InnerText(deptNode))
}
classNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][4]/div[@class="txt"]`)
if classNode != nil {
user.Class = strings.TrimSpace(htmlquery.InnerText(classNode))
}
genderNode := htmlquery.FindOne(userInfoNode, `.//select[@id="gender"]/option[@selected="selected"]`)
if genderNode != nil {
user.Gender = htmlquery.SelectAttr(genderNode, "value")
}
return user, nil
}
// UserGet: 用户信息获取
func (wk *WK) UserInfoGet() (User, error) {
var user User
resp, err := wk.Req.
R().
Get(fmt.Sprintf("https://%s/user/member", wk.Host))
if err != nil {
return user, fmt.Errorf("获取用户信息页面失败: %w", err)
}
user, err = wk.UserInfoParse(string(resp.Bytes()))
if err != nil {
return user, err
}
wk.Username = user.ID
return user, nil
}
// Online: 保持账号状态
func (wk *WK) Online() (bool, error) {
resp, err := wk.Req.
R().
SetHeaders(map[string]string{
"x-requested-with": "XMLHttpRequest",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br, zstd",
}).
Post(fmt.Sprintf("https://%s/user/online", wk.Host))
if err != nil {
return false, fmt.Errorf("保持账号状态失败: %w", err)
}
log.Info("保持账号状态", zap.Any("resp", string(resp.Bytes())))
return true, nil
}
// Study: 学习
func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*StudyResp, error) {
var data map[string]string
switch status {
case StudyStart:
yzm := ""
for i := 1; i <= 3; i++ {
yzm, _ = wk.Code()
if yzm != "" {
break
}
log.Warnf("第 %d 次获取验证码失败, 正在重试...\n", i)
}
if yzm == "" {
return nil, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。")
}
data = map[string]string{
"nodeId": nodeID,
"studyId": "0",
"studyTime": "1",
"code": yzm,
}
case Study:
data = map[string]string{
"nodeId": nodeID,
"studyId": studyID,
"studyTime": studyTime,
}
case StudyOver:
data = map[string]string{
"nodeId": nodeID,
"studyId": studyID,
"studyTime": studyTime,
"close": "1",
}
default:
return nil, fmt.Errorf("传入的学习状态不匹配")
}
resp, err := wk.Req.
R().
SetHeaders(map[string]string{
"x-requested-with": "XMLHttpRequest",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br, zstd",
}).
SetFormData(data).
Post(fmt.Sprintf("https://%s/user/node/study", wk.Host))
if err != nil {
return nil, fmt.Errorf("进行学习失败: %w", err)
}
var result StudyResp
if err := json.Unmarshal(resp.Bytes(), &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
log.Info("学习响应", zap.Any("resp", result))
return &result, nil
}
// GetStudyList: 获取学习记录
func (wk *WK) GetStudyList(courseID, page string) (*AllRecordResp[StudyList], error) {
return GetRecords[StudyList](wk, RecordStudy, courseID, page)
}
// GetExamList: 获取作业记录
// todo
func (wk *WK) GetWorkList(courseID, page string) (*AllRecordResp[ExamList], error) {
return GetRecords[ExamList](wk, RecordStudy, courseID, page)
}
// GetExamList: 获取考试记录
func (wk *WK) GetExamList(courseID, page string) (*AllRecordResp[ExamList], error) {
return GetRecords[ExamList](wk, RecordExam, courseID, page)
}
// GetExamList: 获取讨论记录
// todo
func (wk *WK) GetDiscussList(courseID, page string) (*AllRecordResp[ExamList], error) {
return GetRecords[ExamList](wk, RecordStudy, courseID, page)
}

31
internal/ckwk/api_test.go Normal file
View File

@@ -0,0 +1,31 @@
package ckwk
import (
"fmt"
"testing"
"time"
)
func TestWK(t *testing.T) {
// var cookies []*http.Cookie
// cookie := http.Cookie{
// Name: "token",
// Value: "sid.odITb8QqlLLPPoQMpnMKZjx98cUGuC",
// // Domain: ".leykeji.com",
// Path: "/",
// }
// cookies = append(cookies, &cookie)
// wk := NewWK("2025301201", "Zhilv666", "cqcst.leykeji.com", cookies)
// wk.Login()
// user, err := wk.UserInfoGet()
// log.Debug("获取用户信息", zap.Any("user", user), zap.Error(err))
// courses, err := wk.CourseGet(Finish)
// log.Debug("获取课程信息", zap.Any("courses", courses), zap.Error(err))
fmt.Printf("time.Now().UnixMilli(): %v\n", time.Now().UnixMilli())
}

111
internal/ckwk/resp.go Normal file
View File

@@ -0,0 +1,111 @@
package ckwk
// 获取验证码响应
type CodeResp struct {
Code int `json:"code"`
Message string `json:"message"`
Data string `json:"data"`
}
// 登录响应
type LoginResp struct {
RefreshCode int `json:"refresh_code"`
Status bool `json:"status"`
Msg string `json:"msg"`
Back string `json:"back"`
}
type StudyResp struct {
State int `json:"state"`
StudyID int `json:"studyId"`
Status bool `json:"status"`
Msg string `json:"msg"`
}
type AllRecordResp[T any] struct {
List []T `json:"list"`
PageInfo PageInfo `json:"pageInfo"`
Status bool `json:"status"`
Msg string `json:"msg"`
}
type PageInfo struct {
KeyName string `json:"keyName"`
Page int `json:"page"`
PageCount int `json:"pageCount"`
RecordsCount int `json:"recordsCount"`
OnlyCount int `json:"onlyCount"`
PageSize int `json:"pageSize"`
}
// 学习记录列表
type StudyList struct {
ID string `json:"id"`
Name string `json:"name"`
Type interface{} `json:"type"`
ChapterID string `json:"chapterId"`
CourseID string `json:"courseId"`
VideoFile string `json:"videoFile"`
VideoDuration string `json:"videoDuration"`
VotingPath interface{} `json:"votingPath"`
TabVideo string `json:"tabVideo"`
TabFile string `json:"tabFile"`
TabVote string `json:"tabVote"`
TabWork string `json:"tabWork"`
TabExam string `json:"tabExam"`
Sort string `json:"sort"`
VideoMode string `json:"videoMode"`
LocalFile string `json:"localFile"`
SchoolID string `json:"schoolId"`
Lock string `json:"lock"`
UnlockTime string `json:"unlockTime"`
Bid string `json:"bid"`
Duration string `json:"duration"`
Progress string `json:"progress"`
State string `json:"state"`
ViewCount string `json:"viewCount"`
FinalTime string `json:"finalTime"`
Error int `json:"error"`
ErrorMessage string `json:"errorMessage"`
BeginTime string `json:"beginTime"`
ViewedDuration string `json:"viewedDuration"`
URL string `json:"url"`
}
// 考试记录列表
type ExamList struct {
ID string `json:"id"`
UserID interface{} `json:"userId"`
Title string `json:"title"`
TopicNumber string `json:"topicNumber"`
Score string `json:"score"`
AddTime string `json:"addTime"`
NodeID string `json:"nodeId"`
CourseID string `json:"courseId"`
LimitedTime string `json:"limitedTime"`
Sequence string `json:"sequence"`
Remarks string `json:"remarks"`
PaperID string `json:"paperId"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
CreateUserID string `json:"createUserId"`
ClassList string `json:"classList"`
IsPrivate string `json:"isPrivate"`
TeacherType string `json:"teacherType"`
Allow string `json:"allow"`
Frequency string `json:"frequency"`
HasCollect string `json:"hasCollect"`
SchoolID string `json:"schoolId"`
Parsing string `json:"parsing"`
AddDate string `json:"addDate"`
Random string `json:"random"`
RandData interface{} `json:"randData"`
RandNumber string `json:"randNumber"`
Name string `json:"name"`
ChapterID string `json:"chapterId"`
State string `json:"state"`
SubmitTime string `json:"submitTime"`
FinalScore string `json:"finalScore"`
FinishTime string `json:"finishTime"`
URL string `json:"url"`
}

View File

@@ -0,0 +1,117 @@
package ckwk
import (
"ckwk/pkg/log"
"context"
"sync"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
type SessionManager struct {
mu sync.RWMutex
sessions map[string]SessionItem
userToSession map[string]string
}
type SessionItem struct {
Instance *WK
LastValue time.Time
cancel context.CancelFunc
}
func NewSessionManager() *SessionManager {
return &SessionManager{
sessions: make(map[string]SessionItem),
userToSession: make(map[string]string),
}
}
// Store: 保存 session 并返回 session id
func (m *SessionManager) Store(wk *WK) string {
m.mu.Lock()
defer m.mu.Unlock()
userKey := wk.Host + ":" + wk.Username
if oldID, exists := m.userToSession[userKey]; exists {
item := m.sessions[oldID]
item.LastValue = time.Now()
item.Instance = wk
m.sessions[oldID] = item
log.Info("用户已存在,复用旧 Session",
zap.String("id", oldID),
zap.String("user", userKey),
)
return oldID
}
sessionID := uuid.New().String()
ctx, cancel := context.WithCancel(context.Background())
m.userToSession[userKey] = sessionID
m.sessions[sessionID] = SessionItem{
Instance: wk,
LastValue: time.Now(),
cancel: cancel,
}
log.Info("创建新 Session", zap.String("id", sessionID))
go m.KeepAlive(ctx, sessionID, wk)
return sessionID
}
// Get: 获取指定 session id 的 wk
func (m *SessionManager) Get(sessionID string) (*WK, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
item, ok := m.sessions[sessionID]
if ok {
return item.Instance, true
}
return nil, false
}
func (m *SessionManager) Del(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
if item, ok := m.sessions[sessionID]; ok {
userKey := item.Instance.Host + ":" + item.Instance.Username
if item.cancel != nil {
item.cancel()
}
delete(m.userToSession, userKey)
delete(m.sessions, sessionID)
log.Info("删除 Session", zap.String("id", sessionID))
}
}
func (m *SessionManager) KeepAlive(ctx context.Context, id string, wk *WK) {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
log.Info("启动 KeepAlive", zap.String("id", id))
for {
select {
case <-ctx.Done():
log.Info("KeepAlive 已停止", zap.String("id", id))
return
case <-ticker.C:
_, err := wk.Online()
if err != nil {
log.Error("自动保活请求失败", zap.Error(err))
}
}
}
}

77
internal/ckwk/types.go Normal file
View File

@@ -0,0 +1,77 @@
package ckwk
import (
"ckwk/pkg/log"
"encoding/json"
"fmt"
"time"
"go.uber.org/zap"
)
type CourseKind string
type RecordType string
type StudyStatus int
const (
Run CourseKind = "run" // 我的课程
Finish CourseKind = "finish" // 已结束
Sign CourseKind = "sign" // 报名中
RecordStudy RecordType = "" // 学习记录
RecordWork RecordType = "/work" // 作业记录
RecordExam RecordType = "/exam" // 考试记录
RecordDiscuss RecordType = "/discuss" // 讨论记录
StudyStart StudyStatus = 1 // 开始学习
Study StudyStatus = 2 // 学习中
StudyOver StudyStatus = 3 // 学习介绍
)
// User: 用户
type User struct {
Name string `json:"name"`
ID string `json:"id"`
Dept string `json:"dept"`
Class string `json:"class"`
Gender string `json:"gender"`
}
// Course: 课程
type Course struct {
Name string `json:"name"`
ID int `json:"id"`
Teacher string `json:"teacher"`
Progress string `json:"progress"`
StartTime string `json:"start_time"`
StopTime string `json:"stop_time"`
Credit float32 `json:"credit"`
Type string `json:"type"`
}
func GetRecords[T any](wk *WK, rType RecordType, courseID, page string) (*AllRecordResp[T], error) {
log.Debug("获取记录信息", zap.String("host", wk.Host), zap.String("type", string(rType)))
resp, err := wk.Req.
R().
SetQueryParams(map[string]string{
"courseId": courseID,
"page": page,
"_": fmt.Sprint(time.Now().UnixMilli()),
}).
Get(fmt.Sprintf("https://%s/user/study_record%s.json", wk.Host, rType))
if err != nil {
return nil, err
}
var result AllRecordResp[T]
if err := json.Unmarshal(resp.Bytes(), &result); err != nil {
log.Error("JSON解析失败", zap.Error(err))
return nil, err
}
if !result.Status {
return &result, fmt.Errorf("接口报错: %s", result.Msg)
}
return &result, nil
}

56
internal/dto/ckwk.go Normal file
View File

@@ -0,0 +1,56 @@
package dto
import (
"ckwk/internal/ckwk"
)
type Resp[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
Data T `json:"data,omitempty"`
}
func Success[T any](data T) Resp[T] {
return Resp[T]{
Code: 0,
Message: "success",
Data: data,
}
}
func Ok() Resp[any] {
return Resp[any]{
Code: 0,
Message: "success",
Data: nil,
}
}
func Error(code int, msg string) Resp[any] {
return Resp[any]{
Code: code,
Message: msg,
Data: nil,
}
}
type LoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
Status ckwk.CourseKind `json:"status"`
Host string `json:"host" binding:"required"`
}
type StudyReq struct {
NodeID string `json:"node_id"`
StudyID string `json:"study_id"`
StudyTime string `json:"study_time"`
Status ckwk.StudyStatus `json:"status"`
}
type AllRecordReq struct {
CourseID string `json:"course_id"`
Page int `json:"page"`
RecordType ckwk.RecordType `json:"record_type"`
}

173
internal/handler/ckwk.go Normal file
View File

@@ -0,0 +1,173 @@
package handler
import (
"ckwk/internal/ckwk"
"ckwk/internal/dto"
"ckwk/pkg/log"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type WKHandler struct {
Session *ckwk.SessionManager
}
func NewWKHandler() *WKHandler {
return &WKHandler{
Session: ckwk.NewSessionManager(),
}
}
func (h *WKHandler) Login(ctx *gin.Context) {
var req dto.LoginReq
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(200, dto.Error(-1, "请求参数错误"))
return
}
log.Debug("请求数据", zap.Any("req", req))
var cookies = []*http.Cookie{
{
Name: "token",
Value: req.Token,
Path: "/"},
}
wk := ckwk.NewWK(req.Username, req.Password, req.Host, cookies)
if wk == nil {
ctx.JSON(200, dto.Error(-1, "登录失败:请提供账号密码或有效的 Token并确保 Host 正确"))
return
}
userinfo, err := wk.UserInfoGet()
if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error()))
return
}
courses, err := wk.CourseGet(req.Status)
if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error()))
return
}
sessionID := h.Session.Store(wk)
ctx.JSON(200, dto.Success(map[string]any{
"session_id": sessionID,
"user": userinfo,
"courses": courses,
}))
}
func (h *WKHandler) Online(ctx *gin.Context) {
val, ok := ctx.Get("wk_instance")
if !ok {
ctx.JSON(http.StatusOK, dto.Error(-1, "登录已过期"))
return
}
wk := val.(*ckwk.WK)
flag, err := wk.Online()
if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error()))
return
}
if !flag {
ctx.JSON(200, dto.Error(-1, "保持账号状态失败"))
return
}
ctx.JSON(200, dto.Ok())
}
func (h *WKHandler) Logout(ctx *gin.Context) {
sessionID := ctx.GetString("session_id")
h.Session.Del(sessionID)
ctx.JSON(200, dto.Ok())
}
func (h *WKHandler) Study(ctx *gin.Context) {
val, ok := ctx.Get("wk_instance")
if !ok {
ctx.JSON(200, dto.Error(-1, "登录已过期"))
return
}
wk := val.(*ckwk.WK)
var req dto.StudyReq
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(200, dto.Error(-1, "请求参数错误"))
return
}
result, err := wk.Study(req.NodeID, req.StudyID, req.StudyTime, req.Status)
if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error()))
return
}
ctx.JSON(200, dto.Success(result))
}
func (h *WKHandler) AllRecord(ctx *gin.Context) {
val, ok := ctx.Get("wk_instance")
if !ok {
ctx.JSON(200, dto.Error(-1, "登录已过期"))
return
}
wk := val.(*ckwk.WK)
var req dto.AllRecordReq
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(200, dto.Error(-1, "请求参数错误"))
return
}
if req.Page < 1 {
req.Page = 1
}
var list any
var pageInfo any
var err error
// 根据类型调用不同的泛型方法
switch req.RecordType {
case ckwk.RecordStudy:
res, e := wk.GetStudyList(req.CourseID, fmt.Sprint(req.Page))
err = e
if e == nil {
list, pageInfo = res.List, res.PageInfo
}
case ckwk.RecordWork:
res, e := wk.GetWorkList(req.CourseID, fmt.Sprint(req.Page))
err = e
if e == nil {
list, pageInfo = res.List, res.PageInfo
}
case ckwk.RecordExam:
res, e := wk.GetExamList(req.CourseID, fmt.Sprint(req.Page))
err = e
if e == nil {
list, pageInfo = res.List, res.PageInfo
}
case ckwk.RecordDiscuss:
res, e := wk.GetDiscussList(req.CourseID, fmt.Sprint(req.Page))
err = e
if e == nil {
list, pageInfo = res.List, res.PageInfo
}
default:
ctx.JSON(200, dto.Error(-1, "不支持的记录类型"))
return
}
if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error()))
return
}
ctx.JSON(200, dto.Success(map[string]any{
"list": list,
"page_info": pageInfo,
}))
}

View File

@@ -0,0 +1,24 @@
package middleware
import (
"ckwk/internal/ckwk"
"ckwk/internal/dto"
"net/http"
"github.com/gin-gonic/gin"
)
func SessionMiddleware(sm *ckwk.SessionManager) gin.HandlerFunc {
return func(ctx *gin.Context) {
sessionID := ctx.GetHeader("X-Session-Id")
wk, ok := sm.Get(sessionID)
if !ok {
ctx.JSON(http.StatusUnauthorized, dto.Error(401, "登录过期"))
ctx.Abort()
return
}
ctx.Set("wk_instance", wk)
ctx.Set("session_id", sessionID)
ctx.Next()
}
}

33
internal/router/router.go Normal file
View File

@@ -0,0 +1,33 @@
package router
import (
"ckwk/internal/handler"
"ckwk/internal/middleware"
"github.com/gin-gonic/gin"
)
func SetupRouter() *gin.Engine {
r := gin.Default()
wkHandler := handler.NewWKHandler()
sessionMiddleware := middleware.SessionMiddleware(wkHandler.Session)
api := r.Group("/api")
{
api.POST("/login", wkHandler.Login)
v1 := api.Group("/v1", sessionMiddleware)
{
v1.POST("/online", wkHandler.Online)
v1.POST("/logout", wkHandler.Logout)
}
v2 := api.Group("/v2", sessionMiddleware)
{
v2.POST("/logout", wkHandler.Logout)
v2.POST("/study", wkHandler.Study)
v2.POST("/record", wkHandler.AllRecord)
}
}
return r
}

22
pkg/common/rand.go Normal file
View File

@@ -0,0 +1,22 @@
package common
import (
"math/rand"
"time"
)
// Rand 生成指定长度的随机字符串,字符集为 [0-9a-zA-Z]
func Rand(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
seededRand := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
}
return string(b)
}
func RandFloat64() float64 {
return rand.Float64()
}

73
pkg/log/func.go Normal file
View File

@@ -0,0 +1,73 @@
package log
import (
"go.uber.org/zap"
)
// GetLogger 获取原生 zap logger
func GetLogger() *zap.Logger {
return logger
}
// Sync 刷新缓冲区
func Sync() {
_ = logger.Sync()
}
// ============================================================================
// 结构化日志 (Structured Logging) - 推荐高性能场景使用
// 用法: log.Info("user login", zap.String("username", "admin"))
// ============================================================================
func Debug(msg string, fields ...zap.Field) {
logger.Debug(msg, fields...)
}
func Info(msg string, fields ...zap.Field) {
logger.Info(msg, fields...)
}
func Warn(msg string, fields ...zap.Field) {
logger.Warn(msg, fields...)
}
func Error(msg string, fields ...zap.Field) {
logger.Error(msg, fields...)
}
func Fatal(msg string, fields ...zap.Field) {
logger.Fatal(msg, fields...)
}
func Panic(msg string, fields ...zap.Field) {
logger.Panic(msg, fields...)
}
// ============================================================================
// 格式化日志 (Sugared Logging) - 方便使用,类似 fmt.Printf
// 用法: log.Infof("user %s login failed, count: %d", "admin", 3)
// ============================================================================
func Debugf(template string, args ...interface{}) {
sugar.Debugf(template, args...)
}
func Infof(template string, args ...interface{}) {
sugar.Infof(template, args...)
}
func Warnf(template string, args ...interface{}) {
sugar.Warnf(template, args...)
}
func Errorf(template string, args ...interface{}) {
sugar.Errorf(template, args...)
}
func Fatalf(template string, args ...interface{}) {
sugar.Fatalf(template, args...)
}
func Panicf(template string, args ...interface{}) {
sugar.Panicf(template, args...)
}

105
pkg/log/log.go Normal file
View File

@@ -0,0 +1,105 @@
package log
import (
"os"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
logger *zap.Logger
sugar *zap.SugaredLogger
)
// 通用时间格式
const (
TimeFormatDate = "2006-01-02"
TimeFormatDateTime = "2006-01-02 15:04:05"
)
type Config struct {
Level string
Filepath string
MaxSizeMB int
MaxAgeDay int
Backups int
Compress bool
}
func init() {
encoderConfig := zap.NewDevelopmentEncoderConfig()
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig),
zapcore.AddSync(os.Stdout),
zap.DebugLevel,
)
logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
sugar = logger.Sugar()
}
func Init(cfg Config) {
var zapLevel zapcore.Level
// 日志等级解析
switch cfg.Level {
case "debug":
zapLevel = zap.DebugLevel
case "info":
zapLevel = zap.InfoLevel
case "warning", "warn":
zapLevel = zap.WarnLevel
case "error":
zapLevel = zap.ErrorLevel
default:
zapLevel = zap.InfoLevel
}
// lumberjack 日志切割配置
writeSyncer := zapcore.AddSync(&lumberjack.Logger{
Filename: cfg.Filepath,
MaxSize: cfg.MaxSizeMB,
MaxBackups: cfg.Backups,
MaxAge: cfg.MaxAgeDay,
Compress: cfg.Compress,
})
// 基础编码配置 (提取公共部分)
baseEencoderConfig := zapcore.EncoderConfig{
TimeKey: "time",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "msg",
StacktraceKey: "Stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalLevelEncoder,
EncodeTime: func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Format(TimeFormatDateTime))
},
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
// 1. 控制台编码器(开启颜色)
consoleEncoderConfig := baseEencoderConfig
consoleEncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
encoderConsole := zapcore.NewConsoleEncoder(consoleEncoderConfig)
// 2.文件编码器json 格式, 无色)
fileEncoderConfig := baseEencoderConfig
fileEncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
encoderJson := zapcore.NewJSONEncoder(fileEncoderConfig)
core := zapcore.NewTee(
zapcore.NewCore(encoderJson, writeSyncer, zapLevel),
zapcore.NewCore(encoderConsole, zapcore.AddSync(os.Stdout), zapLevel))
logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
sugar = logger.Sugar()
zap.ReplaceGlobals(logger)
}

71
pkg/request/client.go Normal file
View File

@@ -0,0 +1,71 @@
package request
import (
"crypto/tls"
"net/http"
"time"
"resty.dev/v3"
)
var (
NoRedirectClient *resty.Client
RestyClient *resty.Client
)
const (
DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"
DefaultTimeout = 10 * time.Second
)
type Config struct {
Timeout time.Duration
Proxy string
Debug bool
UserAgent string
VerifySSL bool
}
func DefaultConfg() *Config {
return &Config{
Timeout: DefaultTimeout,
UserAgent: DefaultUserAgent,
VerifySSL: true,
Debug: false,
}
}
// NewClient 创建一个标准的 Resty 客户端
func NewClient(cfg *Config) *resty.Client {
if cfg == nil {
cfg = DefaultConfg()
}
client := resty.New()
client.SetHeader("User-Agent", cfg.UserAgent)
client.SetTimeout(cfg.Timeout)
client.SetRetryCount(3)
client.SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: !cfg.VerifySSL,
})
if cfg.Proxy != "" {
client.SetProxy(cfg.Proxy)
}
return client
}
// NewNoRedirectClient 创建一个禁止重定向的客户端
func NewNoRedirectClient(cfg *Config) *resty.Client {
client := NewClient(cfg)
client.SetRedirectPolicy(
resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}),
)
return client
}

1
web/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
frontend