Files
qitongxue-api/internal/logic/user.go
2026-09-17 17:38:45 +08:00

387 lines
13 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) {
session, err := code2Session(ctx, code)
if err != nil {
return nil, err
}
return loginByOpenid(ctx, session.Openid, session.SessionKey)
}
// 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 EffectiveLevelInfo(ctx, user)
}
// 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
}
// wxSession jscode2session 返回openid + session_key
type wxSession struct {
Openid string `json:"openid"`
SessionKey string `json:"session_key"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// code2Session code 换 openid + session_key。
// session_key 是虚拟支付「用户态签名 signature」的密钥必须与 openid 一起取回。
func code2Session(ctx context.Context, code string) (*wxSession, error) {
// 联调快捷通道debug 模式下 code 传 "dev"
if code == "dev" && IsDebug(ctx) {
return &wxSession{Openid: "dev-user"}, nil
}
appId := g.Cfg().MustGet(ctx, "wx.appId").String()
secret := g.Cfg().MustGet(ctx, "wx.appSecret").String()
if appId == "" || secret == "" {
return nil, 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 nil, gerror.Newf("请求微信接口失败: %v", err)
}
defer resp.Close()
out := &wxSession{}
if err = json.Unmarshal(resp.ReadAll(), out); err != nil {
return nil, gerror.Newf("解析微信响应失败: %v", err)
}
if out.ErrCode != 0 || out.Openid == "" {
return nil, gerror.Newf("微信登录失败: %d %s", out.ErrCode, out.ErrMsg)
}
return out, nil
}
func resolveOpenid(ctx context.Context, code string) (string, error) {
session, err := code2Session(ctx, code)
if err != nil {
return "", err
}
return session.Openid, nil
}
// RefreshSessionKey 换新 session_key 并落库(虚拟支付下单前调用)。
// 返回该 code 对应的 openid供调用方校验「支付人 = 登录人」。
func RefreshSessionKey(ctx context.Context, userId int64, code string) (string, error) {
if code == "" {
// 未传 code复用已落库的 session_key可能已过期支付时由平台判失败
user, err := getUserById(ctx, userId)
if err != nil {
return "", err
}
return user.Openid, nil
}
session, err := code2Session(ctx, code)
if err != nil {
return "", err
}
if _, err = g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
"session_key": session.SessionKey,
}).Update(); err != nil {
return "", err
}
return session.Openid, nil
}
func loginByOpenid(ctx context.Context, openid, sessionKey string) (*v1.WxLoginRes, error) {
// 原子 upsert原生 SQL小程序启动时 onLaunch 与页面 onShow 会并发各发一次登录,
// 冲突时仅更新登录时间。不依赖 ORM 的 OnDuplicate 拼接,行为跨版本稳定。
// session_key 为 NULL 时保留原值,避免空登录把可用于签名的 key 覆盖掉。
now := gtime.Now()
var sessionKeyArg interface{}
if sessionKey != "" {
sessionKeyArg = sessionKey
}
if _, err := g.DB().Exec(ctx,
"INSERT INTO `users`(`openid`, `session_key`, `nickname`, `level_key`, `status`, `created_at`, `last_login_at`, `updated_at`) "+
"VALUES(?, ?, ?, ?, ?, ?, ?, ?) "+
"ON DUPLICATE KEY UPDATE `last_login_at` = VALUES(`last_login_at`), "+
"`session_key` = IF(VALUES(`session_key`) IS NULL, `session_key`, VALUES(`session_key`))",
openid, sessionKeyArg, "微信用户", consts.DefaultLevelKey, 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": consts.DefaultLevelKey,
"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) {
// 等级取「实际生效」值:会员过期后 token 里不能残留高等级
effectiveKey := EffectiveLevelKey(user)
level, err := GetLevelByKey(ctx, effectiveKey)
if err != nil {
return nil, err
}
token, err := IssueToken(ctx, consts.AudUser, user.Id, map[string]string{"levelKey": effectiveKey})
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,
}
}