批量优化功能
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
authsvc "server/services/auth"
|
||||
"server/services/wechatmp"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
// WechatMpCallbackController 微信公众号服务器回调(微信服务器调用,无需平台鉴权)
|
||||
@@ -100,6 +104,10 @@ func (c *WechatMpCallbackController) Callback() {
|
||||
return
|
||||
}
|
||||
|
||||
// 排查用:记录微信推送的消息/事件类型
|
||||
logs.Info("[wechat-mp] 收到推送: type=%s event=%s from=%s key=%s",
|
||||
msg.MsgType, msg.Event, msg.FromUserName, msg.EventKey)
|
||||
|
||||
reply := wechatmp.HandleInbound(cfg, msg)
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
// 无需回复:返回空串,微信视为处理成功
|
||||
@@ -122,3 +130,144 @@ func (c *WechatMpCallbackController) Callback() {
|
||||
}
|
||||
c.Ctx.Output.Body([]byte(plainReply))
|
||||
}
|
||||
|
||||
// Login GET/POST /api/wechat/mp/login?token=xxx&client_id=yyy
|
||||
// 公众号「确认登录」一次性令牌兑换登录态(无平台鉴权:令牌本身即凭证,一次性 + 短时效)。
|
||||
// 已绑定身份:单企业直接签发令牌;多企业签发待选令牌并由前端展示企业选择。
|
||||
// 同时认领等待中的 PC 扫码会话(存在时),PC 端轮询后自动登录。
|
||||
func (c *WechatMpCallbackController) Login() {
|
||||
token := strings.TrimSpace(c.GetString("token"))
|
||||
clientID := strings.TrimSpace(c.GetString("client_id"))
|
||||
if clientID == "" {
|
||||
clientID = "yz-backend"
|
||||
}
|
||||
|
||||
row, err := wechatmp.ConsumeLoginToken(token)
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
switch row.BindType {
|
||||
case models.WechatBindTypeTenantUser:
|
||||
// 认领 PC 扫码会话:确认链接携带 scene 时精确认领;「登录」文本路径无 scene,FIFO 兜底
|
||||
if scene := strings.TrimSpace(c.GetString("scene")); scene != "" {
|
||||
wechatmp.ConfirmScanLoginByToken(scene, token, row.BindType, row.BindID, row.BindTid)
|
||||
} else {
|
||||
wechatmp.ConfirmOldestPendingScanLogin(row.BindType, row.BindID, row.BindTid)
|
||||
}
|
||||
|
||||
// 身份状态校验(禁用/锁定则拒绝登录)
|
||||
var identity models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", row.BindID).One(&identity); err != nil {
|
||||
c.failJSON(400, "账号不存在或已注销")
|
||||
return
|
||||
}
|
||||
if identity.Status != models.AuthIdentityStatusEnabled {
|
||||
c.failJSON(400, "账号已被禁用或锁定,无法登录")
|
||||
return
|
||||
}
|
||||
username := "微信用户"
|
||||
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
|
||||
username = *identity.Nickname
|
||||
}
|
||||
|
||||
// 企业选择:单企业直接登录;多企业签发待选令牌,由前端展示选择界面
|
||||
tenants, lerr := authsvc.ListTenantOptions(row.BindID)
|
||||
if lerr != nil || len(tenants) == 0 {
|
||||
c.failJSON(400, "该账号未加入任何企业,无法登录")
|
||||
return
|
||||
}
|
||||
tid := uint64(0)
|
||||
needChoose := false
|
||||
if len(tenants) == 1 {
|
||||
tid = tenants[0].Tid
|
||||
} else {
|
||||
tid = authsvc.PendingTenantID
|
||||
needChoose = true
|
||||
}
|
||||
|
||||
tokens, terr := c.issueMpLoginTokens(row.BindID, tid, clientID, username, "公众号确认链接登录成功", needChoose)
|
||||
if terr != nil {
|
||||
c.failJSON(400, terr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"type": "backend",
|
||||
"tokens": tokens,
|
||||
"tenant": row.BindTid,
|
||||
}
|
||||
if needChoose {
|
||||
data["need_choose_tenant"] = true
|
||||
data["tenants"] = tenants
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "登录成功",
|
||||
"data": data,
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
default:
|
||||
c.failJSON(400, "该微信绑定的账号类型暂不支持链接登录")
|
||||
}
|
||||
}
|
||||
|
||||
// issueMpLoginTokens 为绑定身份建会话并签发 UAC 令牌(含登录日志,失败不影响主流程)
|
||||
// tid 为 authsvc.PendingTenantID 时签发待选企业用的短期令牌
|
||||
func (c *WechatMpCallbackController) issueMpLoginTokens(identityID, tid uint64, clientID, username, logMsg string, needChoose bool) (*authsvc.TokenPair, error) {
|
||||
accessTTL := 0
|
||||
if needChoose {
|
||||
accessTTL = 600 // 待选状态令牌只够用户完成企业选择
|
||||
}
|
||||
sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
|
||||
IdentityID: identityID,
|
||||
Tid: tid,
|
||||
ClientID: clientID,
|
||||
IP: c.Ctx.Input.IP(),
|
||||
UserAgent: c.Ctx.Request.UserAgent(),
|
||||
LoginType: "wechat_mp",
|
||||
Amr: "wechat_mp",
|
||||
})
|
||||
if serr != nil {
|
||||
return nil, fmt.Errorf("登录失败:%s", serr.Error())
|
||||
}
|
||||
tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{
|
||||
IdentityID: identityID,
|
||||
Tid: tid,
|
||||
ClientID: clientID,
|
||||
Sid: sess.Sid,
|
||||
Username: username,
|
||||
Amr: "wechat_mp",
|
||||
AccessTTL: accessTTL,
|
||||
})
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("签发令牌失败:%s", terr.Error())
|
||||
}
|
||||
|
||||
identityIDCopy := identityID
|
||||
tidCopy := tid
|
||||
amr := "wechat_mp"
|
||||
_, _ = models.Orm.Insert(&models.AuthLoginLog{
|
||||
Tid: &tidCopy,
|
||||
IdentityID: &identityIDCopy,
|
||||
UserName: username,
|
||||
ClientID: clientID,
|
||||
LoginType: "wechat_mp",
|
||||
Amr: &amr,
|
||||
Status: 1,
|
||||
Message: logMsg,
|
||||
IP: c.Ctx.Input.IP(),
|
||||
UserAgent: c.Ctx.Request.UserAgent(),
|
||||
})
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (c *WechatMpCallbackController) failJSON(bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
@@ -399,12 +399,24 @@ func bearerTokenLogin(c *AuthLoginController) string {
|
||||
}
|
||||
|
||||
func setSessionCookieForCtx(ctx *context.Context, sid string) {
|
||||
// Secure 仅在 https 下添加:本地 http 联调时浏览器会丢弃 Secure Cookie,
|
||||
// 导致 authorize 识别不到会话又被送回登录页
|
||||
flag := cookieSecureFlag(ctx)
|
||||
ctx.Output.Header("Set-Cookie",
|
||||
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
|
||||
sessionCookieName, sid, sessionCookieTTL))
|
||||
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly%s; SameSite=Lax",
|
||||
sessionCookieName, sid, sessionCookieTTL, flag))
|
||||
}
|
||||
|
||||
func clearSessionCookieForCtx(ctx *context.Context) {
|
||||
flag := cookieSecureFlag(ctx)
|
||||
ctx.Output.Header("Set-Cookie",
|
||||
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
|
||||
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly%s; SameSite=Lax", sessionCookieName, flag))
|
||||
}
|
||||
|
||||
// cookieSecureFlag https 请求(或网关带 X-Forwarded-Proto: https)时返回 "; Secure"
|
||||
func cookieSecureFlag(ctx *context.Context) string {
|
||||
if ctx.Request.TLS != nil || strings.EqualFold(ctx.Request.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
return "; Secure"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
authsvc "server/services/auth"
|
||||
"server/services/wechatmp"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 统一认证中心「微信扫码登录」(未认证公众号方案):
|
||||
// 登录页展示「服务号关注二维码 + 6 位登录码」→ 用户微信扫码进入公众号会话
|
||||
// → 发送登录码 → 服务号回复「确认登录」链接 → 用户点击(/mp-login 兑换时
|
||||
// 携带 scene 回写确认)→ 本组接口轮询确认后建立 UAC 会话 Cookie,
|
||||
// 前端跳回 authorize 完成 OAuth 发放。全程无极验验证码。
|
||||
// =============================================================
|
||||
|
||||
// WechatScanStart POST /auth/wechat/scan/start
|
||||
// 发起扫码登录会话并生成带参二维码(认证公众号);未认证回退普通关注二维码。
|
||||
// 用户扫码后公众号自动回复「确认登录」链接,无需输入任何登录码。
|
||||
func (c *AuthLoginController) WechatScanStart() {
|
||||
scene, qrURL, notice, expireAt, err := wechatmp.StartScanLogin()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "发起扫码登录失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qrcode_url": qrURL, // 带参二维码(认证公众号);空 = 已回退
|
||||
"follow_qrcode": wechatmp.FollowQrcodeURL(),
|
||||
"notice": notice, // 回退原因(非空时前端醒目提示)
|
||||
"expire_in": int(time.Until(expireAt).Seconds()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WechatScanStatus GET /auth/wechat/scan/status?scene=xxx&redirect=yyy
|
||||
// 登录页轮询:待确认 status=0;已确认 → 建立 UAC 会话(写 Cookie)并返回回跳地址
|
||||
// (多企业时回跳企业选择步骤);过期 status=2。
|
||||
func (c *AuthLoginController) WechatScanStatus() {
|
||||
redirectParam := c.GetString("redirect")
|
||||
clientID := strings.TrimSpace(c.GetString("client_id"))
|
||||
row, err := wechatmp.PickScanLogin(c.GetString("scene"))
|
||||
if err != nil {
|
||||
if err == wechatmp.ErrScanLoginInvalid {
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "data": map[string]interface{}{"status": 2}})
|
||||
return
|
||||
}
|
||||
// 待确认等业务提示:status=0 继续轮询
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "data": map[string]interface{}{"status": 0, "msg": err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
// 身份状态校验(禁用/锁定则拒绝)
|
||||
var identity models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", row.BindID).One(&identity); err != nil || identity.Status != models.AuthIdentityStatusEnabled {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "账号已被禁用或锁定,无法登录"})
|
||||
return
|
||||
}
|
||||
|
||||
// 企业选择:单企业直接进入;多企业进入登录页企业选择步骤
|
||||
tenants, lerr := authsvc.ListTenantOptions(row.BindID)
|
||||
if lerr != nil || len(tenants) == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该账号未加入任何企业,无法登录"})
|
||||
return
|
||||
}
|
||||
tid := uint64(authsvc.PendingTenantID)
|
||||
if len(tenants) == 1 {
|
||||
tid = tenants[0].Tid
|
||||
}
|
||||
|
||||
username := "微信用户"
|
||||
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
|
||||
username = *identity.Nickname
|
||||
}
|
||||
ip := c.Ctx.Input.IP()
|
||||
userAgent := c.Ctx.Request.UserAgent()
|
||||
|
||||
// 建立 UAC 会话并写 Cookie(与账号密码登录同一套会话体系,authorize 据此发码)
|
||||
sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
|
||||
IdentityID: row.BindID,
|
||||
Tid: tid,
|
||||
ClientID: clientID,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
LoginType: "wechat_mp",
|
||||
Amr: "wechat_mp",
|
||||
})
|
||||
if serr != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "登录失败:" + serr.Error()})
|
||||
return
|
||||
}
|
||||
if _, terr := authsvc.IssueTokens(authsvc.TokenIssue{
|
||||
IdentityID: row.BindID,
|
||||
Tid: tid,
|
||||
ClientID: clientID,
|
||||
Sid: sess.Sid,
|
||||
Username: username,
|
||||
Amr: "wechat_mp",
|
||||
}); terr != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "签发令牌失败:" + terr.Error()})
|
||||
return
|
||||
}
|
||||
setSessionCookieForCtx(c.Ctx, sess.Sid)
|
||||
|
||||
// 登录日志(失败不影响主流程)
|
||||
identityID := row.BindID
|
||||
amr := "wechat_mp"
|
||||
_, _ = models.Orm.Insert(&models.AuthLoginLog{
|
||||
Tid: &tid,
|
||||
IdentityID: &identityID,
|
||||
UserName: username,
|
||||
ClientID: clientID,
|
||||
LoginType: "wechat_mp",
|
||||
Amr: &amr,
|
||||
Status: 1,
|
||||
Message: "公众号扫码登录成功",
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
|
||||
// 默认回跳到原 authorize;多企业时回跳登录页企业选择步骤
|
||||
back := decodeRedirect(redirectParam)
|
||||
if len(tenants) > 1 {
|
||||
back = "/auth/login?step=tenant&client_id=" + url.QueryEscape(clientID) +
|
||||
"&redirect=" + url.QueryEscape(redirectParam)
|
||||
}
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"status": 1,
|
||||
"redirect": back,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
@@ -76,6 +75,8 @@ func (c *BackendWechatMpController) readBody(out interface{}) bool {
|
||||
}
|
||||
|
||||
// BindStart POST /backend/wechatMp/bind/start
|
||||
// 未认证公众号无法生成带参二维码(48001):不再下发二维码,
|
||||
// 返回绑定指引(公众号内发送「验证码」取码 → 页面填码 → bind/confirm 核销)。
|
||||
func (c *BackendWechatMpController) BindStart() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
@@ -88,17 +89,16 @@ func (c *BackendWechatMpController) BindStart() {
|
||||
c.jsonErr(401, 401, "未获取到用户信息")
|
||||
return
|
||||
}
|
||||
scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypeTenantUser, uid, tid, 0)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "生成二维码失败:"+err.Error())
|
||||
if _, err := wechatmp.LoadEnabledConfig(); err != nil {
|
||||
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qrcode_url": qrURL,
|
||||
"expire_at": expireAt,
|
||||
"expires_in": int(time.Until(expireAt).Seconds()),
|
||||
}, "生成成功")
|
||||
"mode": "mp_message",
|
||||
"guides": wechatmp.BindGuides(),
|
||||
"follow_qrcode": wechatmp.FollowQrcodeURL(),
|
||||
"expires_in": int(wechatmp.DefaultVerifyTTL.Seconds()),
|
||||
}, "请按指引完成绑定")
|
||||
}
|
||||
|
||||
// BindStatus GET /backend/wechatMp/bind/status?scene=xxx
|
||||
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -105,6 +106,105 @@ func validPassword(p passwordPayload) bool {
|
||||
return p.Platform != ""
|
||||
}
|
||||
|
||||
// sameAccount 判定两个账号是否重复:
|
||||
// 登录账号都非空时按账号比较(忽略大小写);账号为空时按密码比较。
|
||||
func sameAccount(a, b models.PasswordAccountItem) bool {
|
||||
u1 := strings.TrimSpace(a.Username)
|
||||
u2 := strings.TrimSpace(b.Username)
|
||||
if u1 != "" && u2 != "" {
|
||||
return strings.EqualFold(u1, u2)
|
||||
}
|
||||
if u1 != u2 {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(a.Password) == strings.TrimSpace(b.Password)
|
||||
}
|
||||
|
||||
// validateAccountsUnique 校验同一平台下的登录账号不重复
|
||||
func validateAccountsUnique(list []models.PasswordAccountItem) error {
|
||||
seen := map[string]bool{}
|
||||
for _, item := range list {
|
||||
u := strings.TrimSpace(item.Username)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(u)
|
||||
if seen[key] {
|
||||
return fmt.Errorf("同一平台下登录账号「%s」重复,请合并后再保存", u)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dedupeAccounts 去掉同组内的重复账号(保留首次出现的一条),返回去重后的列表与去重数量
|
||||
func dedupeAccounts(list []models.PasswordAccountItem) ([]models.PasswordAccountItem, int) {
|
||||
out := make([]models.PasswordAccountItem, 0, len(list))
|
||||
removed := 0
|
||||
for _, item := range list {
|
||||
dup := false
|
||||
for _, keep := range out {
|
||||
if sameAccount(keep, item) {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dup {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, removed
|
||||
}
|
||||
|
||||
// findSamePlatformRecord 查询同用户(同租户)下是否已存在「平台名称 + 网址」相同的记录
|
||||
func findSamePlatformRecord(qs orm.QuerySeter, excludeID uint64, platform, url string) (uint64, error) {
|
||||
q := qs.Filter("is_deleted", 0).Filter("platform", platform).Filter("url", url)
|
||||
if excludeID > 0 {
|
||||
q = q.Exclude("id", excludeID)
|
||||
}
|
||||
// 用 Values 只取 ID,避免不同模型的 One() 类型差异
|
||||
var rows []orm.Params
|
||||
if _, err := q.OrderBy("id").Limit(1).Values(&rows, "id"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, orm.ErrNoRows
|
||||
}
|
||||
id, _ := strconv.ParseUint(fmt.Sprintf("%v", rows[0]["Id"]), 10, 64)
|
||||
if id == 0 {
|
||||
id, _ = strconv.ParseUint(fmt.Sprintf("%v", rows[0]["id"]), 10, 64)
|
||||
}
|
||||
if id == 0 {
|
||||
return 0, orm.ErrNoRows
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// passwordPageParams 解析分页参数:page 默认 1,pageSize 默认 20、上限 200
|
||||
func passwordPageParams(c *beego.Controller) (page, pageSize int) {
|
||||
page, _ = c.GetInt("page", 1)
|
||||
pageSize, _ = c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// passwordPageReply 分页列表统一响应(list + total + 当前分页信息)
|
||||
func passwordPageReply(c *beego.Controller, msg string, list interface{}, total int64, page, pageSize int) {
|
||||
passwordReply(c, 200, msg, map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 平台端 Password Store ====================
|
||||
|
||||
type PlatformPasswordStoreController struct{ beego.Controller }
|
||||
@@ -125,9 +225,11 @@ func (c *PlatformPasswordStoreController) List() {
|
||||
Or("remark__icontains", k)
|
||||
qs = qs.SetCond(orm.NewCondition().And("is_deleted", 0).And("user_id", cl.UserID).AndCond(cond))
|
||||
}
|
||||
// 分页:默认每页 20 条,单页最多 200 条
|
||||
page, pageSize := passwordPageParams(&c.Controller)
|
||||
total, _ := qs.Count()
|
||||
var list []models.PlatformPasswordStore
|
||||
_, e = qs.OrderBy("-id").Limit(200).All(&list)
|
||||
_, e = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&list)
|
||||
if e != nil && e != orm.ErrNoRows {
|
||||
logs.Error("[passwordStore] platform list failed: %v", e)
|
||||
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||
@@ -146,7 +248,7 @@ func (c *PlatformPasswordStoreController) List() {
|
||||
UpdateTime: row.UpdateTime,
|
||||
})
|
||||
}
|
||||
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": total})
|
||||
passwordPageReply(&c.Controller, "success", res, total, page, pageSize)
|
||||
}
|
||||
|
||||
func (c *PlatformPasswordStoreController) Detail() {
|
||||
@@ -186,6 +288,18 @@ func (c *PlatformPasswordStoreController) Create() {
|
||||
passwordReply(&c.Controller, 400, "平台名称不能为空", nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:平台名称 + 网址 已存在则拒绝新增
|
||||
if existID, ferr := findSamePlatformRecord(
|
||||
models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("user_id", cl.UserID),
|
||||
0, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请直接编辑该记录", p.Platform, existID), nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:同一平台下的登录账号不能重复
|
||||
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||
return
|
||||
}
|
||||
accJSON := accountsToJSON(p.Accounts)
|
||||
v := &models.PlatformPasswordStore{
|
||||
Platform: p.Platform,
|
||||
@@ -232,6 +346,18 @@ func (c *PlatformPasswordStoreController) Update() {
|
||||
passwordReply(&c.Controller, 404, "记录不存在", nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:改成的平台名称 + 网址 不能与自己的其它记录冲突
|
||||
if existID, ferr := findSamePlatformRecord(
|
||||
models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("user_id", cl.UserID),
|
||||
id, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请先合并或改名", p.Platform, existID), nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:同一平台下的登录账号不能重复
|
||||
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
accJSON := accountsToJSON(p.Accounts)
|
||||
_, e = models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("id", id).Update(map[string]interface{}{
|
||||
@@ -299,9 +425,11 @@ func (c *BackendPasswordStoreController) List() {
|
||||
Or("remark__icontains", k)
|
||||
qs = qs.SetCond(orm.NewCondition().And("is_deleted", 0).And("tid", cl.TenantId).And("user_id", cl.UserID).AndCond(cond))
|
||||
}
|
||||
// 分页:默认每页 20 条,单页最多 200 条
|
||||
page, pageSize := passwordPageParams(&c.Controller)
|
||||
total, _ := qs.Count()
|
||||
var list []models.BackendPasswordStore
|
||||
_, e = qs.OrderBy("-id").Limit(200).All(&list)
|
||||
_, e = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&list)
|
||||
if e != nil && e != orm.ErrNoRows {
|
||||
logs.Error("[passwordStore] backend list failed: %v", e)
|
||||
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||
@@ -321,7 +449,7 @@ func (c *BackendPasswordStoreController) List() {
|
||||
UpdateTime: row.UpdateTime,
|
||||
})
|
||||
}
|
||||
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": total})
|
||||
passwordPageReply(&c.Controller, "success", res, total, page, pageSize)
|
||||
}
|
||||
|
||||
func (c *BackendPasswordStoreController) Detail() {
|
||||
@@ -362,6 +490,18 @@ func (c *BackendPasswordStoreController) Create() {
|
||||
passwordReply(&c.Controller, 400, "平台名称不能为空", nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:同租户下平台名称 + 网址 已存在则拒绝新增
|
||||
if existID, ferr := findSamePlatformRecord(
|
||||
models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID),
|
||||
0, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请直接编辑该记录", p.Platform, existID), nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:同一平台下的登录账号不能重复
|
||||
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||
return
|
||||
}
|
||||
accJSON := accountsToJSON(p.Accounts)
|
||||
v := &models.BackendPasswordStore{
|
||||
Tid: cl.TenantId,
|
||||
@@ -410,6 +550,18 @@ func (c *BackendPasswordStoreController) Update() {
|
||||
passwordReply(&c.Controller, 404, "记录不存在", nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:改成的平台名称 + 网址 不能与自己的其它记录冲突
|
||||
if existID, ferr := findSamePlatformRecord(
|
||||
models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID),
|
||||
id, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请先合并或改名", p.Platform, existID), nil)
|
||||
return
|
||||
}
|
||||
// 重复检测:同一平台下的登录账号不能重复
|
||||
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
accJSON := accountsToJSON(p.Accounts)
|
||||
_, e = models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("id", id).Update(map[string]interface{}{
|
||||
@@ -457,3 +609,314 @@ func (c *BackendPasswordStoreController) Delete() {
|
||||
}
|
||||
passwordReply(&c.Controller, 200, "删除成功", nil)
|
||||
}
|
||||
|
||||
// ==================== 批量导入 / 导出 ====================
|
||||
//
|
||||
// Excel 列顺序(与数据库字段一一对应):
|
||||
// 平台名称 platform | 网址 url | 账号 username | 密码 password |
|
||||
// 注册信息 registration_info | 账号备注 account_remark | 备注 remark
|
||||
// 一个平台可挂多个账号:多行「平台名称 + 网址」相同即合并为一条记录的多个账号;
|
||||
// 已存在的同平台同网址记录按「追加账号(跳过完全重复项)」处理。
|
||||
|
||||
type passwordImportRow struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
RegistrationInfo string `json:"registration_info"`
|
||||
AccountRemark string `json:"account_remark"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type passwordImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Failures []string `json:"failures"`
|
||||
}
|
||||
|
||||
type passwordImportGroup struct {
|
||||
Platform string
|
||||
URL string
|
||||
Remark string
|
||||
Accounts []models.PasswordAccountItem
|
||||
}
|
||||
|
||||
func parsePasswordImportRows(c *beego.Controller) ([]passwordImportRow, error) {
|
||||
b, e := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Rows []passwordImportRow `json:"rows"`
|
||||
}
|
||||
if e == nil {
|
||||
e = json.Unmarshal(b, &p)
|
||||
}
|
||||
return p.Rows, e
|
||||
}
|
||||
|
||||
// groupPasswordImportRows 按「平台名称 + 网址」聚合成记录,返回有序分组与行级错误
|
||||
func groupPasswordImportRows(rows []passwordImportRow) ([]*passwordImportGroup, []string) {
|
||||
order := make([]string, 0, len(rows))
|
||||
index := map[string]*passwordImportGroup{}
|
||||
failures := make([]string, 0)
|
||||
for i, r := range rows {
|
||||
platform := strings.TrimSpace(r.Platform)
|
||||
if platform == "" {
|
||||
failures = append(failures, fmt.Sprintf("第%d行:平台名称不能为空", i+2))
|
||||
continue
|
||||
}
|
||||
url := strings.TrimSpace(r.URL)
|
||||
username := strings.TrimSpace(r.Username)
|
||||
pwd := strings.TrimSpace(r.Password)
|
||||
reg := strings.TrimSpace(r.RegistrationInfo)
|
||||
accRemark := strings.TrimSpace(r.AccountRemark)
|
||||
remark := strings.TrimSpace(r.Remark)
|
||||
|
||||
key := platform + "\n" + url
|
||||
g, ok := index[key]
|
||||
if !ok {
|
||||
g = &passwordImportGroup{Platform: platform, URL: url}
|
||||
index[key] = g
|
||||
order = append(order, key)
|
||||
}
|
||||
if username != "" || pwd != "" || reg != "" || accRemark != "" {
|
||||
item := models.PasswordAccountItem{
|
||||
Username: username,
|
||||
Password: pwd,
|
||||
RegistrationInfo: reg,
|
||||
Remark: accRemark,
|
||||
}
|
||||
// 文件内重复检测:同一「平台名称 + 网址」下登录账号重复则跳过
|
||||
dup := false
|
||||
for _, keep := range g.Accounts {
|
||||
if sameAccount(keep, item) {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dup {
|
||||
label := username
|
||||
if label == "" {
|
||||
label = "(空账号)"
|
||||
}
|
||||
failures = append(failures, fmt.Sprintf("第%d行:平台「%s」下账号「%s」与前面某行重复,已跳过", i+2, platform, label))
|
||||
continue
|
||||
}
|
||||
g.Accounts = append(g.Accounts, item)
|
||||
}
|
||||
if remark != "" {
|
||||
g.Remark = remark
|
||||
}
|
||||
}
|
||||
out := make([]*passwordImportGroup, 0, len(order))
|
||||
for _, k := range order {
|
||||
out = append(out, index[k])
|
||||
}
|
||||
return out, failures
|
||||
}
|
||||
|
||||
// mergeAccounts 追加导入账号到已有账号之后,返回合并结果与「实际新增条数」。
|
||||
// 重复判定:登录账号相同(忽略大小写)即视为重复,账号为空时按密码比较。
|
||||
func mergeAccounts(exist, incoming []models.PasswordAccountItem) ([]models.PasswordAccountItem, int) {
|
||||
out := make([]models.PasswordAccountItem, 0, len(exist)+len(incoming))
|
||||
out = append(out, exist...)
|
||||
added := 0
|
||||
for _, in := range incoming {
|
||||
dup := false
|
||||
for _, e := range exist {
|
||||
if sameAccount(e, in) {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dup {
|
||||
continue
|
||||
}
|
||||
out = append(out, in)
|
||||
added++
|
||||
}
|
||||
return out, added
|
||||
}
|
||||
|
||||
// Import POST /platform/passwordStore/import { rows: [...] }
|
||||
func (c *PlatformPasswordStoreController) Import() {
|
||||
cl, e := passwordClaims(&c.Controller, "platform")
|
||||
if e != nil {
|
||||
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
rows, e := parsePasswordImportRows(&c.Controller)
|
||||
if e != nil {
|
||||
passwordReply(&c.Controller, 400, "参数错误", nil)
|
||||
return
|
||||
}
|
||||
uid := uint64(cl.UserID)
|
||||
groups, failures := groupPasswordImportRows(rows)
|
||||
res := passwordImportResult{Failed: len(failures), Failures: failures}
|
||||
now := time.Now()
|
||||
|
||||
for _, g := range groups {
|
||||
var exist models.PlatformPasswordStore
|
||||
err := models.Orm.QueryTable(new(models.PlatformPasswordStore)).
|
||||
Filter("is_deleted", 0).Filter("user_id", uid).
|
||||
Filter("platform", g.Platform).Filter("url", g.URL).One(&exist)
|
||||
if err == nil {
|
||||
merged, added := mergeAccounts(accountsFromJSON(exist.Accounts), g.Accounts)
|
||||
if added == 0 {
|
||||
res.Skipped++
|
||||
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」的登录账号已全部存在,已跳过", g.Platform))
|
||||
continue
|
||||
}
|
||||
accounts := accountsToJSON(merged)
|
||||
remark := exist.Remark
|
||||
if g.Remark != "" {
|
||||
remark = g.Remark
|
||||
}
|
||||
if _, uerr := models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("id", exist.ID).
|
||||
Update(map[string]interface{}{"accounts": accounts, "remark": remark, "update_time": now}); uerr != nil {
|
||||
res.Failed++
|
||||
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」更新失败:%s", g.Platform, uerr.Error()))
|
||||
continue
|
||||
}
|
||||
res.Updated++
|
||||
continue
|
||||
}
|
||||
v := &models.PlatformPasswordStore{
|
||||
Platform: g.Platform,
|
||||
URL: g.URL,
|
||||
Accounts: accountsToJSON(g.Accounts),
|
||||
Remark: g.Remark,
|
||||
UserID: uid,
|
||||
}
|
||||
if _, ierr := models.Orm.Insert(v); ierr != nil {
|
||||
res.Failed++
|
||||
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」新增失败:%s", g.Platform, ierr.Error()))
|
||||
continue
|
||||
}
|
||||
res.Created++
|
||||
}
|
||||
passwordReply(&c.Controller, 200, "导入完成", res)
|
||||
}
|
||||
|
||||
// Export GET /platform/passwordStore/export 导出全部(不受列表 200 条限制)
|
||||
func (c *PlatformPasswordStoreController) Export() {
|
||||
cl, e := passwordClaims(&c.Controller, "platform")
|
||||
if e != nil {
|
||||
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
var list []models.PlatformPasswordStore
|
||||
if _, e = models.Orm.QueryTable(new(models.PlatformPasswordStore)).
|
||||
Filter("is_deleted", 0).Filter("user_id", cl.UserID).
|
||||
OrderBy("-id").All(&list); e != nil && e != orm.ErrNoRows {
|
||||
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||
return
|
||||
}
|
||||
res := make([]PasswordStoreItemDTO, 0, len(list))
|
||||
for _, row := range list {
|
||||
res = append(res, PasswordStoreItemDTO{
|
||||
ID: row.ID,
|
||||
Platform: row.Platform,
|
||||
URL: row.URL,
|
||||
Accounts: accountsFromJSON(row.Accounts),
|
||||
Remark: row.Remark,
|
||||
UserID: row.UserID,
|
||||
CreateTime: row.CreateTime,
|
||||
UpdateTime: row.UpdateTime,
|
||||
})
|
||||
}
|
||||
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": len(res)})
|
||||
}
|
||||
|
||||
// Import POST /backend/passwordStore/import { rows: [...] }
|
||||
func (c *BackendPasswordStoreController) Import() {
|
||||
cl, e := passwordClaims(&c.Controller, "backend")
|
||||
if e != nil || cl.TenantId <= 0 {
|
||||
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
rows, e := parsePasswordImportRows(&c.Controller)
|
||||
if e != nil {
|
||||
passwordReply(&c.Controller, 400, "参数错误", nil)
|
||||
return
|
||||
}
|
||||
uid := uint64(cl.UserID)
|
||||
tid := cl.TenantId
|
||||
groups, failures := groupPasswordImportRows(rows)
|
||||
res := passwordImportResult{Failed: len(failures), Failures: failures}
|
||||
now := time.Now()
|
||||
|
||||
for _, g := range groups {
|
||||
var exist models.BackendPasswordStore
|
||||
err := models.Orm.QueryTable(new(models.BackendPasswordStore)).
|
||||
Filter("is_deleted", 0).Filter("tid", tid).Filter("user_id", uid).
|
||||
Filter("platform", g.Platform).Filter("url", g.URL).One(&exist)
|
||||
if err == nil {
|
||||
merged, added := mergeAccounts(accountsFromJSON(exist.Accounts), g.Accounts)
|
||||
if added == 0 {
|
||||
res.Skipped++
|
||||
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」的登录账号已全部存在,已跳过", g.Platform))
|
||||
continue
|
||||
}
|
||||
accounts := accountsToJSON(merged)
|
||||
remark := exist.Remark
|
||||
if g.Remark != "" {
|
||||
remark = g.Remark
|
||||
}
|
||||
if _, uerr := models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("id", exist.ID).
|
||||
Update(map[string]interface{}{"accounts": accounts, "remark": remark, "update_time": now}); uerr != nil {
|
||||
res.Failed++
|
||||
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」更新失败:%s", g.Platform, uerr.Error()))
|
||||
continue
|
||||
}
|
||||
res.Updated++
|
||||
continue
|
||||
}
|
||||
v := &models.BackendPasswordStore{
|
||||
Tid: tid,
|
||||
Platform: g.Platform,
|
||||
URL: g.URL,
|
||||
Accounts: accountsToJSON(g.Accounts),
|
||||
Remark: g.Remark,
|
||||
UserID: uid,
|
||||
}
|
||||
if _, ierr := models.Orm.Insert(v); ierr != nil {
|
||||
res.Failed++
|
||||
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」新增失败:%s", g.Platform, ierr.Error()))
|
||||
continue
|
||||
}
|
||||
res.Created++
|
||||
}
|
||||
passwordReply(&c.Controller, 200, "导入完成", res)
|
||||
}
|
||||
|
||||
// Export GET /backend/passwordStore/export 导出当前租户下本人全部记录
|
||||
func (c *BackendPasswordStoreController) Export() {
|
||||
cl, e := passwordClaims(&c.Controller, "backend")
|
||||
if e != nil || cl.TenantId <= 0 {
|
||||
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
var list []models.BackendPasswordStore
|
||||
if _, e = models.Orm.QueryTable(new(models.BackendPasswordStore)).
|
||||
Filter("is_deleted", 0).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID).
|
||||
OrderBy("-id").All(&list); e != nil && e != orm.ErrNoRows {
|
||||
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||
return
|
||||
}
|
||||
res := make([]PasswordStoreItemDTO, 0, len(list))
|
||||
for _, row := range list {
|
||||
res = append(res, PasswordStoreItemDTO{
|
||||
ID: row.ID,
|
||||
Tid: row.Tid,
|
||||
Platform: row.Platform,
|
||||
URL: row.URL,
|
||||
Accounts: accountsFromJSON(row.Accounts),
|
||||
Remark: row.Remark,
|
||||
UserID: row.UserID,
|
||||
CreateTime: row.CreateTime,
|
||||
UpdateTime: row.UpdateTime,
|
||||
})
|
||||
}
|
||||
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": len(res)})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type adminUserDTO struct {
|
||||
Email *string `json:"email"`
|
||||
Qq *string `json:"qq"`
|
||||
Sex uint8 `json:"sex"`
|
||||
Birth *string `json:"birth"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Rid uint64 `json:"rid"`
|
||||
LoginCount uint64 `json:"login_count"`
|
||||
@@ -34,6 +35,18 @@ type adminUserDTO struct {
|
||||
UpdateTime *string `json:"update_time"`
|
||||
}
|
||||
|
||||
// formatPlatformBirth 空串统一返回 nil,避免前端拿到空字符串
|
||||
func formatPlatformBirth(birth *string) *string {
|
||||
if birth == nil {
|
||||
return nil
|
||||
}
|
||||
s := strings.TrimSpace(*birth)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func toAdminUserDTO(u models.AdminUser) adminUserDTO {
|
||||
var updateTime *string
|
||||
if u.UpdateTime != nil {
|
||||
@@ -48,6 +61,7 @@ func toAdminUserDTO(u models.AdminUser) adminUserDTO {
|
||||
Email: u.Email,
|
||||
Qq: u.Qq,
|
||||
Sex: u.Sex,
|
||||
Birth: formatPlatformBirth(u.Birth),
|
||||
Avatar: u.Avatar,
|
||||
Rid: u.RoleID,
|
||||
LoginCount: u.LoginCount,
|
||||
@@ -111,6 +125,7 @@ type adminAddUserPayload struct {
|
||||
Email *string `json:"email"`
|
||||
Qq *string `json:"qq"`
|
||||
Sex *uint8 `json:"sex"`
|
||||
Birth *string `json:"birth"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Rid *uint64 `json:"rid"`
|
||||
Status *uint8 `json:"status"`
|
||||
@@ -152,7 +167,7 @@ func (c *PlatformAdminUserController) AddUser() {
|
||||
roleID = *p.Rid
|
||||
}
|
||||
|
||||
id, err := services.CreateAdminUser(p.Account, p.Password, p.Name, p.Phone, p.Email, p.Qq, p.Avatar, sex, roleID, status)
|
||||
id, err := services.CreateAdminUser(p.Account, p.Password, p.Name, p.Phone, p.Email, p.Qq, p.Avatar, formatPlatformBirth(p.Birth), sex, roleID, status)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "添加失败"}
|
||||
_ = c.ServeJSON()
|
||||
@@ -175,6 +190,7 @@ type editUserPayload struct {
|
||||
Email *string `json:"email"`
|
||||
Qq *string `json:"qq"`
|
||||
Sex *uint8 `json:"sex"`
|
||||
Birth *string `json:"birth"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Rid *uint64 `json:"rid"`
|
||||
Status *uint8 `json:"status"`
|
||||
@@ -220,6 +236,13 @@ func (c *PlatformAdminUserController) EditUser() {
|
||||
if p.Sex != nil {
|
||||
fields["sex"] = *p.Sex
|
||||
}
|
||||
if p.Birth != nil {
|
||||
if birth := formatPlatformBirth(p.Birth); birth != nil {
|
||||
fields["birth"] = *birth
|
||||
} else {
|
||||
fields["birth"] = nil
|
||||
}
|
||||
}
|
||||
if p.Avatar != nil {
|
||||
fields["avatar"] = *p.Avatar
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (c *PlatformAuthController) LoginPlatform() {
|
||||
}
|
||||
|
||||
// 控制器只做 HTTP 解析与响应编排,业务逻辑放 services 层
|
||||
token, loginUser, err := services.PlatformAdminLogin(req.Account, req.Password)
|
||||
token, loginUser, err := services.PlatformAdminLogin(req.Account, req.Password, c.Ctx.Input.IP())
|
||||
if err != nil {
|
||||
RecordLoginLog(nil, 0, req.Account, "", "", "password", 0, err.Error(), c.Ctx.Input.IP(), c.Ctx.Request.UserAgent())
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -106,6 +108,7 @@ func (c *PlatformWechatMpController) GetConfig() {
|
||||
data["encrypt_mode"] = cfg.EncryptMode
|
||||
data["template_id"] = cfg.TemplateID
|
||||
data["remark"] = cfg.Remark
|
||||
data["follow_qrcode"] = cfg.FollowQrcode
|
||||
data["configured"] = cfg.AppID != ""
|
||||
if cfg.Verified {
|
||||
data["verified"] = 1
|
||||
@@ -190,7 +193,8 @@ func (c *PlatformWechatMpController) TestConnection() {
|
||||
// ============================ 绑定(当前平台用户) ============================
|
||||
|
||||
// BindStart POST /platform/wechatMp/bind/start
|
||||
// 生成带参二维码;用户扫码关注后公众号将被动回复验证码
|
||||
// 未认证公众号无法生成带参二维码(48001):不再下发二维码,
|
||||
// 返回绑定指引(公众号内发送「验证码」取码 → 页面填码 → bind/confirm 核销)。
|
||||
func (c *PlatformWechatMpController) BindStart() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
@@ -202,18 +206,137 @@ func (c *PlatformWechatMpController) BindStart() {
|
||||
c.jsonErr(401, 401, "未获取到用户信息")
|
||||
return
|
||||
}
|
||||
scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypePlatformUser, uid, 0, 0)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "生成二维码失败:"+err.Error())
|
||||
if _, err := wechatmp.LoadEnabledConfig(); err != nil {
|
||||
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qrcode_url": qrURL,
|
||||
"expire_at": expireAt,
|
||||
"expires_in": int(time.Until(expireAt).Seconds()),
|
||||
"callback_url": wechatmp.CallbackURL(),
|
||||
}, "生成成功")
|
||||
"mode": "mp_message",
|
||||
"guides": wechatmp.BindGuides(),
|
||||
"follow_qrcode": wechatmp.FollowQrcodeURL(),
|
||||
"expires_in": int(wechatmp.DefaultVerifyTTL.Seconds()),
|
||||
"callback_url": wechatmp.CallbackURL(),
|
||||
}, "请按指引完成绑定")
|
||||
}
|
||||
|
||||
// ============================ 自定义菜单 ============================
|
||||
|
||||
// PublishMenu POST /platform/wechatMp/menu/publish
|
||||
// 通过接口在公众号底部发布「扫码登录」菜单(点击 → 自动回复确认登录链接)。
|
||||
// 需认证公众号;未认证(48001)时提示改用「公众号内发送登录」路径。
|
||||
func (c *PlatformWechatMpController) PublishMenu() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
cfg, err := wechatmp.LoadEnabledConfig()
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
|
||||
return
|
||||
}
|
||||
res, merr := wechatmp.PublishLoginMenu(cfg)
|
||||
if merr != nil {
|
||||
msg := "发布菜单失败:" + merr.Error()
|
||||
if hint, ok := wechatmp.ExplainError(merr); ok {
|
||||
msg += ";" + hint
|
||||
}
|
||||
c.jsonErr(400, 400, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// 微信客户端有菜单缓存:发布后需重新关注或等待(最长 24 小时)才可见
|
||||
msg := "菜单已发布:微信客户端有缓存,请重新关注公众号或等待刷新(最长 24 小时)后可见「扫码登录」"
|
||||
if res.AlreadyExists {
|
||||
msg = "菜单中已存在「扫码登录」入口,未重复发布;若底部未显示,请等待客户端刷新或重新关注"
|
||||
}
|
||||
if res.ConditionalMenuCount > 0 {
|
||||
msg += fmt.Sprintf(";检测到个性化菜单 %d 条,个性化菜单会覆盖默认菜单,请到公众号后台确认",
|
||||
res.ConditionalMenuCount)
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"menu": res.Buttons,
|
||||
"already_exists": res.AlreadyExists,
|
||||
"conditional_menu_count": res.ConditionalMenuCount,
|
||||
}, msg)
|
||||
}
|
||||
|
||||
// ============================ 关注二维码 ============================
|
||||
|
||||
// 二维码图片限制:2MB,常见图片格式
|
||||
const followQrcodeMaxBytes = 2 * 1024 * 1024
|
||||
|
||||
var followQrcodeAllowedExts = map[string]bool{
|
||||
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true,
|
||||
}
|
||||
|
||||
// saveFollowQrcode 校验图片并写入配置表(data URL 存储),返回 data URL
|
||||
func (c *PlatformWechatMpController) saveFollowQrcode() (string, error) {
|
||||
if err := c.Ctx.Request.ParseMultipartForm(followQrcodeMaxBytes); err != nil {
|
||||
return "", fmt.Errorf("解析上传失败: %w", err)
|
||||
}
|
||||
fh, header, err := c.GetFile("file")
|
||||
if err != nil || fh == nil {
|
||||
return "", fmt.Errorf("请选择要上传的二维码图片")
|
||||
}
|
||||
defer fh.Close()
|
||||
if header != nil && header.Size > followQrcodeMaxBytes {
|
||||
return "", fmt.Errorf("图片大小不能超过 2MB")
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(header.Filename), "."))
|
||||
if !followQrcodeAllowedExts[ext] {
|
||||
return "", fmt.Errorf("仅支持 jpg / png / gif / webp / bmp 格式")
|
||||
}
|
||||
raw, err := io.ReadAll(fh)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取图片失败: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return "", fmt.Errorf("图片内容为空")
|
||||
}
|
||||
mime := "image/png"
|
||||
switch ext {
|
||||
case "jpg", "jpeg":
|
||||
mime = "image/jpeg"
|
||||
case "gif":
|
||||
mime = "image/gif"
|
||||
case "webp":
|
||||
mime = "image/webp"
|
||||
case "bmp":
|
||||
mime = "image/bmp"
|
||||
}
|
||||
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// UploadFollowQrcode POST /platform/wechatMp/followQrcode
|
||||
// 上传公众号「关注二维码」图片(普通二维码,用户扫码进入公众号会话)
|
||||
func (c *PlatformWechatMpController) UploadFollowQrcode() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
dataURL, err := c.saveFollowQrcode()
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := wechatmp.SaveFollowQrcode(dataURL); err != nil {
|
||||
c.jsonErr(500, 500, "保存失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{"follow_qrcode": dataURL}, "上传成功")
|
||||
}
|
||||
|
||||
// DeleteFollowQrcode POST /platform/wechatMp/followQrcode/delete
|
||||
func (c *PlatformWechatMpController) DeleteFollowQrcode() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
if err := wechatmp.SaveFollowQrcode(""); err != nil {
|
||||
c.jsonErr(500, 500, "删除失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "已删除")
|
||||
}
|
||||
|
||||
// BindStatus GET /platform/wechatMp/bind/status?scene=xxx
|
||||
|
||||
Reference in New Issue
Block a user