diff --git a/.gitea/workflows/devstar-studio-ci.yaml b/.gitea/workflows/devstar-studio-ci.yaml index 673614bbcc..2b188d3023 100644 --- a/.gitea/workflows/devstar-studio-ci.yaml +++ b/.gitea/workflows/devstar-studio-ci.yaml @@ -5,10 +5,12 @@ # Add variables of Remote Git Repository Panel: # - ${{ vars.DOCKER_REGISTRY_ADDRESS }}: the address for Docker Registry -# - ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}: the artifact $name:$version, e.g., `devstar/devstar-studio:latest-rootless` +# - ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}: the artifact $name:$version, e.g., `devstar/devstar-studio:latest-rootless` # - ${{ vars.K8S_NAMESPACE }}: the namespace defined in Helm Chart # - ${{ vars.K8S_DEPLOYMENT_NAME}}: the Deployment to rolled out restart after pushing artifact to Docker Registry +# Note: the actual artifact name for `master` branch: ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}-build-${{ gitea.sha }} + name: DevStar Studio CI Pipeline - master branch on: pull_request: @@ -30,9 +32,9 @@ jobs: make docker - name: 🚀 Push Artifact to Docker Registry run: | - docker tag devstar-studio:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT }} + docker tag devstar-studio:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT }}-build-${{ gitea.sha }} echo "${{ secrets.DOCKER_REGISTRY_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_REGISTRY_USERNAME }} ${{ vars.DOCKER_REGISTRY_ADDRESS }} --password-stdin - docker push ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT }} + docker push ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT }}-build-${{ gitea.sha }} - name: 🔧 Roll out Update on Kubernetes run: | echo "Please manually execute: kubectl rollout restart deployment -n ${{ vars.K8S_NAMESPACE }} ${{ vars.K8S_DEPLOYMENT_NAME}}" @@ -41,7 +43,7 @@ jobs: # -# P.S.: +# P.S.: ################################################################################ # 1. How to config runner: # $ docker run \ diff --git a/.gitea/workflows/devstar-studio-dev-ci.yaml b/.gitea/workflows/devstar-studio-dev-ci.yaml index 24e939801b..0abc01b1c9 100644 --- a/.gitea/workflows/devstar-studio-dev-ci.yaml +++ b/.gitea/workflows/devstar-studio-dev-ci.yaml @@ -5,10 +5,12 @@ # Add variables of Remote Git Repository Panel: # - ${{ vars.DOCKER_REGISTRY_ADDRESS }}: the address for Docker Registry -# - ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}: the artifact $name:$version, e.g., `devstar/devstar-studio:latest-rootless` +# - ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}: the artifact $name:$version, e.g., `devstar/devstar-studio:rootless` # - ${{ vars.K8S_NAMESPACE }}: the namespace defined in Helm Chart # - ${{ vars.K8S_DEPLOYMENT_NAME}}: the Deployment to rolled out restart after pushing artifact to Docker Registry +# Note: the actual artifact name for `dev` branch: ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}-dev-${{ gitea.sha }} + name: DevStar Studio CI Pipeline - dev branch on: push: @@ -33,9 +35,9 @@ jobs: make docker - name: 🚀 Push Artifact to Docker Registry run: | - docker tag devstar-studio:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT }} + docker tag devstar-studio:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}-dev-${{ gitea.sha }} echo "${{ secrets.DOCKER_REGISTRY_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_REGISTRY_USERNAME }} ${{ vars.DOCKER_REGISTRY_ADDRESS }} --password-stdin - docker push ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT }} + docker push ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}-dev-${{ gitea.sha }} - name: 🔧 Roll out Update on Kubernetes run: | echo "Please manually execute: kubectl rollout restart deployment -n ${{ vars.K8S_NAMESPACE }} ${{ vars.K8S_DEPLOYMENT_NAME}}" @@ -44,7 +46,7 @@ jobs: # -# P.S.: +# P.S.: ################################################################################ # 1. How to config runner: # $ docker run \ diff --git a/go.mod b/go.mod index 61b3bec7e7..b5cec4d54c 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121 + github.com/ArtisanCloud/PowerLibs/v3 v3.2.3 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2 github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 @@ -127,9 +128,10 @@ require ( ) require ( - github.com/ArtisanCloud/PowerLibs/v3 v3.2.3 // indirect github.com/ArtisanCloud/PowerSocialite/v3 v3.0.7 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/gomodule/redigo v1.8.4 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect ) diff --git a/go.sum b/go.sum index 5c5f1795ec..6c4026ba8c 100644 --- a/go.sum +++ b/go.sum @@ -347,6 +347,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= diff --git a/models/migrations/devstar_v1_0/dv1.go b/models/migrations/devstar_v1_0/dv1.go new file mode 100644 index 0000000000..688722b522 --- /dev/null +++ b/models/migrations/devstar_v1_0/dv1.go @@ -0,0 +1,24 @@ +package devstar_v1_0 + +// 构建 DevStar Studio v1.0 所需数据库类型 +// 从 Gitea v300 到 + +import ( + wechat_models "code.gitea.io/gitea/models/wechat" + "xorm.io/xorm" +) + +// AddDBWeChatOfficialAccountUser 创建微信公众号二维码登录所需要的数据库字段 +func AddDBWeChatOfficialAccountUser(x *xorm.Engine) error { + + // 创建数据库表格 + err := x.Sync(new(wechat_models.UserWechatOfficialAccountOpenid)) + if err != nil { + return ErrMigrateDevstarDatabase{ + Step: "create table 'user_wechat_official_account_openid'", + Message: err.Error(), + } + } + + return nil +} diff --git a/models/migrations/devstar_v1_0/errors.go b/models/migrations/devstar_v1_0/errors.go new file mode 100644 index 0000000000..770a8c3a88 --- /dev/null +++ b/models/migrations/devstar_v1_0/errors.go @@ -0,0 +1,15 @@ +package devstar_v1_0 + +import ( + "fmt" +) + +// ErrMigrateDevstarDatabase 定义从 Gitea v300 迁移到 DevStar Studio 遇到的错误 +type ErrMigrateDevstarDatabase struct { + Step string + Message string +} + +func (err ErrMigrateDevstarDatabase) Error() string { + return fmt.Sprintf("Failed to migrate DevStar Studio DB at step #{%s}: %s", err.Step, err.Message) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 08882fb119..f51accf57d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -5,6 +5,7 @@ package migrations import ( + "code.gitea.io/gitea/models/migrations/devstar_v1_0" "context" "fmt" @@ -591,6 +592,13 @@ var migrations = []Migration{ // v299 -> v300 NewMigration("Add content version to issue and comment table", v1_23.AddContentVersionToIssueAndComment), + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 下面是 创建新数据库表语句: 从 Gitea 迁移到 DevStar Studio + // v300 -> dv1 (Devstar studio Version #1) + NewMigration("Add Support for WeChat Official Account Login", devstar_v1_0.AddDBWeChatOfficialAccountUser), + + // DevStar Studio v1.0 ends at dv1 } // GetCurrentDBVersion returns the current db version diff --git a/models/wechat/errors.go b/models/wechat/errors.go new file mode 100644 index 0000000000..b153ef0e0e --- /dev/null +++ b/models/wechat/errors.go @@ -0,0 +1,25 @@ +package wechat + +import ( + "fmt" +) + +// ErrFailedToOperateWechatOfficialAccountUserDB 错误类型:打开数据库失败 +type ErrFailedToOperateWechatOfficialAccountUserDB struct { + Action string + Message string +} + +func (err ErrFailedToOperateWechatOfficialAccountUserDB) Error() string { + return fmt.Sprintf("Failed to %v in WeChat Official Account DB: %v", err.Action, err.Message) +} + +// ErrWechatOfficialAccountUserNotExist 错误类型:找不到对应的微信公众号用户 +type ErrWechatOfficialAccountUserNotExist struct { + AppID string + OpenID string +} + +func (err ErrWechatOfficialAccountUserNotExist) Error() string { + return fmt.Sprintf("WeChat Official Account User not found: AppID = %v, OpenID = %v", err.AppID, err.OpenID) +} diff --git a/models/wechat/official_account_openid.go b/models/wechat/official_account_openid.go new file mode 100644 index 0000000000..0b85fecbf8 --- /dev/null +++ b/models/wechat/official_account_openid.go @@ -0,0 +1,136 @@ +package wechat + +import ( + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "context" + "fmt" +) + +// UserWechatOfficialAccountOpenid 用户ID与微信公众号openid一对一关联表 +// 数据库指定schema下 必须存在 `user_wechat_official_account_openid`,否则报错 ERROR 1146 (42S02): Table does not exist +// +// 遵循gonic规则映射数据库表 `user_wechat_official_account_openid`,各字段注解见 https://xorm.io/docs/chapter-02/4.columns/ +type UserWechatOfficialAccountOpenid struct { + Id int64 `xorm:"BIGINT pk NOT NULL autoincr 'id' comment('主键')"` + Uid int64 `xorm:"BIGINT UNIQUE NOT NULL 'uid' comment('user表主键')"` + OfficialAccountAppid string `xorm:"VARCHAR(50) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'official_account_appid' comment('微信公众号关联的AppID,便于区分不同公众号用户、便于后期公众号迁移')"` + Openid string `xorm:"VARCHAR(50) charset=utf8mb4 collate=utf8mb4_bin UNIQUE NOT NULL 'openid' comment('微信公众号粉丝OpenID')"` +} + +// Use `charset=utf8mb4 collate=utf8mb4_bin` to fix log.Error: There are 2 table columns(`official_account_appid`, `openid`) using inconsistent collation, they should use "utf8mb4_bin". Please go to admin panel Self Check page + +// QueryUserByOpenid 根据微信公众号用户openid查询 `user` 表用户 +func QueryUserByOpenid(ctx context.Context, openid string) (*user_model.User, error) { + + // 1. 根据公众号 AppID 和 OpenID 查询数据库中 user表 主键ID + wechatUser := &UserWechatOfficialAccountOpenid{ + OfficialAccountAppid: setting.Wechat.OfficialAccount.UserConfig.AppID, + Openid: openid, + } + + var user *user_model.User + + // 2. 开启数据库事务,查询用户信息 + err := db.WithTx(ctx, func(ctx context.Context) error { + // 2.1 根据 `user_wechat_official_account_openid` 表 查询用户 openid + exist, err := db.GetEngine(ctx).Get(wechatUser) // 注: 断点必须打在操作数据库之后,否则超时导致报错: context canceled + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Find WeChat openid '%s'", openid), + Message: err.Error(), + } + } + + if !exist { + return ErrWechatOfficialAccountUserNotExist{ + AppID: wechatUser.OfficialAccountAppid, + OpenID: wechatUser.Openid, + } + } + + // 2.2 根据 `user` 表 主键ID查询用户信息 + user, err = user_model.GetUserByID(ctx, wechatUser.Uid) + if err != nil { + return err + } + // 数据库事务完成,返回nil自动进行Commit + return nil + }) + + if err != nil { + return nil, err + } + + // 3. 检查用户是否被禁用,参考校验逻辑 services/auth/signin.go#UserSignIn. + if user.ProhibitLogin { + return nil, user_model.ErrUserProhibitLogin{UID: user.ID, Name: user.Name} + } + + return user, err +} + +// UpdateOrCreateWechatOfficialAccountUser 更新/新建用户微信 +func UpdateOrCreateWechatOfficialAccountUser(ctx context.Context, user *user_model.User, openid string) error { + + // 1. 构造查询条件: 微信公众号 AppID, UID + wechatUser := &UserWechatOfficialAccountOpenid{ + OfficialAccountAppid: setting.Wechat.OfficialAccount.UserConfig.AppID, + Uid: user.ID, + } + + // 2. 开启数据库事务,保证数据库表 `user_wechat_official_account_openid` 原子操作(出错时候自动进行 transaction Rollback) + err := db.WithTx(ctx, func(ctx context.Context) error { + // 2.1 根据 微信公众号AppID 和 UID 查询数据库 + has, err := db.GetEngine(ctx).Get(wechatUser) + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Find UserID %v", user.ID), + Message: err.Error(), + } + } + + // 2.2 数据库更新或插入新记录 + if has && wechatUser.Openid == openid { + // 有用户记录,且未发生变化,直接返回,结束事务 + return nil + } else if has && wechatUser.Openid != openid { + // 有用户记录,且发生变化 + wechatUser.Openid = openid + _, err = db.GetEngine(ctx).ID(wechatUser.Id).Update(wechatUser) + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Update existing UID %v", user.ID), + Message: err.Error(), + } + } + } else { + // 没有用户记录,需要插入新纪录 + wechatUser.Openid = openid + _, err = db.GetEngine(ctx).Insert(wechatUser) + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Insert new user UID = %v", user.ID), + Message: err.Error(), + } + } + } + return nil + }) + + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: "Start an DB transaction", + Message: err.Error(), + } + } + + log.Info("微信公众号绑定成功: (UID, User.Name, OpenID) = (%v, %v, %v)", user.ID, user.Name, openid) + return nil +} + +func init() { + db.RegisterModel(new(UserWechatOfficialAccountOpenid)) +} diff --git a/modules/setting/wechat.go b/modules/setting/wechat.go index cfc9057717..46f79c7cd0 100644 --- a/modules/setting/wechat.go +++ b/modules/setting/wechat.go @@ -32,10 +32,10 @@ type OfficialAccountType struct { cache kernel.CacheInterface } +// loadWechatSettingsFrom /** - * 从配置文件中加载微信公众号配置信息,并创建PowerWechat全局工具类实例 - * 配置文件: custom/conf/app.ini - * 全局工具类实例:Wechat.OfficialAccount.PowerWechat + * 创建PowerWechat全局工具类实例 + * 配置文件: custom/conf/app.ini */ func loadWechatSettingsFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("wechat") @@ -51,8 +51,8 @@ func loadWechatSettingsFrom(rootCfg ConfigProvider) { /** * 创建微信公众号工具类 * - * @param userConfig 微信公众号配置信息,详见 `custom/conf/app.ini` - * @return PowerWechat app实例 + * @param userConfig 微信公众号配置信息, 详见 `custom/conf/app.ini` + * @return PowerWechat app 实例. */ func createPowerWechatApp(userConfig PowerWechatOfficialAccountUserConfigType) { @@ -101,21 +101,21 @@ type ConcurrentHashMapType struct { data map[string]string } -// ctor +// NewConcurrentHashMap constructor func NewConcurrentHashMap() *ConcurrentHashMapType { return &ConcurrentHashMapType{ data: make(map[string]string), } } -// 设置值 +// Set 设置值 func (t *ConcurrentHashMapType) Set(key, value string) { t.mu.Lock() // 锁定写操作 defer t.mu.Unlock() // 解锁写操作 t.data[key] = value } -// 获取值 +// Get 获取值 func (t *ConcurrentHashMapType) Get(key string) (value string, ok bool) { t.mu.RLock() // 锁定读操作 defer t.mu.RUnlock() // 解锁读操作 @@ -123,10 +123,20 @@ func (t *ConcurrentHashMapType) Get(key string) (value string, ok bool) { return } -// 删除值 +// Delete 删除值 func (t *ConcurrentHashMapType) Delete(key string) { t.mu.Lock() // 锁定写操作 defer t.mu.Unlock() // 解锁写操作 delete(t.data, key) } + +func (t *ConcurrentHashMapType) GetAndDeleteAtomically(key string) (value string, ok bool) { + t.mu.Lock() + defer t.mu.Unlock() + value, ok = t.data[key] + if ok { + delete(t.data, key) + } + return +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 7d1ec04938..c3665480b2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -773,7 +773,8 @@ change_phone_success= Phone updated successfully. change_wechat= Update WeChat Account wechat_official_account_qr_prompt=Scan QR with WeChat, and follow the Official Account. wechat_official_account_qr_expired=WeChat QR expired. -change_wechat_success= Wechat account updated successfully. +wechat_official_account_bind_confirm=Are you sure to bind to WeChat Official Account '%s'? +wechat_official_account_update_success= Wechat account updated successfully. emails = Email Addresses manage_emails = Manage Email Addresses diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 01efa14a51..513657428b 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -772,7 +772,8 @@ change_phone_success=手机号成功更新 change_wechat=更新微信 wechat_official_account_qr_prompt=使用微信扫描二维码,关注公众号 wechat_official_account_qr_expired=微信二维码已过期 -change_wechat_success=微信成功更新 +wechat_official_account_bind_confirm=你确定要将当前用户绑定到微信 '%s'? +wechat_official_account_update_success=微信成功更新 emails=邮箱地址 manage_emails=管理邮箱地址 diff --git a/routers/web/auth/wechat_qr_auth.go b/routers/web/auth/wechat_qr_auth.go new file mode 100644 index 0000000000..dc87b4fc62 --- /dev/null +++ b/routers/web/auth/wechat_qr_auth.go @@ -0,0 +1,38 @@ +package auth + +import ( + wechat_official_account_openid_model "code.gitea.io/gitea/models/wechat" + "code.gitea.io/gitea/modules/setting" + gitea_web_context "code.gitea.io/gitea/services/context" +) + +// WechatOfficialAccountQrSignIn 处理扫码登录用户Cookie保存等 +// +// 由前端页面 window.location.href 跳转到 /user/login/wechat/official-account/success?ticket=${ticket} +func WechatOfficialAccountQrSignIn(ctx *gitea_web_context.Context) { + + // 1. 取出 微信公众号二维码 ticket + wechatQrTicket := ctx.Base.Req.URL.Query().Get("ticket") + + // 2. 一气呵成:取出用户openid并清空对应的ticket + openid, ok := setting.Wechat.FakeRedisTicket2UserId.GetAndDeleteAtomically(wechatQrTicket) + if !ok { + ctx.Redirect("/") + return + } + + // 3. 拉取 user 表 用户信息 + user, err := wechat_official_account_openid_model.QueryUserByOpenid(ctx, openid) + if err != nil { + ctx.Redirect("/") + return + } + + // 4. 登录成功,跳转目标页面 + redirect := handleSignInFull(ctx, user, false, true) + if ctx.Written() { + return + } + ctx.Redirect(redirect) + return +} diff --git a/routers/web/user/setting/wechat_official_account.go b/routers/web/user/setting/wechat_official_account.go new file mode 100644 index 0000000000..5309964a71 --- /dev/null +++ b/routers/web/user/setting/wechat_official_account.go @@ -0,0 +1,36 @@ +package setting + +import ( + wechat_official_account_openid_model "code.gitea.io/gitea/models/wechat" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + gitea_web_context "code.gitea.io/gitea/services/context" +) + +func BindWechatOfficialAccountQR(ctx *gitea_web_context.Context) { + + // 1. 取出 微信公众号二维码 ticket + wechatQrTicket := ctx.Base.Req.URL.Query().Get("ticket") + + // 2. 一气呵成:取出用户openid并清空对应的ticket + openid, ok := setting.Wechat.FakeRedisTicket2UserId.GetAndDeleteAtomically(wechatQrTicket) + if !ok { + ctx.Redirect("/") + return + } + + // 3. 从 Gitea Web Context 中获取用户信息 + user := ctx.Doer + + // 4. 更新数据库 `user_wechat_official_account_openid` + err := wechat_official_account_openid_model.UpdateOrCreateWechatOfficialAccountUser(ctx, user, openid) + if err != nil { + log.Error("绑定微信失败: " + err.Error()) + ctx.Redirect("/") + return + } + + // 5. 携带扫码成功信息,重定向回用户修改信息页面 + ctx.Data["wechatOfficialAccountQRScanSuccess"] = true + Account(ctx) +} diff --git a/routers/web/web.go b/routers/web/web.go index a9412ffae7..775d032a00 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -506,6 +506,7 @@ func registerRoutes(m *web.Router) { // "user/login" doesn't need signOut, then logged-in users can still access this route for redirection purposes by "/user/login?redirec_to=..." m.Get("/user/login", auth.SignIn) m.Get("/user/login/sms", auth.SignInSms) + m.Get("/user/login/wechat/official-account/success", auth.WechatOfficialAccountQrSignIn) m.Group("/user", func() { m.Post("/login", web.Bind(forms.SignInForm{}), auth.SignInPost) @@ -566,6 +567,7 @@ func registerRoutes(m *web.Router) { m.Group("/account", func() { m.Combo("").Get(user_setting.Account).Post(web.Bind(forms.ChangePasswordForm{}), user_setting.AccountPost) m.Post("/email", web.Bind(forms.AddEmailForm{}), user_setting.EmailPost) + m.Get("/wechat/official-account/bind-success", user_setting.BindWechatOfficialAccountQR) m.Post("/email/delete", user_setting.DeleteEmail) m.Post("/delete", user_setting.DeleteAccount) }) diff --git a/routers/web/wechat/entity/result.go b/routers/web/wechat/entity/result.go index 4b6bccc877..6d96a5bfc8 100644 --- a/routers/web/wechat/entity/result.go +++ b/routers/web/wechat/entity/result.go @@ -12,14 +12,17 @@ type ResultType struct { Data interface{} `json:"data,omitempty"` // Data 字段可选 } -// respondWithJSON 将 ResultType 响应转换为 JSON 并写入响应 +// RespondJson2HttpResponseWriter +// 将 ResultType 响应转换为 JSON 并写入响应 func (t *ResultType) RespondJson2HttpResponseWriter(w http.ResponseWriter) { responseBytes, err := json.Marshal(*t) if err != nil { + // 只有序列化 ResultType 失败时候, 才返回 HTTP 500 Internal Server Error http.Error(w, http.StatusText(http.StatusInternalServerError)+": failed to marshal JSON", http.StatusInternalServerError) return } + // 序列化 ResultType 成功,无论成功或者失败,统一返回 HTTP 200 OK w.WriteHeader(http.StatusOK) - w.Write(responseBytes) + _, _ = w.Write(responseBytes) } diff --git a/routers/web/wechat/official_account/callback.go b/routers/web/wechat/official_account/callback.go index 1b836914e1..ec2e19124b 100644 --- a/routers/web/wechat/official_account/callback.go +++ b/routers/web/wechat/official_account/callback.go @@ -1,10 +1,12 @@ package official_account import ( + "context" XmlUtils "encoding/xml" "net/http" "strconv" + wechat_official_account_user_model "code.gitea.io/gitea/models/wechat" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "github.com/ArtisanCloud/PowerLibs/v3/http/helper" @@ -12,18 +14,17 @@ import ( "fmt" "time" - pretty_fmt "github.com/ArtisanCloud/PowerLibs/v3/fmt" - "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/contract" "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/messages" models2 "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models" "github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount/server/handlers/models" ) +// CallbackVerifyMessage /** * 微信服务器验证消息 * GET /api/wechat/official-account/callback/message - * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html + * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html */ func CallbackVerifyMessage(responseWriter http.ResponseWriter, request *http.Request) { resp, err := setting.Wechat.OfficialAccount.PowerWechat.Server.VerifyURL(request) @@ -42,10 +43,11 @@ func CallbackVerifyMessage(responseWriter http.ResponseWriter, request *http.Req } } +// CallbackNotifyEvents /** * 微信服务器通知事件 * POST /api/wechat/official-account/callback/message - * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html + * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html */ func CallbackNotifyEvents(responseWriter http.ResponseWriter, request *http.Request) { @@ -55,7 +57,7 @@ func CallbackNotifyEvents(responseWriter http.ResponseWriter, request *http.Requ case models2.CALLBACK_MSG_TYPE_EVENT: // "event" 类型消息处理 - return callbackMsgEventHandler(event) + return callbackMsgEventHandler(request, event) case models2.CALLBACK_MSG_TYPE_TEXT: // "text" 类型消息处理 @@ -79,7 +81,9 @@ func CallbackNotifyEvents(responseWriter http.ResponseWriter, request *http.Requ } } -func callbackMsgEventHandler(event contract.EventInterface) *messages.Text { +func callbackMsgEventHandler(request *http.Request, event contract.EventInterface) *messages.Text { + ctx := request.Context() + // 根据不同 event 分别处理 switch event.GetEvent() { @@ -89,7 +93,7 @@ func callbackMsgEventHandler(event contract.EventInterface) *messages.Text { case models.CALLBACK_EVENT_SCAN: // event:SCAN 老用户扫描二维码事件 - return eventScanHandler(event) + return eventScanHandler(ctx, event) case models.CALLBACK_EVENT_UNSUBSCRIBE: // event:unsubscribe 掉粉事件处理 @@ -120,21 +124,11 @@ func eventSubscribeHandler(event contract.EventInterface) *messages.Text { return messages.NewText("error: " + err.Error()) } log.Info("[+] event:subscribe User Subscribed: " + msg.FromUserName + ", at Timestamp " + msg.CreateTime) - - pretty_fmt.Dump("----- BEGIN event:subscribe DUMP-----") - pretty_fmt.Dump(event) - pretty_fmt.Dump("----- END event:subscribe DUMP -----") - - // TODO: 绑定微信公众号页面 与 登录页面分开处理 - if "this page" == "bind page" { - return messages.NewText("欢迎新用户 " + msg.FromUserName + ", 您已绑定微信公众号!") - } else { - return messages.NewText("欢迎新用户 " + msg.FromUserName + " 访问, 请先绑定账号再使用") - } + return messages.NewText("欢迎新用户 " + msg.FromUserName + " 关注公众号, 请先绑定账号再使用") } // event:SCAN 类型消息,已关注公众号的老用户扫码 -func eventScanHandler(event contract.EventInterface) *messages.Text { +func eventScanHandler(ctx context.Context, event contract.EventInterface) *messages.Text { msg := models.EventScanCodePush{} err := event.ReadMessage(&msg) @@ -154,22 +148,35 @@ func eventScanHandler(event contract.EventInterface) *messages.Text { return messages.NewText("error: " + err.Error()) } - log.Info("扫码成功:\n") - log.Info(fmt.Sprintf(" 扫码人: %s\n", qrScanResponseDigest.FromUserName)) - log.Info(fmt.Sprintf(" Ticket: %s\n", qrScanResponseDigest.Ticket)) - log.Info(fmt.Sprintf(" SceneStr: %s\n", qrScanResponseDigest.SceneStr)) - // 将扫码记录加入缓存 // TODO: remove this fake redis setting.Wechat.FakeRedisTicket2UserId.Set(qrScanResponseDigest.Ticket, qrScanResponseDigest.FromUserName) // TODO: 查询是否绑定用户,若无记录,则弹出“请先绑定用户再使用” + wechatOfficialAccountOpenid := qrScanResponseDigest.FromUserName + user, err := wechat_official_account_user_model.QueryUserByOpenid(ctx, wechatOfficialAccountOpenid) + + if err != nil { + // 发生错误:表明用户不存在 + loginFailedInfo := "登录失败:账号未绑定,请先绑定微信!\n" + + "\n" + + fmt.Sprintf("未绑定用户名: %v", msg.FromUserName) + return messages.NewText(loginFailedInfo) + } + + log.Info("扫码登录成功:\n" + + fmt.Sprintf(" 扫码人: `%s` (微信公众号用户识别码openid: %s)\n", user.Name, qrScanResponseDigest.FromUserName) + + fmt.Sprintf(" Ticket: %s\n", qrScanResponseDigest.Ticket) + + fmt.Sprintf(" SceneStr: %s\n", qrScanResponseDigest.SceneStr)) + timestamp, _ := strconv.Atoi(msg.CreateTime) beijingDateTimeStr := time.Unix(int64(timestamp), 0).Add(8 * time.Hour).Format("2006-01-02 15:04") loginSuccessInfo := "微信扫码登录成功通知\n" + "\n" + - fmt.Sprintf("登录账号: %v", msg.FromUserName) + "\n" + - fmt.Sprintf("北京时间: %v", beijingDateTimeStr) + "\n" + + fmt.Sprintf("用户名: %s", user.Name) + "\n" + + fmt.Sprintf("邮 箱: %s", user.Email) + "\n" + + fmt.Sprintf("北京时间: %v", beijingDateTimeStr) + "\n\n" + + fmt.Sprintf("微信公众号用户 OpenID: %v", msg.FromUserName) + "\n" + "\n" + "如非本人,请尽快重新绑定微信!" log.Info(fmt.Sprintf("[+] event:SCAN 老用户 '%v' 登录, sceneStr: %v, Timestamp: %v", msg.FromUserName, msg.CreateTime, msg.EventKey)) diff --git a/routers/web/wechat/official_account/init-wechat-official-account-routes.go b/routers/web/wechat/official_account/init-wechat-official-account-routes.go index 89ef0aa72c..58e8e1e990 100644 --- a/routers/web/wechat/official_account/init-wechat-official-account-routes.go +++ b/routers/web/wechat/official_account/init-wechat-official-account-routes.go @@ -4,14 +4,12 @@ import ( "code.gitea.io/gitea/modules/web" ) -/* - * 注册微信公众号 - * 前缀 "/api/wechat/official-account" - */ +// InitWechatOfficialAccountRoutes +// 注册微信公众号路由前缀 /api/wechat/official-account func InitWechatOfficialAccountRoutes() *web.Router { wechatOfficialAccountWebRouter := web.NewRouter() - // 消息回调 + // 微信服务器回调接口 wechatOfficialAccountWebRouter.Group("/callback", func() { wechatOfficialAccountWebRouter.Get("/message", CallbackVerifyMessage) wechatOfficialAccountWebRouter.Post("/message", CallbackNotifyEvents) diff --git a/routers/web/wechat/official_account/login-qr-handlers.go b/routers/web/wechat/official_account/login-qr-handlers.go index c35dab5d40..2564249e40 100644 --- a/routers/web/wechat/official_account/login-qr-handlers.go +++ b/routers/web/wechat/official_account/login-qr-handlers.go @@ -9,24 +9,24 @@ import ( // 常量定义 var ( - RESP_SUCCESS = WechatEntity.ResultType{ + RespSuccess = WechatEntity.ResultType{ Code: 0, Msg: "扫码登录成功", } - RESP_PENDING_QR_NOT_SCANNED = WechatEntity.ResultType{ + RespPendingQrNotScanned = WechatEntity.ResultType{ Code: 10001, Msg: "用户未扫码", } - RESP_FAILED_ILLEGAL_PARAM = WechatEntity.ResultType{ + RespFailedIllegalParam = WechatEntity.ResultType{ Code: 20001, Msg: "提交的Ticket参数无效", } ) -/* -* +// QrCheckCodeStatus +/** - 微信服务器验证消息 - GET /api/wechat/official-account/login/qr/check-status - 请求参数: @@ -48,7 +48,7 @@ func QrCheckCodeStatus(responseWriter http.ResponseWriter, request *http.Request // 从请求中提取 ticket 参数 ticket := request.URL.Query().Get("ticket") if ticket == "" { - RESP_FAILED_ILLEGAL_PARAM.RespondJson2HttpResponseWriter(responseWriter) + RespFailedIllegalParam.RespondJson2HttpResponseWriter(responseWriter) return } @@ -57,14 +57,14 @@ func QrCheckCodeStatus(responseWriter http.ResponseWriter, request *http.Request wechatOpenId, isExists := setting.Wechat.FakeRedisTicket2UserId.Get(ticket) if !isExists { // 如果 ticket 不在缓存中,返回PENDING状态,提示用户稍后再试 - RESP_PENDING_QR_NOT_SCANNED.RespondJson2HttpResponseWriter(responseWriter) + RespPendingQrNotScanned.RespondJson2HttpResponseWriter(responseWriter) return } // 构造响应数据 result := WechatEntity.ResultType{ - Code: RESP_SUCCESS.Code, - Msg: RESP_SUCCESS.Msg, + Code: RespSuccess.Code, + Msg: RespSuccess.Msg, Data: map[string]string{ "FromUserName": wechatOpenId, }, diff --git a/templates/user/auth/signin_wechat_qr_inner.tmpl b/templates/user/auth/signin_wechat_qr_inner.tmpl index d940ad66e4..d46e501927 100644 --- a/templates/user/auth/signin_wechat_qr_inner.tmpl +++ b/templates/user/auth/signin_wechat_qr_inner.tmpl @@ -1,4 +1,13 @@ +{{if .wechatOfficialAccountQRScanSuccess}} + +
+ +{{else}} + +{{/* ============================================================= 扫码失败,需要扫码 - 开始 ============================================================= */}} {{if .PageIsSignIn}}