feat: add custom list

This commit is contained in:
goder-zhang 2026-02-15 12:54:33 +00:00
parent a203de46f7
commit eeda43d30c
21 changed files with 475 additions and 70 deletions

View File

@ -1,7 +1,10 @@
package cmd
import (
"ai-css/common"
"ai-css/library/logger"
"ai-css/models"
"ai-css/ws"
"errors"
"fmt"
"os"
@ -28,13 +31,16 @@ func args(cmd *cobra.Command, args []string) error {
}
func Execute() {
logger.InitDefault()
common.LoadConfig()
rootCmd.AddCommand(serverCmd)
rootCmd.AddCommand(installCmd)
rootCmd.AddCommand(stopCmd)
models.Connect()
ws.StartUpdateVisitorStatusCron()
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func init() {
rootCmd.AddCommand(serverCmd)
rootCmd.AddCommand(installCmd)
rootCmd.AddCommand(stopCmd)
}

View File

@ -1,5 +1,11 @@
package common
import (
"fmt"
"os"
"path"
)
var (
PageSize uint = 10
VisitorPageSize uint = 8
@ -10,3 +16,21 @@ var (
MysqlConf string = Dir + "mysql.json"
IsCompireTemplate bool = false //是否编译静态模板到二进制
)
const (
ENV_DEV = "dev"
ENV_PROD = "prod"
)
var (
environment = os.Getenv("AICSS_ENV")
)
func getConfigPath() string {
switch environment {
case ENV_DEV, ENV_PROD:
return path.Join(Dir, fmt.Sprintf("config_%s.yaml", environment))
default:
return path.Join(Dir, "config.yaml")
}
}

View File

@ -1,29 +1,59 @@
package common
import (
"ai-css/tools"
"encoding/json"
"io/ioutil"
"fmt"
"sync"
"github.com/spf13/viper"
)
type Mysql struct {
Server string
Port string
Database string
Username string
Password string
type Config struct {
Service Service `mapstructure:"service" json:"service"` // 服务配置
MysqlService Mysql `mapstructure:"mysql_service" json:"mysql_service"`
}
func GetMysqlConf() *Mysql {
var mysql = &Mysql{}
isExist, _ := tools.IsFileExist(MysqlConf)
if !isExist {
return mysql
}
info, err := ioutil.ReadFile(MysqlConf)
if err != nil {
return mysql
}
err = json.Unmarshal(info, mysql)
return mysql
type Service struct {
Sites map[string]SiteConfig `mapstructure:"site" json:"site"`
}
type SiteConfig struct {
UnauthURI string `mapstructure:"unauth_uri" json:"unauth_uri"`
}
type Mysql struct {
Server string `mapstructure:"server" json:"server"`
Port string `mapstructure:"port" json:"port"`
Database string `mapstructure:"database" json:"database"`
Username string `mapstructure:"username" json:"username"`
Password string `mapstructure:"password" json:"password"`
}
var (
cfg Config
loadOnce sync.Once
)
func LoadConfig() Config {
loadOnce.Do(func() {
var configPath = getConfigPath()
viper.SetConfigType("yaml")
viper.SetConfigFile(configPath)
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("read file failed err:%v,file:%s", err, configPath))
}
err = viper.Unmarshal(&cfg)
if err != nil {
panic(fmt.Errorf("errpr viper.Unnarshal err:%v,file:%s", err, configPath))
}
})
return cfg
}
func GetMysqlConfig() Mysql {
return cfg.MysqlService
}
func GetServiceConfig() Service {
return cfg.Service
}

6
config/config.yaml Normal file
View File

@ -0,0 +1,6 @@
mysql_service:
server: xpink-prod-mysql.cjkmm024cf76.ap-east-1.rds.amazonaws.com
port: 3306
database: aicss_db
username: admin
password: o()Vq$NuZdwoEe>VLalG]CBMp4LZ

6
config/config_prod.yaml Normal file
View File

@ -0,0 +1,6 @@
mysql_service:
server: xpink-prod-mysql.cjkmm024cf76.ap-east-1.rds.amazonaws.com
port: 3306
database: aicss_db
username: admin
password: o()Vq$NuZdwoEe>VLalG]CBMp4LZ

View File

@ -5,10 +5,65 @@ import (
"ai-css/tools"
"ai-css/ws"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func PostKefuStatus(c *gin.Context) {
v := c.GetString("kefu_name")
userInfo := models.FindUser(v)
if userInfo.Role != 1 {
c.JSON(200, gin.H{
"code": 400,
"msg": "权限不足",
})
return
}
kefuName := c.PostForm("username")
statusStr := c.PostForm("status")
status, _ := strconv.Atoi(statusStr)
models.UpdateUserStatus(kefuName, int32(status))
c.JSON(200, gin.H{
"code": 200,
"msg": "ok",
"result": "",
})
}
func PostAdminResetPass(c *gin.Context) {
v := c.GetString("kefu_name")
userInfo := models.FindUser(v)
if userInfo.Role != 1 {
c.JSON(200, gin.H{
"code": 400,
"msg": "权限不足",
})
return
}
kefuName := c.PostForm("username")
newPass := c.PostForm("password")
if kefuName == "" || newPass == "" {
c.JSON(200, gin.H{
"code": 400,
"msg": "Username and password are required",
"result": "",
})
return
}
models.UpdateUserPass(kefuName, tools.Md5(newPass))
c.JSON(200, gin.H{
"code": 200,
"msg": "ok",
"result": "",
})
}
func PostKefuAvator(c *gin.Context) {
avator := c.PostForm("avator")
@ -78,22 +133,46 @@ func PostKefuClient(c *gin.Context) {
func GetIdleKefu(c *gin.Context) {
visitorId := c.Query("visitor_id")
var kefuName string
var kefuName, oldKefuname string
var reset bool
var visitorDataId uint
if visitorId != "" {
visitor := models.FindVisitorByVistorId(visitorId)
if visitor.ToId != "" {
kefuName = visitor.ToId
visitorDataId = visitor.ID
}
if kefuName != "" {
userInfo := models.FindUser(kefuName)
if userInfo.ID == 0 || userInfo.Status == 0 || userInfo.IsOnline == 0 {
reset = true
oldKefuname = kefuName
kefuName = ""
}
}
}
if kefuName == "" {
user := models.FindIdleUser()
kefuName = user.Name
if reset {
if visitorDataId > 0 {
models.UpdatesVisitor(visitorDataId, kefuName)
}
}
}
if kefuName == "" {
c.JSON(200, gin.H{
"code": 400,
"msg": "暂时没有在线客服",
})
} else {
c.JSON(200, gin.H{
"code": 200,
"msg": "ok",
"result": kefuName,
"oldResult": oldKefuname,
})
}
}
func GetKefuInfo(c *gin.Context) {
kefuName, _ := c.Get("kefu_name")
@ -124,7 +203,7 @@ func GetOtherKefuList(c *gin.Context) {
id := idStr.(float64)
result := make([]interface{}, 0)
ws.SendPingToKefuClient()
kefus := models.FindUsers()
kefus := models.FindUsers("")
for _, kefu := range kefus {
if uint(id) == kefu.ID {
continue
@ -180,6 +259,15 @@ func GetKefuInfoSetting(c *gin.Context) {
})
}
func PostKefuRegister(c *gin.Context) {
kefuName := c.GetString("kefu_name")
userInfo := models.FindUser(kefuName)
if userInfo.Role != 1 {
c.JSON(200, gin.H{
"code": 400,
"msg": "权限不足",
})
return
}
name := c.PostForm("username")
password := c.PostForm("password")
nickname := c.PostForm("nickname")
@ -246,7 +334,16 @@ func PostKefuInfo(c *gin.Context) {
})
}
func GetKefuList(c *gin.Context) {
users := models.FindUsers()
kefuName := c.GetString("kefu_name")
userInfo := models.FindUser(kefuName)
if userInfo.Role != 1 {
c.JSON(200, gin.H{
"code": 400,
"msg": "权限不足",
})
return
}
users := models.FindUsers(kefuName)
c.JSON(200, gin.H{
"code": 200,
"msg": "获取成功",

View File

@ -3,8 +3,9 @@ package controller
import (
"ai-css/models"
"ai-css/tools"
"github.com/gin-gonic/gin"
"time"
"github.com/gin-gonic/gin"
)
// @Summary User Authentication API
@ -33,6 +34,14 @@ func LoginCheckPass(c *gin.Context) {
return
}
if info.Status != 1 {
c.JSON(200, gin.H{
"code": 400,
"message": "账号已经停用",
})
return
}
// Prepare user session data
userinfo := map[string]interface{}{
"kefu_name": info.Name,

View File

@ -16,6 +16,7 @@ func JwtPageMiddleware(c *gin.Context) {
// c.Abort()
//}
}
func JwtApiMiddleware(c *gin.Context) {
token := c.GetHeader("aicss-token")
if token == "" {

View File

@ -0,0 +1,31 @@
package login_direct
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func DomainAuthRedirectMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
host := c.Request.Host
// 只针对 admin 域名
if strings.HasPrefix(host, "admin.example.com") {
// 这里写你的认证逻辑
token := c.GetHeader("Authorization")
if token == "" {
// 未认证跳转
c.Redirect(http.StatusFound, "https://admin.example.com/login")
c.Abort()
return
}
}
c.Next()
}
}

View File

@ -18,25 +18,21 @@ type Model struct {
DeletedAt *time.Time `sql:"index" json:"deleted_at"`
}
func init() {
Connect()
}
func Connect() error {
mysql := common.GetMysqlConf()
func Connect() {
mysql := common.GetMysqlConfig()
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", mysql.Username, mysql.Password, mysql.Server, mysql.Port, mysql.Database)
var err error
DB, err = gorm.Open("mysql", dsn)
if err != nil {
log.Println(err)
panic("数据库连接失败!")
return err
}
DB.SingularTable(true)
DB.LogMode(true)
DB.DB().SetMaxIdleConns(10)
DB.DB().SetMaxOpenConns(100)
DB.DB().SetConnMaxLifetime(59 * time.Second)
return nil
DB.AutoMigrate(&User{})
}
func Execute(sql string) error {
return DB.Exec(sql).Error

View File

@ -16,6 +16,8 @@ type User struct {
Role int32 `json:"role"`
RoleName string `json:"role_name" sql:"-"`
RoleId string `json:"role_id" sql:"-"`
Status int32 `json:"status" gorm:"default:1"` // 1: active, 0: inactive
IsOnline int32 `json:"is_online" gorm:"default:0"` // 1: online, 0: offline
}
func CreateUser(name string, password string, avator string, nickname string) uint {
@ -29,6 +31,15 @@ func CreateUser(name string, password string, avator string, nickname string) ui
DB.Create(user)
return user.ID
}
func UpdateUserIsOnline(name string, isOnline int32) {
user := &User{
IsOnline: isOnline,
}
user.UpdatedAt = time.Now()
DB.Model(user).Where("name = ?", name).Update("IsOnline", isOnline)
}
func UpdateUser(name string, password string, avator string, nickname string) {
user := &User{
Avator: avator,
@ -47,6 +58,13 @@ func UpdateUserPass(name string, pass string) {
user.UpdatedAt = time.Now()
DB.Model(user).Where("name = ?", name).Update("Password", pass)
}
func UpdateUserStatus(name string, status int32) {
user := &User{
Status: status,
}
user.UpdatedAt = time.Now()
DB.Model(user).Where("name = ?", name).Update("Status", status)
}
func UpdateUserAvator(name string, avator string) {
user := &User{
Avator: avator,
@ -70,7 +88,7 @@ func FindIdleUser() User {
defer assignMutex.Unlock()
var users []User
DB.Where("name != ?", "admin").Order("id desc").Find(&users)
DB.Where("name != ? and `status` = 1 and `is_online` = 1", "admin").Order("id desc").Find(&users)
if len(users) == 0 {
return User{}
@ -104,9 +122,13 @@ func FindUserById(id interface{}) User {
func DeleteUserById(id string) {
DB.Where("id = ?", id).Delete(User{})
}
func FindUsers() []User {
func FindUsers(withoutUsername string) []User {
var users []User
DB.Select("user.*,role.name role_name").Joins("left join user_role on user.id=user_role.user_id").Joins("left join role on user_role.role_id=role.id").Order("user.id desc").Find(&users)
if withoutUsername == "" {
DB.Find(&users)
} else {
DB.Where("name != ?", withoutUsername).Find(&users)
}
return users
}
func FindUserRole(query interface{}, id interface{}) User {

View File

@ -39,6 +39,11 @@ func FindVisitorByVistorId(visitorId string) Visitor {
DB.Where("visitor_id = ?", visitorId).First(&v)
return v
}
func UpdatesVisitor(id uint, toId string) {
DB.Model(&Visitor{}).Where("id = ?", id).Update("to_id", toId)
}
func FindVisitors(page uint, pagesize uint) []Visitor {
offset := (page - 1) * pagesize
if offset < 0 {
@ -86,21 +91,21 @@ func UpdateVisitorKefu(visitorId string, kefuId string) {
DB.Model(&visitor).Where("visitor_id = ?", visitorId).Update("to_id", kefuId)
}
//查询条数
// 查询条数
func CountVisitors() uint {
var count uint
DB.Model(&Visitor{}).Count(&count)
return count
}
//查询条数
// 查询条数
func CountVisitorsByKefuId(kefuId string) uint {
var count uint
DB.Model(&Visitor{}).Where("to_id=?", kefuId).Count(&count)
return count
}
//查询每天条数
// 查询每天条数
type EveryDayNum struct {
Day string `json:"day"`
Num int64 `json:"num"`

Binary file not shown.

View File

@ -25,7 +25,7 @@ func InitApiRouter(engine *gin.RouterGroup) {
engine.POST("/check", controller.LoginCheckPass)
engine.GET("/userinfo", middleware.JwtApiMiddleware, controller.GetKefuInfoAll)
engine.POST("/register", middleware.Ipblack, controller.PostKefuRegister)
engine.POST("/register", middleware.JwtApiMiddleware, controller.PostKefuRegister)
engine.POST("/install", controller.PostInstall)
//前后聊天
engine.GET("/ws_kefu", middleware.JwtApiMiddleware, ws.NewKefuServer)
@ -50,6 +50,8 @@ func InitApiRouter(engine *gin.RouterGroup) {
engine.POST("/kefuinfo", middleware.JwtApiMiddleware, middleware.RbacAuth, controller.PostKefuInfo)
engine.DELETE("/kefuinfo", middleware.JwtApiMiddleware, middleware.RbacAuth, controller.DeleteKefuInfo)
engine.GET("/kefulist", middleware.JwtApiMiddleware, middleware.RbacAuth, controller.GetKefuList)
engine.POST("/kefu_status", middleware.JwtApiMiddleware, middleware.RbacAuth, controller.PostKefuStatus)
engine.POST("/admin_reset_pass", middleware.JwtApiMiddleware, middleware.RbacAuth, controller.PostAdminResetPass)
engine.GET("/other_kefulist", middleware.JwtApiMiddleware, controller.GetOtherKefuList)
engine.GET("/trans_kefu", middleware.JwtApiMiddleware, controller.PostTransKefu)
engine.POST("/modifypass", middleware.JwtApiMiddleware, middleware.RbacAuth, controller.PostKefuPass)

View File

@ -726,6 +726,16 @@
success: function(data) {
if(data.code==200 && data.msg=="ok"){
KEFU_ID=data.result;
var oldResult = data.oldResult;
if (oldResult && oldResult != KEFU_ID) {
var oldKey = "visitor_" + oldResult;
var newKey = "visitor_" + KEFU_ID;
var oldCache = localStorage.getItem(oldKey);
if(oldCache) {
localStorage.setItem(newKey, oldCache);
localStorage.removeItem(oldKey);
}
}
}else{
KEFU_ID="default";
}

View File

@ -121,6 +121,8 @@
]
},
showRegHtml: false,
kefuInfo:{},
kefuList: [],
},
methods: {
validatePasswordMatch(rule, value, callback) {
@ -190,7 +192,16 @@
"nickname": this.form.nickname,
};
$.post("/aicss/register", data, (response) => {
$.ajax({
url: "/aicss/register",
type: "POST",
data: data,
headers: {
"aicss-token": localStorage.getItem("aicss-token"),
"X-Client-Type": "web",
"X-Custom-Domain": window.location.host
},
success: (response) => {
if (response.code === 200) {
this.$message({
message: 'Account created successfully!',
@ -203,18 +214,23 @@
type: 'error'
});
}
}).fail(() => {
},
error: () => {
this.$message({
message: 'Connection error',
type: 'error'
});
}
});
}
},
created: function() {
if (top.location != location) {
top.location.href = location.href;
}
this.getKefuInfo();
}
});
</script>

View File

@ -115,6 +115,43 @@
</div>
>
</el-tab-pane>
<el-tab-pane v-if="kefuInfo.role === 1" label="Customer List">
<div class="profile-form">
<div style="margin-bottom: 20px;">
<el-button type="primary" icon="el-icon-plus" @click="openCreateKefu">添加客服</el-button>
</div>
<el-table :data="kefuList" style="width: 100%" stripe>
<el-table-column label="头像" width="80">
<template slot-scope="scope">
<el-avatar :size="40" :src="scope.row.avator"></el-avatar>
</template>
</el-table-column>
<el-table-column prop="name" label="账号"></el-table-column>
<el-table-column prop="nickname" label="昵称"></el-table-column>
<el-table-column label="在线状态" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.is_online === 1" type="success" size="mini">在线</el-tag>
<el-tag v-else type="info" size="mini">离线</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-switch
v-model="scope.row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row)">
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template slot-scope="scope">
<el-button size="mini" type="warning" @click="openResetPass(scope.row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane v-if="kefuInfo.role === 1" label="System Configuration">
<div class="profile-form" style="margin-top: 20px">
<el-table
@ -174,6 +211,36 @@
</div>
</el-tab-pane>
</el-tabs>
<el-dialog title="创建客服" :visible.sync="createKefuDialog" width="400px">
<el-form :model="createKefuForm">
<el-form-item label="账号">
<el-input v-model="createKefuForm.username"></el-input>
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="createKefuForm.nickname"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input type="password" v-model="createKefuForm.password" show-password></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="createKefuDialog = false">取 消</el-button>
<el-button type="primary" @click="submitCreateKefu">确 定</el-button>
</div>
</el-dialog>
<el-dialog title="重置密码" :visible.sync="resetPassDialog" width="400px">
<el-form :model="resetPassForm">
<el-form-item label="新密码">
<el-input type="password" v-model="resetPassForm.password" show-password></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="resetPassDialog = false">取 消</el-button>
<el-button type="primary" @click="submitResetPass">确 定</el-button>
</div>
</el-dialog>
</template>
</div>
</body>

View File

@ -25,6 +25,18 @@
role_name:"",
role_id:"",
},
kefuList: [],
resetPassDialog: false,
createKefuDialog: false,
resetPassForm: {
username: "",
password: ""
},
createKefuForm: {
username: "",
nickname: "",
password: ""
},
avatarUrl:"",
chatEndpoint: "",
@ -303,8 +315,71 @@ A: 因二次结算导致,系统将按最终结果扣回或补发。`}
if(data.code==200 && data.result!=null){
_this.kefuInfo=data.result;
_this.chatEndpoint=window.location.origin + '/aicss/livechat?kefu_id='+data.result.username;
if (_this.kefuInfo.role === 1) {
_this.getKefuList();
}
}
}
});
},
// Get Kefu List
getKefuList() {
let _this = this;
this.sendAjax("/aicss/kefulist", "get", {}, function(result) {
_this.kefuList = result;
});
},
// Toggle Status
handleStatusChange(row) {
let _this = this;
this.sendAjax("/kefu_status", "post", {
username: row.name,
status: row.status
}, function(result) {
_this.$message({
message: "Status updated",
type: 'success'
});
});
},
// Open Reset Password Dialog
openResetPass(row) {
this.resetPassForm.username = row.name;
this.resetPassForm.password = "";
this.resetPassDialog = true;
},
// Submit Reset Password
submitResetPass() {
let _this = this;
if (this.resetPassForm.password.length < 2) {
this.$message.error("Password too short");
return;
}
this.sendAjax("/admin_reset_pass", "post", this.resetPassForm, function(result) {
_this.$message.success("Password reset successfully");
_this.resetPassDialog = false;
});
},
// Open Create User Dialog
openCreateKefu() {
this.createKefuForm = {
username: "",
nickname: "",
password: ""
};
this.createKefuDialog = true;
},
// Submit Create User
submitCreateKefu() {
let _this = this;
if (!this.createKefuForm.username || !this.createKefuForm.password) {
this.$message.error("Username and password required");
return;
}
this.sendAjax("/register", "post", this.createKefuForm, function(result) {
_this.$message.success("User created successfully");
_this.createKefuDialog = false;
_this.getKefuList();
});
},
},
@ -325,4 +400,5 @@ A: 因二次结算导致,系统将按最终结果扣回或补发。`}
</script>
</html>
{{end}}

View File

@ -1,16 +1,13 @@
package tmpl
import (
"ai-css/tools"
"github.com/gin-gonic/gin"
"net/http"
"github.com/gin-gonic/gin"
)
// 登陆界面
func PageLogin(c *gin.Context) {
if noExist, _ := tools.IsFileNotExist("./install.lock"); noExist {
c.Redirect(302, "/install")
}
c.HTML(http.StatusOK, "login.html", nil)
}

View File

@ -35,6 +35,7 @@ func NewKefuServer(c *gin.Context) {
kefu.Avator = kefuInfo.Avator
kefu.Conn = conn
AddKefuToList(&kefu)
models.UpdateUserIsOnline(kefuInfo.Name, 1)
for {
//接受消息
@ -43,6 +44,7 @@ func NewKefuServer(c *gin.Context) {
if err != nil {
log.Println("ws/user.go ", err)
conn.Close()
models.UpdateUserIsOnline(kefuInfo.Name, 0)
return
}
@ -114,6 +116,7 @@ func SendPingToKefuClient() {
if err != nil {
log.Println("定时发送ping给客服失败", err.Error())
delete(KefuList, kefuId)
models.UpdateUserIsOnline(kefuId, 0)
}
}
}

View File

@ -64,7 +64,7 @@ var message = make(chan *Message, 10)
var upgrader = websocket.Upgrader{}
var Mux sync.RWMutex
func init() {
func StartUpdateVisitorStatusCron() {
upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
@ -75,6 +75,7 @@ func init() {
}
go UpdateVisitorStatusCron()
}
func SendServerJiang(title string, content string, domain string) string {
noticeServerJiang, err := strconv.ParseBool(models.FindConfig("NoticeServerJiang"))
serverJiangAPI := models.FindConfig("ServerJiangAPI")