Files

390 lines
13 KiB
Go
Raw Permalink Normal View History

2026-09-17 14:42:44 +08:00
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) {
2026-09-17 17:38:45 +08:00
session, err := code2Session(ctx, code)
2026-09-17 14:42:44 +08:00
if err != nil {
return nil, err
}
2026-09-17 17:38:45 +08:00
return loginByOpenid(ctx, session.Openid, session.SessionKey)
2026-09-17 14:42:44 +08:00
}
// DevLogin 开发联调登录(仅 debug 配置开放)
func DevLogin(ctx context.Context) (*v1.WxLoginRes, error) {
if !IsDebug(ctx) {
return nil, gerror.New("当前环境未开启 debug,禁止开发登录")
}
2026-09-17 17:38:45 +08:00
return loginByOpenid(ctx, "dev-user", "")
2026-09-17 14:42:44 +08:00
}
2026-09-17 17:38:45 +08:00
// UserLevel 我的等级与已授权模块(已过期回落默认等级)
2026-09-17 14:42:44 +08:00
func UserLevel(ctx context.Context) (*v1.LevelRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
2026-09-17 17:38:45 +08:00
return EffectiveLevelInfo(ctx, user)
2026-09-17 14:42:44 +08:00
}
// GetUserInfo 当前登录用户信息
func GetUserInfo(ctx context.Context) (*v1.UserInfo, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
2026-09-30 17:01:18 +08:00
return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: absoluteURL(ctx, user.AvatarUrl)}, nil
2026-09-17 14:42:44 +08:00
}
2026-09-17 17:38:45 +08:00
// 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) {
2026-09-17 14:42:44 +08:00
// 联调快捷通道:debug 模式下 code 传 "dev"
if code == "dev" && IsDebug(ctx) {
2026-09-17 17:38:45 +08:00
return &wxSession{Openid: "dev-user"}, nil
2026-09-17 14:42:44 +08:00
}
appId := g.Cfg().MustGet(ctx, "wx.appId").String()
secret := g.Cfg().MustGet(ctx, "wx.appSecret").String()
if appId == "" || secret == "" {
2026-09-17 17:38:45 +08:00
return nil, gerror.New("服务端未配置微信 appId/appSecret,请填写 manifest/config/config.yaml 后重启")
2026-09-17 14:42:44 +08:00
}
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 {
2026-09-17 17:38:45 +08:00
return nil, gerror.Newf("请求微信接口失败: %v", err)
2026-09-17 14:42:44 +08:00
}
defer resp.Close()
2026-09-17 17:38:45 +08:00
out := &wxSession{}
if err = json.Unmarshal(resp.ReadAll(), out); err != nil {
return nil, gerror.Newf("解析微信响应失败: %v", err)
2026-09-17 14:42:44 +08:00
}
if out.ErrCode != 0 || out.Openid == "" {
2026-09-17 17:38:45 +08:00
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
2026-09-17 14:42:44 +08:00
}
2026-09-17 17:38:45 +08:00
return session.Openid, nil
2026-09-17 14:42:44 +08:00
}
2026-09-17 17:38:45 +08:00
// 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) {
2026-09-17 14:42:44 +08:00
// 原子 upsert(原生 SQL):小程序启动时 onLaunch 与页面 onShow 会并发各发一次登录,
// 冲突时仅更新登录时间。不依赖 ORM 的 OnDuplicate 拼接,行为跨版本稳定。
2026-09-17 17:38:45 +08:00
// session_key 为 NULL 时保留原值,避免空登录把可用于签名的 key 覆盖掉。
2026-09-17 14:42:44 +08:00
now := gtime.Now()
2026-09-17 17:38:45 +08:00
var sessionKeyArg interface{}
if sessionKey != "" {
sessionKeyArg = sessionKey
}
2026-09-17 14:42:44 +08:00
if _, err := g.DB().Exec(ctx,
2026-09-17 17:38:45 +08:00
"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,
2026-09-17 14:42:44 +08:00
); err != nil {
return nil, err
}
2026-09-29 10:57:01 +08:00
// 软删除:已删用户 openid 已墓碑化,不会命中;同 openid 重新登录走上面的 INSERT 新建账号
record, err := g.Model(consts.TableUsers).Where("openid", openid).WhereNull("deleted_at").One()
2026-09-17 14:42:44 +08:00
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) {
2026-09-29 10:57:01 +08:00
// 软删除:已删用户 username 已墓碑化,不占用新注册
count, err := g.Model(consts.TableUsers).Where("username", username).WhereNull("deleted_at").Count()
2026-09-17 14:42:44 +08:00
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,
2026-09-17 17:38:45 +08:00
"level_key": consts.DefaultLevelKey,
2026-09-17 14:42:44 +08:00
"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) {
2026-09-29 10:57:01 +08:00
record, err := g.Model(consts.TableUsers).Where("username", username).WhereNull("deleted_at").One()
2026-09-17 14:42:44 +08:00
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
}
2026-09-29 10:57:01 +08:00
record, err := g.Model(consts.TableUsers).Where("username", username).WhereNull("deleted_at").One()
2026-09-17 14:42:44 +08:00
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,
2026-09-30 17:01:18 +08:00
"avatar_url": normalizeToRelativePath(avatarUrl), // 库内只存相对路径(图片域名不落库)
2026-09-17 14:42:44 +08:00
}).Update(); err != nil {
return nil, err
}
2026-09-30 17:01:18 +08:00
// 返回值按现配 base 拼成绝对 URL,前端直接可用(与库内相对路径解耦)
return &v1.ProfileUpdateRes{Nickname: nickname, AvatarUrl: absoluteURL(ctx, normalizeToRelativePath(avatarUrl))}, nil
2026-09-17 14:42:44 +08:00
}
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) {
2026-09-17 17:38:45 +08:00
// 等级取「实际生效」值:会员过期后 token 里不能残留高等级
effectiveKey := EffectiveLevelKey(user)
level, err := GetLevelByKey(ctx, effectiveKey)
2026-09-17 14:42:44 +08:00
if err != nil {
return nil, err
}
2026-09-17 17:38:45 +08:00
token, err := IssueToken(ctx, consts.AudUser, user.Id, map[string]string{"levelKey": effectiveKey})
2026-09-17 14:42:44 +08:00
if err != nil {
return nil, err
}
return &v1.WxLoginRes{
Token: token,
2026-09-30 17:01:18 +08:00
User: &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: absoluteURL(ctx, user.AvatarUrl)},
2026-09-17 14:42:44 +08:00
Level: level,
}, nil
}
func getUserById(ctx context.Context, id int64) (*entity.Users, error) {
2026-09-29 10:57:01 +08:00
record, err := g.Model(consts.TableUsers).Where("id", id).WhereNull("deleted_at").One()
2026-09-17 14:42:44 +08:00
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,
}
}