342 lines
11 KiB
Go
342 lines
11 KiB
Go
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,
|
||
}
|
||
}
|