From 858c29a79928f8618f89093a41ac195ce25ab30d Mon Sep 17 00:00:00 2001 From: zhilv Date: Wed, 25 Mar 2026 22:39:44 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E7=AC=AC=E4=B8=80=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以实现登录获取个人信息和课程 - 实现了获取视频记录 - 实现了学习接口 --- .gitignore | 1 + cmd/agent/main.go | 20 ++ cmd/server/main.go | 5 + go.mod | 47 +++ go.sum | 172 +++++++++ internal/ckwk/api.go | 402 ++++++++++++++++++++++ internal/ckwk/api_test.go | 31 ++ internal/ckwk/resp.go | 111 ++++++ internal/ckwk/session_manager.go | 117 +++++++ internal/ckwk/types.go | 77 +++++ internal/dto/ckwk.go | 56 +++ internal/handler/ckwk.go | 173 ++++++++++ internal/middleware/session_middleware.go | 24 ++ internal/router/router.go | 33 ++ pkg/common/rand.go | 22 ++ pkg/log/func.go | 73 ++++ pkg/log/log.go | 105 ++++++ pkg/request/client.go | 71 ++++ web/.gitignore | 1 + 19 files changed, 1541 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/agent/main.go create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ckwk/api.go create mode 100644 internal/ckwk/api_test.go create mode 100644 internal/ckwk/resp.go create mode 100644 internal/ckwk/session_manager.go create mode 100644 internal/ckwk/types.go create mode 100644 internal/dto/ckwk.go create mode 100644 internal/handler/ckwk.go create mode 100644 internal/middleware/session_middleware.go create mode 100644 internal/router/router.go create mode 100644 pkg/common/rand.go create mode 100644 pkg/log/func.go create mode 100644 pkg/log/log.go create mode 100644 pkg/request/client.go create mode 100644 web/.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6d928d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Taskfile.yml diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..51ad4b2 --- /dev/null +++ b/cmd/agent/main.go @@ -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)) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7905807 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9ec329c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a2513f --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/ckwk/api.go b/internal/ckwk/api.go new file mode 100644 index 0000000..028e016 --- /dev/null +++ b/internal/ckwk/api.go @@ -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) +} diff --git a/internal/ckwk/api_test.go b/internal/ckwk/api_test.go new file mode 100644 index 0000000..daa495f --- /dev/null +++ b/internal/ckwk/api_test.go @@ -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()) +} diff --git a/internal/ckwk/resp.go b/internal/ckwk/resp.go new file mode 100644 index 0000000..c2cfe0e --- /dev/null +++ b/internal/ckwk/resp.go @@ -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"` +} diff --git a/internal/ckwk/session_manager.go b/internal/ckwk/session_manager.go new file mode 100644 index 0000000..7fc976f --- /dev/null +++ b/internal/ckwk/session_manager.go @@ -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)) + } + } + } +} diff --git a/internal/ckwk/types.go b/internal/ckwk/types.go new file mode 100644 index 0000000..0827e38 --- /dev/null +++ b/internal/ckwk/types.go @@ -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 +} diff --git a/internal/dto/ckwk.go b/internal/dto/ckwk.go new file mode 100644 index 0000000..c5bdbbd --- /dev/null +++ b/internal/dto/ckwk.go @@ -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"` +} diff --git a/internal/handler/ckwk.go b/internal/handler/ckwk.go new file mode 100644 index 0000000..c087c34 --- /dev/null +++ b/internal/handler/ckwk.go @@ -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, + })) +} diff --git a/internal/middleware/session_middleware.go b/internal/middleware/session_middleware.go new file mode 100644 index 0000000..d46ded7 --- /dev/null +++ b/internal/middleware/session_middleware.go @@ -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() + } +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..fb2fab6 --- /dev/null +++ b/internal/router/router.go @@ -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 +} diff --git a/pkg/common/rand.go b/pkg/common/rand.go new file mode 100644 index 0000000..4c15e2c --- /dev/null +++ b/pkg/common/rand.go @@ -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() +} diff --git a/pkg/log/func.go b/pkg/log/func.go new file mode 100644 index 0000000..5d30ac0 --- /dev/null +++ b/pkg/log/func.go @@ -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...) +} diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..51a55ce --- /dev/null +++ b/pkg/log/log.go @@ -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) +} diff --git a/pkg/request/client.go b/pkg/request/client.go new file mode 100644 index 0000000..6b7e671 --- /dev/null +++ b/pkg/request/client.go @@ -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 +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..1097e68 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +frontend