Files
qitongxue-api/internal/logic/user.go
2026-09-17 14:42:44 +08:00

342 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logic
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/guid"
"golang.org/x/crypto/bcrypt"
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
"tool-api/internal/model/entity"
)
// WxLogin 微信登录code 换 openid
func WxLogin(ctx context.Context, code string) (*v1.WxLoginRes, error) {
openid, err := resolveOpenid(ctx, code)
if err != nil {
return nil, err
}
return loginByOpenid(ctx, openid)
}
// DevLogin 开发联调登录(仅 debug 配置开放)
func DevLogin(ctx context.Context) (*v1.WxLoginRes, error) {
if !IsDebug(ctx) {
return nil, gerror.New("当前环境未开启 debug禁止开发登录")
}
return loginByOpenid(ctx, "dev-user")
}
// UserLevel 我的等级与已授权模块
func UserLevel(ctx context.Context) (*v1.LevelRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
return GetLevelByKey(ctx, user.LevelKey)
}
// GetUserInfo 当前登录用户信息
func GetUserInfo(ctx context.Context) (*v1.UserInfo, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl}, nil
}
func resolveOpenid(ctx context.Context, code string) (string, error) {
// 联调快捷通道debug 模式下 code 传 "dev"
if code == "dev" && IsDebug(ctx) {
return "dev-user", nil
}
appId := g.Cfg().MustGet(ctx, "wx.appId").String()
secret := g.Cfg().MustGet(ctx, "wx.appSecret").String()
if appId == "" || secret == "" {
return "", gerror.New("服务端未配置微信 appId/appSecret请填写 manifest/config/config.yaml 后重启")
}
url := fmt.Sprintf(
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
appId, secret, code,
)
resp, err := g.Client().Get(ctx, url)
if err != nil {
return "", gerror.Newf("请求微信接口失败: %v", err)
}
defer resp.Close()
var out struct {
Openid string `json:"openid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
return "", gerror.Newf("解析微信响应失败: %v", err)
}
if out.ErrCode != 0 || out.Openid == "" {
return "", gerror.Newf("微信登录失败: %d %s", out.ErrCode, out.ErrMsg)
}
return out.Openid, nil
}
func loginByOpenid(ctx context.Context, openid string) (*v1.WxLoginRes, error) {
// 原子 upsert原生 SQL小程序启动时 onLaunch 与页面 onShow 会并发各发一次登录,
// 冲突时仅更新登录时间。不依赖 ORM 的 OnDuplicate 拼接,行为跨版本稳定。
now := gtime.Now()
if _, err := g.DB().Exec(ctx,
"INSERT INTO `users`(`openid`, `nickname`, `level_key`, `status`, `created_at`, `last_login_at`, `updated_at`) "+
"VALUES(?, ?, ?, ?, ?, ?, ?) "+
"ON DUPLICATE KEY UPDATE `last_login_at` = VALUES(`last_login_at`)",
openid, "微信用户", "v1", 1, now, now, now,
); err != nil {
return nil, err
}
record, err := g.Model(consts.TableUsers).Where("openid", openid).One()
if err != nil {
return nil, err
}
user := &entity.Users{}
if err = record.Struct(user); err != nil {
return nil, err
}
if user.Status != 1 {
return nil, gerror.New("账号已被禁用,请联系管理员")
}
return loginResult(ctx, user)
}
// ===== H5 账号:注册 / 登录 / 绑定合并 =====
// placeholderOpenid H5 独立账号的 openid 占位openid 列 NOT NULL UNIQUE微信 openid 不会以 h5- 开头)
func placeholderOpenid() string {
return "h5-" + guid.S()
}
// Register H5 独立账号注册(注册即登录)
func Register(ctx context.Context, username, password, nickname string) (*v1.WxLoginRes, error) {
count, err := g.Model(consts.TableUsers).Where("username", username).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.New("用户名已被占用")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
if nickname == "" {
nickname = username
}
now := gtime.Now()
if _, err = g.Model(consts.TableUsers).Data(g.Map{
"openid": placeholderOpenid(),
"username": username,
"password_hash": string(hash),
"nickname": nickname,
"level_key": "v1",
"status": 1,
"created_at": now,
"last_login_at": now,
}).Insert(); err != nil {
return nil, err
}
return loginByUsername(ctx, username, password)
}
// AccountLogin H5 账号密码登录
func AccountLogin(ctx context.Context, username, password string) (*v1.WxLoginRes, error) {
return loginByUsername(ctx, username, password)
}
func loginByUsername(ctx context.Context, username, password string) (*v1.WxLoginRes, error) {
record, err := g.Model(consts.TableUsers).Where("username", username).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.New("用户名或密码错误")
}
user := &entity.Users{}
if err = record.Struct(user); err != nil {
return nil, err
}
if user.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return nil, gerror.New("用户名或密码错误")
}
if user.Status != 1 {
return nil, gerror.New("账号已被禁用,请联系管理员")
}
// 更新登录时间fire-and-forget 语义,失败不影响登录)
_, _ = g.Model(consts.TableUsers).Where("id", user.Id).Data(g.Map{"last_login_at": gtime.Now()}).Update()
return loginResult(ctx, user)
}
// BindAccount 把一个 H5 独立账号合并进当前登录账号:
// 迁移其工作台配置 → 将用户名/密码挂到当前账号 → 目标账号禁用保留历史。
// 合并后两端使用同一份用户数据(工作台/等级/反馈)。
func BindAccount(ctx context.Context, username, password string) (*v1.BindAccountRes, error) {
current, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
record, err := g.Model(consts.TableUsers).Where("username", username).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.New("该账号不存在,请先在 H5 端注册")
}
target := &entity.Users{}
if err = record.Struct(target); err != nil {
return nil, err
}
// 已是当前账号:视为重复操作
if target.Id == current.Id {
return nil, gerror.New("该账号已是当前账号")
}
// 只允许合并 H5 独立账号openid 为 h5- 占位),微信账号不可被合并
if target.Openid == current.Openid || !(len(target.Openid) > 3 && target.Openid[:3] == "h5-") {
return nil, gerror.New("该账号已绑定微信,无法合并")
}
if target.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(target.PasswordHash), []byte(password)) != nil {
return nil, gerror.New("用户名或密码错误")
}
if current.Username != "" {
return nil, gerror.New("当前账号已绑定过 H5 账号")
}
// 迁移工作台:目标 keys 追加到当前 keys 之后(去重,上限 20
targetKeys := workbenchKeysOf(ctx, target.Id)
if len(targetKeys) > 0 {
currentKeys := workbenchKeysOf(ctx, current.Id)
seen := map[string]bool{}
merged := make([]string, 0, len(currentKeys)+len(targetKeys))
for _, k := range append(append([]string{}, currentKeys...), targetKeys...) {
if !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
if len(merged) > 20 {
merged = merged[:20]
}
if err = saveWorkbenchRaw(ctx, current.Id, merged); err != nil {
return nil, err
}
}
// 先释放目标账号的用户名username 可空,唯一索引不冲突),并禁用保留历史
if _, err = g.Model(consts.TableUsers).Where("id", target.Id).Data(g.Map{
"username": nil,
"status": 0,
}).Update(); err != nil {
return nil, err
}
// 凭证挂到当前账号
if _, err = g.Model(consts.TableUsers).Where("id", current.Id).Data(g.Map{
"username": username,
"password_hash": target.PasswordHash,
}).Update(); err != nil {
return nil, err
}
return &v1.BindAccountRes{Username: username}, nil
}
// ProfileUpdate 更新当前用户昵称 / 头像
func ProfileUpdate(ctx context.Context, nickname, avatarUrl string) (*v1.ProfileUpdateRes, error) {
userId := CtxUserId(ctx)
if _, err := g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
"nickname": nickname,
"avatar_url": avatarUrl,
}).Update(); err != nil {
return nil, err
}
return &v1.ProfileUpdateRes{Nickname: nickname, AvatarUrl: avatarUrl}, nil
}
func workbenchKeysOf(ctx context.Context, userId int64) []string {
record, err := g.Model(consts.TableWorkbench).Where("user_id", userId).One()
if err != nil || record.IsEmpty() {
return nil
}
keys := []string{}
_ = json.Unmarshal([]byte(record["tool_keys"].String()), &keys)
return keys
}
// saveWorkbenchRaw 直接写入工作台配置(绑定合并用,不做逐个工具校验)
func saveWorkbenchRaw(ctx context.Context, userId int64, keys []string) error {
jsonStr, err := json.Marshal(keys)
if err != nil {
return err
}
_, err = g.Model(consts.TableWorkbench).Data(g.Map{
"user_id": userId,
"tool_keys": string(jsonStr),
"updated_at": gtime.Now(),
}).Save()
return err
}
// loginResult 统一组装登录返回token + 用户 + 等级)
func loginResult(ctx context.Context, user *entity.Users) (*v1.WxLoginRes, error) {
level, err := GetLevelByKey(ctx, user.LevelKey)
if err != nil {
return nil, err
}
token, err := IssueToken(ctx, consts.AudUser, user.Id, map[string]string{"levelKey": user.LevelKey})
if err != nil {
return nil, err
}
return &v1.WxLoginRes{
Token: token,
User: &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl},
Level: level,
}, nil
}
func getUserById(ctx context.Context, id int64) (*entity.Users, error) {
record, err := g.Model(consts.TableUsers).Where("id", id).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, gerror.NewCode(gcodeUnauthorized(), "用户不存在")
}
user := &entity.Users{}
if err = record.Struct(user); err != nil {
return nil, err
}
return user, nil
}
// GetLevelByKey 查询等级(含已授权模块 key 列表)
func GetLevelByKey(ctx context.Context, key string) (*v1.LevelInfo, error) {
record, err := g.Model(consts.TableLevels).Where("level_key", key).Where("is_enabled", 1).One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
// 等级被停用或不存在:回落为空权限,避免越权
return &v1.LevelInfo{LevelKey: key, Name: "未授权", Modules: []string{}}, nil
}
return levelInfoFromRecord(record), nil
}
func levelInfoFromRecord(record gdb.Record) *v1.LevelInfo {
modules := []string{}
_ = json.Unmarshal([]byte(record["modules"].String()), &modules)
return &v1.LevelInfo{
LevelKey: record["level_key"].String(),
Name: record["name"].String(),
Modules: modules,
}
}