feat(api): 接入微信登录并原生实现咨询域(ECR-049/050)
小程序可在 Go 上完成微信手机号登录、测评、预约和下单,不再反代 Java。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,23 @@ admin:
|
||||
bootstrap_username: admin
|
||||
bootstrap_password: "change-me" # override in config.local.yaml; never commit secrets
|
||||
|
||||
# Mini-program WeChat login (never commit real secrets)
|
||||
wechat:
|
||||
app_id: ""
|
||||
secret: ""
|
||||
|
||||
# Legacy Java (ECR-049, unused after ECR-050 native consult)
|
||||
java:
|
||||
base_url: ""
|
||||
internal_key: ""
|
||||
|
||||
# WeChat JSAPI pay. Empty = mock pay params (dev).
|
||||
pay:
|
||||
app_id: ""
|
||||
mch_id: ""
|
||||
api_key: ""
|
||||
notify_url: "https://h5.yuxingu.com.cn/psy/api/v1/psychic/pay/notify"
|
||||
|
||||
# jwt:
|
||||
# secret: ""
|
||||
# payment:
|
||||
|
||||
@@ -19,6 +19,29 @@ type Config struct {
|
||||
AppEnv string
|
||||
DeepSeek DeepSeekConfig
|
||||
Admin AdminConfig
|
||||
WeChat WeChatConfig
|
||||
Java JavaConfig
|
||||
Pay PayConfig
|
||||
}
|
||||
|
||||
// PayConfig is WeChat JSAPI merchant settings.
|
||||
type PayConfig struct {
|
||||
AppID string
|
||||
MchID string
|
||||
APIKey string
|
||||
NotifyURL string
|
||||
}
|
||||
|
||||
// WeChatConfig is mini-program jscode2session credentials.
|
||||
type WeChatConfig struct {
|
||||
AppID string
|
||||
Secret string
|
||||
}
|
||||
|
||||
// JavaConfig is the consult stack bridge (internal issue + proxy).
|
||||
type JavaConfig struct {
|
||||
BaseURL string
|
||||
InternalKey string
|
||||
}
|
||||
|
||||
// AdminConfig for ops console bootstrap (ECR-006).
|
||||
@@ -58,6 +81,20 @@ type fileConfig struct {
|
||||
BootstrapUsername string `yaml:"bootstrap_username"`
|
||||
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||
} `yaml:"admin"`
|
||||
WeChat struct {
|
||||
AppID string `yaml:"app_id"`
|
||||
Secret string `yaml:"secret"`
|
||||
} `yaml:"wechat"`
|
||||
Java struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
InternalKey string `yaml:"internal_key"`
|
||||
} `yaml:"java"`
|
||||
Pay struct {
|
||||
AppID string `yaml:"app_id"`
|
||||
MchID string `yaml:"mch_id"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
NotifyURL string `yaml:"notify_url"`
|
||||
} `yaml:"pay"`
|
||||
}
|
||||
|
||||
// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides.
|
||||
@@ -140,6 +177,30 @@ func mergeFile(cfg *Config, path string) error {
|
||||
if f.Admin.BootstrapPassword != "" {
|
||||
cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword
|
||||
}
|
||||
if f.WeChat.AppID != "" {
|
||||
cfg.WeChat.AppID = f.WeChat.AppID
|
||||
}
|
||||
if f.WeChat.Secret != "" {
|
||||
cfg.WeChat.Secret = f.WeChat.Secret
|
||||
}
|
||||
if f.Java.BaseURL != "" {
|
||||
cfg.Java.BaseURL = strings.TrimRight(f.Java.BaseURL, "/")
|
||||
}
|
||||
if f.Java.InternalKey != "" {
|
||||
cfg.Java.InternalKey = f.Java.InternalKey
|
||||
}
|
||||
if f.Pay.AppID != "" {
|
||||
cfg.Pay.AppID = f.Pay.AppID
|
||||
}
|
||||
if f.Pay.MchID != "" {
|
||||
cfg.Pay.MchID = f.Pay.MchID
|
||||
}
|
||||
if f.Pay.APIKey != "" {
|
||||
cfg.Pay.APIKey = f.Pay.APIKey
|
||||
}
|
||||
if f.Pay.NotifyURL != "" {
|
||||
cfg.Pay.NotifyURL = f.Pay.NotifyURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -207,6 +268,30 @@ func applyEnv(cfg *Config) {
|
||||
if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" {
|
||||
cfg.Admin.BootstrapPassword = v
|
||||
}
|
||||
if v := os.Getenv("WECHAT_MINI_APP_ID"); v != "" {
|
||||
cfg.WeChat.AppID = v
|
||||
}
|
||||
if v := os.Getenv("WECHAT_MINI_APP_SECRET"); v != "" {
|
||||
cfg.WeChat.Secret = v
|
||||
}
|
||||
if v := os.Getenv("JAVA_CONSULT_BASE_URL"); v != "" {
|
||||
cfg.Java.BaseURL = strings.TrimRight(v, "/")
|
||||
}
|
||||
if v := os.Getenv("JAVA_INTERNAL_KEY"); v != "" {
|
||||
cfg.Java.InternalKey = v
|
||||
}
|
||||
if v := os.Getenv("PAY_APP_ID"); v != "" {
|
||||
cfg.Pay.AppID = v
|
||||
}
|
||||
if v := os.Getenv("PAY_MCH_ID"); v != "" {
|
||||
cfg.Pay.MchID = v
|
||||
}
|
||||
if v := os.Getenv("PAY_API_KEY"); v != "" {
|
||||
cfg.Pay.APIKey = v
|
||||
}
|
||||
if v := os.Getenv("PAY_NOTIFY_URL"); v != "" {
|
||||
cfg.Pay.NotifyURL = v
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether DeepSeek can be called.
|
||||
|
||||
@@ -22,6 +22,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/auth")
|
||||
g.POST("/register", h.RegisterAccount)
|
||||
g.POST("/login", h.Login)
|
||||
g.POST("/wechat", h.WeChat)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", h.Me)
|
||||
g.PATCH("/me", h.PatchMe)
|
||||
@@ -34,6 +35,12 @@ type authBody struct {
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
type wechatBody struct {
|
||||
Code string `json:"code"`
|
||||
EncryptedData string `json:"encryptedData"`
|
||||
IV string `json:"iv"`
|
||||
}
|
||||
|
||||
// RegisterAccount handles POST /auth/register (DeviceAuth required on group).
|
||||
func (h *AuthHandler) RegisterAccount(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
@@ -88,6 +95,32 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// WeChat handles POST /auth/wechat (mini-program phone login).
|
||||
func (h *AuthHandler) WeChat(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
var body wechatBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
res, err := h.Svc.WeChatLogin(c.Request.Context(), userID, deviceKey, body.Code, body.EncryptedData, body.IV)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrAccountRestricted) {
|
||||
response.Fail(c, http.StatusUnauthorized, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40115, err.Error())
|
||||
return
|
||||
}
|
||||
c.Set(string(middleware.UserIDKey), res.User.ID)
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// Logout handles POST /auth/logout.
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
tok := bearerToken(c)
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/consult"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// ConsultHandler serves /api/v1/psychic/* (ECR-050).
|
||||
type ConsultHandler struct {
|
||||
Svc *consult.Service
|
||||
}
|
||||
|
||||
func (h *ConsultHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/psychic")
|
||||
g.POST("/banner/all", h.banners)
|
||||
g.GET("/news/page", h.newsPage)
|
||||
g.GET("/news/get", h.newsGet)
|
||||
g.POST("/procotol/getByCode", h.protocol)
|
||||
g.POST("/study/list", h.studyList)
|
||||
g.POST("/study/getSingleDetail", h.studyDetail)
|
||||
g.POST("/user-choice/save", h.choiceSave)
|
||||
g.POST("/user-choice/getResult", h.choiceResult)
|
||||
g.POST("/user-choice/my-test", h.myTest)
|
||||
g.GET("/doctor-info/business-scope-list", h.scopes)
|
||||
g.GET("/doctor-info/page", h.doctorPage)
|
||||
g.GET("/doctor-info/get", h.doctorGet)
|
||||
g.POST("/doctor-info/focus", h.focusOn)
|
||||
g.POST("/doctor-info/cancel-focus", h.focusOff)
|
||||
g.GET("/doctor-info/get-show-info", h.showInfo)
|
||||
g.POST("/appointment/remain-list", h.remain)
|
||||
g.POST("/appointment/date-detail-list", h.dateDetail)
|
||||
g.POST("/order/create", h.orderCreate)
|
||||
g.GET("/order/get-pay-param", h.payParam)
|
||||
g.POST("/order/order-list", h.orderList)
|
||||
g.POST("/order/order-detail", h.orderDetail)
|
||||
g.POST("/order/cancel", h.orderCancel)
|
||||
g.POST("/order/delete-order", h.orderDelete)
|
||||
g.POST("/pay/createOrder", h.payDemo)
|
||||
g.POST("/pay/refund", h.payRefund)
|
||||
g.POST("/pay/notify", h.payNotify)
|
||||
g.GET("/platform-user/getSelfInfo", h.selfInfo)
|
||||
g.POST("/platform-user/update", h.updateSelf)
|
||||
g.GET("/platform-user/not-read-num", h.notRead)
|
||||
g.POST("/platform-user/focus-to-read", h.focusRead)
|
||||
g.POST("/platform-user/order-to-read", h.orderRead)
|
||||
g.POST("/platform-user/focus-all", h.focusAll)
|
||||
g.POST("/platform-user/user-feedback", h.feedback)
|
||||
g.POST("/platform-user/feedback-flag", h.feedbackFlag)
|
||||
}
|
||||
|
||||
func (h *ConsultHandler) banners(c *gin.Context) {
|
||||
data, err := h.Svc.Banners(c.Request.Context())
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) newsPage(c *gin.Context) {
|
||||
data, err := h.Svc.NewsPage(c.Request.Context(), qInt(c, "type", 0), qInt(c, "showMain", -1), qInt(c, "pageNo", 1), qInt(c, "pageSize", 20))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
|
||||
func (h *ConsultHandler) ok(c *gin.Context, data any, err error) {
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, data)
|
||||
}
|
||||
|
||||
func (h *ConsultHandler) newsGet(c *gin.Context) {
|
||||
data, err := h.Svc.NewsGet(c.Request.Context(), qInt64(c, "id"))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) protocol(c *gin.Context) {
|
||||
data, err := h.Svc.ProtocolByCode(c.Request.Context(), c.Query("code"))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) studyList(c *gin.Context) {
|
||||
data, err := h.Svc.TestList(c.Request.Context(), qInt(c, "showMain", -1))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) studyDetail(c *gin.Context) {
|
||||
data, err := h.Svc.TestDetail(c.Request.Context(), qInt64(c, "studyId"))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) choiceSave(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in consult.ChoiceIn
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.SaveChoice(c.Request.Context(), uid, in)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) choiceResult(c *gin.Context) {
|
||||
data, err := h.Svc.GetResult(c.Request.Context(), qInt64(c, "resultId"))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) myTest(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
PageNo int `json:"pageNo"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&p)
|
||||
data, err := h.Svc.MyTests(c.Request.Context(), uid, p.PageNo, p.PageSize)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) scopes(c *gin.Context) {
|
||||
data, err := h.Svc.Scopes(c.Request.Context())
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) doctorPage(c *gin.Context) {
|
||||
data, err := h.Svc.DoctorPage(c.Request.Context(), c.Query("businessScope"), qInt(c, "isTop", -1), qInt(c, "pageNo", 1), qInt(c, "pageSize", 20))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) doctorGet(c *gin.Context) {
|
||||
uid, _ := middleware.UserIDFromContext(c)
|
||||
data, err := h.Svc.DoctorGet(c.Request.Context(), qInt64(c, "id"), uid)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) focusOn(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.SetFocus(c.Request.Context(), uid, qInt64(c, "id"), true); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) focusOff(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.SetFocus(c.Request.Context(), uid, qInt64(c, "id"), false); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) showInfo(c *gin.Context) {
|
||||
uid, _ := middleware.UserIDFromContext(c)
|
||||
data, err := h.Svc.ShowInfo(c.Request.Context(), qInt64(c, "id"), uid)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) remain(c *gin.Context) {
|
||||
var in struct {
|
||||
DoctorID int64 `json:"doctorId"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
data, err := h.Svc.RemainList(c.Request.Context(), in.DoctorID)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) dateDetail(c *gin.Context) {
|
||||
var in struct {
|
||||
DoctorID int64 `json:"doctorId"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
data, err := h.Svc.DateDetail(c.Request.Context(), in.DoctorID)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) orderCreate(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in consult.OrderIn
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.CreateOrder(c.Request.Context(), uid, in)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) payParam(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.PayParam(c.Request.Context(), uid, c.Query("orderSn"))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) orderList(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
PageNo int `json:"pageNo"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&p)
|
||||
data, err := h.Svc.OrderList(c.Request.Context(), uid, p.PageNo, p.PageSize)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) orderDetail(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.OrderDetail(c.Request.Context(), uid, qInt64(c, "orderId"))
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) orderCancel(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.CancelOrder(c.Request.Context(), uid, qInt64(c, "orderId")); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) orderDelete(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.DeleteOrder(c.Request.Context(), uid, qInt64(c, "orderId")); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) payDemo(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.DemoPay(c.Request.Context(), uid)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) payRefund(c *gin.Context) {
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) payNotify(c *gin.Context) {
|
||||
raw, _ := c.GetRawData()
|
||||
sn := extractXML(string(raw), "out_trade_no")
|
||||
if sn != "" {
|
||||
_ = h.Svc.MarkPaid(c.Request.Context(), sn)
|
||||
}
|
||||
c.String(http.StatusOK, "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>")
|
||||
}
|
||||
func (h *ConsultHandler) selfInfo(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.SelfInfo(c.Request.Context(), uid)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) updateSelf(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
NickName string `json:"nickName"`
|
||||
AvatarUrl string `json:"avatarUrl"`
|
||||
StayPeriod string `json:"stayPeriod"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
if err := h.Svc.UpdateSelf(c.Request.Context(), uid, in.NickName, in.AvatarUrl, in.StayPeriod); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) notRead(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.NotRead(c.Request.Context(), uid)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) focusRead(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = h.Svc.FocusToRead(c.Request.Context(), uid)
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) orderRead(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = h.Svc.OrderToRead(c.Request.Context(), uid)
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) focusAll(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.FocusAll(c.Request.Context(), uid)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
func (h *ConsultHandler) feedback(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ContentText string `json:"contentText"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
if err := h.Svc.Feedback(c.Request.Context(), uid, in.ContentText); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, true)
|
||||
}
|
||||
func (h *ConsultHandler) feedbackFlag(c *gin.Context) {
|
||||
uid, ok := h.mustUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.Svc.FeedbackFlag(c.Request.Context(), uid)
|
||||
h.ok(c, data, err)
|
||||
}
|
||||
|
||||
func (h *ConsultHandler) mustUser(c *gin.Context) (uuid.UUID, bool) {
|
||||
uid, ok := middleware.UserIDFromContext(c)
|
||||
if !ok || !h.Svc.Registered(c.Request.Context(), uid) {
|
||||
response.Fail(c, http.StatusUnauthorized, 40112, "请先登录后再使用")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
|
||||
func qInt(c *gin.Context, key string, def int) int {
|
||||
s := c.Query(key)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func qInt64(c *gin.Context, key string) int64 {
|
||||
n, _ := strconv.ParseInt(c.Query(key), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func extractXML(s, tag string) string {
|
||||
open, close := "<"+tag+">", "</"+tag+">"
|
||||
i := strings.Index(s, open)
|
||||
if i < 0 {
|
||||
open, close = "<"+tag+"><![CDATA[", "]]></"+tag+">"
|
||||
i = strings.Index(s, open)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
i += len(open)
|
||||
j := strings.Index(s[i:], close)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[i : i+j]
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// MediaHandler serves uploaded media files.
|
||||
@@ -17,6 +23,8 @@ type MediaHandler struct {
|
||||
// Register mounts public media routes.
|
||||
func (h *MediaHandler) Register(api *gin.RouterGroup) {
|
||||
api.GET("/media/avatars/:file", h.ServeAvatar)
|
||||
api.GET("/media/uploads/:file", h.ServeUpload)
|
||||
api.POST("/upload", h.Upload)
|
||||
}
|
||||
|
||||
// ServeAvatar streams a stored avatar image.
|
||||
@@ -34,3 +42,54 @@ func (h *MediaHandler) ServeAvatar(c *gin.Context) {
|
||||
c.File(full)
|
||||
_ = filepath.Ext(full)
|
||||
}
|
||||
|
||||
func (h *MediaHandler) uploadDir() string {
|
||||
return "data/uploads"
|
||||
}
|
||||
|
||||
func (h *MediaHandler) ServeUpload(c *gin.Context) {
|
||||
name := filepath.Base(c.Param("file"))
|
||||
full := filepath.Join(h.uploadDir(), name)
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.File(full)
|
||||
}
|
||||
|
||||
func (h *MediaHandler) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "请选择文件")
|
||||
return
|
||||
}
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "读取失败")
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
ext := strings.ToLower(filepath.Ext(fh.Filename))
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
name := hex.EncodeToString(b[:]) + ext
|
||||
if err := os.MkdirAll(h.uploadDir(), 0o755); err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
dstPath := filepath.Join(h.uploadDir(), name)
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, "/api/v1/media/uploads/"+name)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
authsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/auth"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
|
||||
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
|
||||
consultsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/consult"
|
||||
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/membership"
|
||||
@@ -26,6 +27,8 @@ import (
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/relation"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/scale"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/wechat"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/wechatpay"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
@@ -73,7 +76,18 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
adminSvc := &adminsvc.Service{
|
||||
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
|
||||
}
|
||||
authSvc := &authsvc.Service{Repo: authRepo, AvatarDir: "data/avatars"}
|
||||
authSvc := &authsvc.Service{
|
||||
Repo: authRepo,
|
||||
AvatarDir: "data/avatars",
|
||||
WeChat: &wechat.APIClient{AppID: cfg.WeChat.AppID, Secret: cfg.WeChat.Secret},
|
||||
WeChatApp: cfg.WeChat.AppID,
|
||||
}
|
||||
consultSvc := &consultsvc.Service{
|
||||
Pool: pool,
|
||||
Pay: wechatpay.Config{
|
||||
AppID: cfg.Pay.AppID, MchID: cfg.Pay.MchID, APIKey: cfg.Pay.APIKey, NotifyURL: cfg.Pay.NotifyURL,
|
||||
},
|
||||
}
|
||||
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
|
||||
Username: cfg.Admin.BootstrapUsername,
|
||||
Password: cfg.Admin.BootstrapPassword,
|
||||
@@ -87,6 +101,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id")
|
||||
c.Next()
|
||||
})
|
||||
r.Static("/yxg-mp", "data/yxg-mp")
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
handler.NewHealthHandler().Register(api)
|
||||
@@ -99,6 +114,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
authed := api.Group("")
|
||||
authed.Use(middleware.DeviceAuth(pool))
|
||||
(&handler.AuthHandler{Svc: authSvc}).Register(authed)
|
||||
(&handler.ConsultHandler{Svc: consultSvc}).Register(authed)
|
||||
(&handler.AnalyticsHandler{Svc: analyticsSvc}).Register(authed)
|
||||
(&handler.HomeHandler{Svc: homeSvc}).Register(authed)
|
||||
(&handler.StarConfigPublicHandler{Repo: adminRepo}).Register(authed)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConsultNativePublic(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
env, _ := doJSON(t, r, http.MethodPost, "/api/v1/psychic/banner/all", nil, "")
|
||||
if env.Code != 0 {
|
||||
t.Fatalf("banner code %d", env.Code)
|
||||
}
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/psychic/news/page?type=1&showMain=1&pageNo=1&pageSize=10", nil, "")
|
||||
if env.Code != 0 {
|
||||
t.Fatalf("news code %d", env.Code)
|
||||
}
|
||||
env, _ = doJSON(t, r, http.MethodPost, "/api/v1/psychic/study/list?showMain=1", nil, "")
|
||||
if env.Code != 0 {
|
||||
t.Fatalf("study code %d", env.Code)
|
||||
}
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/psychic/doctor-info/page?pageNo=1&pageSize=10&isTop=1", nil, "")
|
||||
if env.Code != 0 {
|
||||
t.Fatalf("doctor page code %d", env.Code)
|
||||
}
|
||||
env, _ = doJSON(t, r, http.MethodPost, "/api/v1/psychic/procotol/getByCode?code=consultation_appointment_agreement", nil, "")
|
||||
if env.Code != 0 {
|
||||
t.Fatalf("protocol code %d", env.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package javabridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client talks to the Java consult stack (internal issue + logout).
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
InternalKey string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// Token is a Java Redis access token.
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
MemberID int64
|
||||
}
|
||||
|
||||
type issueBody struct {
|
||||
OpenID string `json:"openid"`
|
||||
UnionID string `json:"unionId"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
type javaEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Code any `json:"code"`
|
||||
Info string `json:"info"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
MemberID int64 `json:"memberId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Enabled reports whether Java bridging is configured.
|
||||
func (c *Client) Enabled() bool {
|
||||
return c != nil && strings.TrimSpace(c.BaseURL) != "" && strings.TrimSpace(c.InternalKey) != ""
|
||||
}
|
||||
|
||||
// IssueMiniApp asks Java to register-or-find mini_app account and mint a Redis token.
|
||||
func (c *Client) IssueMiniApp(ctx context.Context, openid, unionID, phone string) (*Token, error) {
|
||||
if !c.Enabled() {
|
||||
return nil, errors.New("咨询后台未配置")
|
||||
}
|
||||
raw, _ := json.Marshal(issueBody{OpenID: openid, UnionID: unionID, Phone: phone})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/app-api/oauth/internal/issue-mini-app", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Internal-Key", c.InternalKey)
|
||||
cli := c.http()
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("咨询账号同步失败")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var env javaEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, errors.New("咨询账号同步失败")
|
||||
}
|
||||
codeOK := env.Success || fmt.Sprint(env.Code) == "0" || fmt.Sprint(env.Code) == "200"
|
||||
if resp.StatusCode >= 300 || (!codeOK && env.Data.AccessToken == "") {
|
||||
msg := env.Info
|
||||
if msg == "" {
|
||||
msg = env.Msg
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "咨询账号同步失败"
|
||||
}
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
if env.Data.AccessToken == "" {
|
||||
return nil, errors.New("咨询账号同步失败")
|
||||
}
|
||||
return &Token{AccessToken: env.Data.AccessToken, MemberID: env.Data.MemberID}, nil
|
||||
}
|
||||
|
||||
// Logout revokes a Java access token (best effort).
|
||||
func (c *Client) Logout(ctx context.Context, accessToken string) {
|
||||
if !c.Enabled() || strings.TrimSpace(accessToken) == "" {
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/app-api/oauth/app/logout", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := c.http().Do(req)
|
||||
if err == nil && resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) http() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
// HTTPDo relays an already-built request (consult proxy).
|
||||
func (c *Client) HTTPDo(req *http.Request) (*http.Response, error) {
|
||||
if c == nil {
|
||||
return nil, errors.New("咨询服务未配置")
|
||||
}
|
||||
return c.http().Do(req)
|
||||
}
|
||||
@@ -17,22 +17,27 @@ type AuthRepo struct {
|
||||
|
||||
// AccountRow is a registered user snapshot.
|
||||
type AccountRow struct {
|
||||
ID uuid.UUID
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Nickname string
|
||||
AvatarURL string
|
||||
Status string
|
||||
ID uuid.UUID
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Nickname string
|
||||
AvatarURL string
|
||||
Status string
|
||||
WxOpenID string
|
||||
WxUnionID string
|
||||
JavaPlatformUserID int64
|
||||
}
|
||||
|
||||
// GetByPhone loads a registered user by phone.
|
||||
func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status
|
||||
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status,
|
||||
COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0)
|
||||
FROM users
|
||||
WHERE phone=$1 AND deleted_at IS NULL`, phone,
|
||||
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status)
|
||||
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status,
|
||||
&row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -47,9 +52,11 @@ func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRo
|
||||
row := &AccountRow{}
|
||||
var phone, hash *string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status
|
||||
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status,
|
||||
COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0)
|
||||
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status)
|
||||
).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status,
|
||||
&row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,6 +186,83 @@ func (r *AuthRepo) RevokeSession(ctx context.Context, token string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByWxOpenid loads a user by WeChat openid.
|
||||
func (r *AuthRepo) GetByWxOpenid(ctx context.Context, openid string) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status,
|
||||
COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0)
|
||||
FROM users
|
||||
WHERE wx_openid=$1 AND deleted_at IS NULL`, openid,
|
||||
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status,
|
||||
&row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// BindWeChat writes openid/unionid (and optional phone) onto a user.
|
||||
func (r *AuthRepo) BindWeChat(ctx context.Context, userID uuid.UUID, openid, unionID, phone string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET wx_openid=$2,
|
||||
wx_unionid=COALESCE(NULLIF($3,''), wx_unionid),
|
||||
phone=COALESCE(NULLIF($4,''), phone),
|
||||
updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`,
|
||||
userID, openid, unionID, phone,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetJavaPlatformUserID stores the Java t_platform_user.id.
|
||||
func (r *AuthRepo) SetJavaPlatformUserID(ctx context.Context, userID uuid.UUID, javaID int64) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE users SET java_platform_user_id=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, javaID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetSessionJavaToken stores the Java Redis token on a Go session.
|
||||
func (r *AuthRepo) SetSessionJavaToken(ctx context.Context, goToken, javaToken string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE user_sessions SET java_access_token=$2
|
||||
WHERE token=$1 AND revoked_at IS NULL`, goToken, javaToken)
|
||||
return err
|
||||
}
|
||||
|
||||
// JavaTokenByGoToken returns the cached Java token for a live Go session.
|
||||
func (r *AuthRepo) JavaTokenByGoToken(ctx context.Context, goToken string) (string, error) {
|
||||
var tok *string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT java_access_token FROM user_sessions
|
||||
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, goToken,
|
||||
).Scan(&tok)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tok == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *tok, nil
|
||||
}
|
||||
|
||||
// CreateUserWithWeChat inserts a registered user bound to WeChat.
|
||||
func (r *AuthRepo) CreateUserWithWeChat(ctx context.Context, phone, hash, nickname, openid, unionID string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO users(phone, password_hash, nickname, wx_openid, wx_unionid)
|
||||
VALUES ($1,$2,NULLIF($3,''),$4,NULLIF($5,''))
|
||||
RETURNING id`,
|
||||
phone, hash, nickname, openid, unionID,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// IsRegistered reports whether user has phone.
|
||||
func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/javabridge"
|
||||
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/wechat"
|
||||
)
|
||||
|
||||
// Service handles register/login sessions.
|
||||
@@ -24,6 +26,9 @@ import (
|
||||
type Service struct {
|
||||
Repo *repository.AuthRepo
|
||||
AvatarDir string
|
||||
WeChat wechat.Client
|
||||
WeChatApp string
|
||||
Java *javabridge.Client
|
||||
}
|
||||
|
||||
// Me is the public account payload.
|
||||
@@ -159,10 +164,106 @@ func (s *Service) UpdateAvatar(ctx context.Context, userID uuid.UUID, fh *multip
|
||||
return s.Me(ctx, userID)
|
||||
}
|
||||
|
||||
// WeChatLogin signs in with mini-program js_code + phone encryptedData.
|
||||
func (s *Service) WeChatLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, code, encryptedData, iv string) (*SessionResult, error) {
|
||||
if s.WeChat == nil {
|
||||
return nil, errors.New("微信登录未配置")
|
||||
}
|
||||
sess, err := s.WeChat.Code2Session(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
phone, err := wechat.DecryptPhone(sess.SessionKey, encryptedData, iv, s.WeChatApp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acc, err := s.Repo.GetByWxOpenid(ctx, sess.OpenID)
|
||||
if err == nil {
|
||||
if acc.Status != "active" {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
_ = s.Repo.BindWeChat(ctx, acc.ID, sess.OpenID, sess.UnionID, phone)
|
||||
if deviceKey != "" {
|
||||
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
|
||||
}
|
||||
return s.finishWeChat(ctx, acc.ID, phone, acc.Nickname, false, sess, deviceKey)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byPhone, err := s.Repo.GetByPhone(ctx, phone)
|
||||
if err == nil {
|
||||
if byPhone.Status != "active" {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
_ = s.Repo.BindWeChat(ctx, byPhone.ID, sess.OpenID, sess.UnionID, phone)
|
||||
if deviceKey != "" {
|
||||
_ = s.Repo.BindDevice(ctx, deviceKey, byPhone.ID)
|
||||
}
|
||||
nick := byPhone.Nickname
|
||||
if nick == "" {
|
||||
nick = nickgen.Random()
|
||||
_ = s.Repo.UpdateNickname(ctx, byPhone.ID, nick)
|
||||
}
|
||||
return s.finishWeChat(ctx, byPhone.ID, phone, nick, false, sess, deviceKey)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("wx:"+sess.OpenID), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nick := nickgen.Random()
|
||||
uid := deviceUserID
|
||||
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
|
||||
if curErr == nil && cur.Phone == "" && cur.WxOpenID == "" {
|
||||
if cur.Status != "active" {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, string(hash), nick); err != nil {
|
||||
return nil, errors.New("登录失败,请重试")
|
||||
}
|
||||
_ = s.Repo.BindWeChat(ctx, deviceUserID, sess.OpenID, sess.UnionID, phone)
|
||||
} else {
|
||||
uid, err = s.Repo.CreateUserWithWeChat(ctx, phone, string(hash), nick, sess.OpenID, sess.UnionID)
|
||||
if err != nil {
|
||||
return nil, errors.New("登录失败,请重试")
|
||||
}
|
||||
}
|
||||
if deviceKey != "" {
|
||||
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
|
||||
}
|
||||
return s.finishWeChat(ctx, uid, phone, nick, true, sess, deviceKey)
|
||||
}
|
||||
|
||||
func (s *Service) finishWeChat(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool, sess wechat.Session, _ string) (*SessionResult, error) {
|
||||
res, err := s.issue(ctx, userID, phone, nickname, isNew)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Java != nil && s.Java.Enabled() {
|
||||
if tok, jerr := s.Java.IssueMiniApp(ctx, sess.OpenID, sess.UnionID, phone); jerr == nil && tok != nil {
|
||||
_ = s.Repo.SetSessionJavaToken(ctx, res.Token, tok.AccessToken)
|
||||
if tok.MemberID > 0 {
|
||||
_ = s.Repo.SetJavaPlatformUserID(ctx, userID, tok.MemberID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Logout revokes the current bearer session and unbinds the device from the account
|
||||
// so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5).
|
||||
func (s *Service) Logout(ctx context.Context, token, deviceKey string) error {
|
||||
if token != "" {
|
||||
if s.Java != nil && s.Java.Enabled() {
|
||||
if jt, err := s.Repo.JavaTokenByGoToken(ctx, token); err == nil && jt != "" {
|
||||
s.Java.Logout(ctx, jt)
|
||||
}
|
||||
}
|
||||
if err := s.Repo.RevokeSession(ctx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,818 @@
|
||||
package consult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/wechatpay"
|
||||
)
|
||||
|
||||
// Service implements miniprogram consult APIs on PostgreSQL.
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
Pay wechatpay.Config
|
||||
}
|
||||
|
||||
// Registered reports users.phone present.
|
||||
func (s *Service) Registered(ctx context.Context, userID uuid.UUID) bool {
|
||||
var ok bool
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL)`, userID).Scan(&ok)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Service) Banners(ctx context.Context) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `SELECT id, banner_name, banner_image, click_url, describe, order_index, created_at, jump_type FROM consult_banners ORDER BY order_index, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, order, jump int64
|
||||
var name, img, url, desc string
|
||||
var created time.Time
|
||||
if err := rows.Scan(&id, &name, &img, &url, &desc, &order, &created, &jump); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "bannerName": name, "bannerImage": img, "clickUrl": url,
|
||||
"describe": desc, "orderIndex": order, "createTime": created.Format(time.RFC3339), "jumpType": jump,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) NewsPage(ctx context.Context, typ, showMain, pageNo, pageSize int) (map[string]any, error) {
|
||||
if pageNo <= 0 {
|
||||
pageNo = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
var total int
|
||||
q := `SELECT COUNT(*) FROM consult_news WHERE ($1=0 OR type=$1) AND ($2<0 OR show_main=$2)`
|
||||
if err := s.Pool.QueryRow(ctx, q, typ, showMain).Scan(&total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, type, title, show_image, content, show_main
|
||||
FROM consult_news WHERE ($1=0 OR type=$1) AND ($2<0 OR show_main=$2)
|
||||
ORDER BY id DESC OFFSET $3 LIMIT $4`, typ, showMain, (pageNo-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, t, sm int64
|
||||
var title, img, content string
|
||||
if err := rows.Scan(&id, &t, &title, &img, &content, &sm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, map[string]any{"id": id, "type": t, "title": title, "showImage": img, "content": content, "showMain": sm})
|
||||
}
|
||||
return map[string]any{"list": list, "total": total}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) NewsGet(ctx context.Context, id int64) (map[string]any, error) {
|
||||
var t, sm int64
|
||||
var title, img, content string
|
||||
err := s.Pool.QueryRow(ctx, `SELECT type, title, show_image, content, show_main FROM consult_news WHERE id=$1`, id).
|
||||
Scan(&t, &title, &img, &content, &sm)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("资讯不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": id, "type": t, "title": title, "showImage": img, "content": content, "showMain": sm}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProtocolByCode(ctx context.Context, code string) (map[string]any, error) {
|
||||
var id int64
|
||||
var title, contextText string
|
||||
err := s.Pool.QueryRow(ctx, `SELECT id, title, context FROM consult_protocols WHERE code=$1 AND status=1`, code).
|
||||
Scan(&id, &title, &contextText)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("协议不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": id, "code": code, "title": title, "context": contextText}, nil
|
||||
}
|
||||
|
||||
func (s *Service) TestList(ctx context.Context, showMain int) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, test_name, sub_title, test_pic, total_num, show_main
|
||||
FROM consult_tests WHERE status=1 AND ($1<0 OR show_main=$1) ORDER BY id`, showMain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, total, sm int64
|
||||
var name, sub, pic string
|
||||
if err := rows.Scan(&id, &name, &sub, &pic, &total, &sm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{"id": id, "testName": name, "subTitle": sub, "testPic": pic, "totalNum": total, "showMain": sm})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) TestDetail(ctx context.Context, studyID int64) (map[string]any, error) {
|
||||
var name, sub, pic, intro, notice string
|
||||
var total int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT test_name, sub_title, test_pic, test_introduction, total_num, test_notice
|
||||
FROM consult_tests WHERE id=$1 AND status=1`, studyID).
|
||||
Scan(&name, &sub, &pic, &intro, &total, ¬ice)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("测评不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qrows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, question_type, question_text, question_image, required, order_index
|
||||
FROM consult_questions WHERE test_id=$1 ORDER BY order_index, id`, studyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer qrows.Close()
|
||||
questions := []map[string]any{}
|
||||
for qrows.Next() {
|
||||
var qid, qt, req, ord int64
|
||||
var text, img string
|
||||
if err := qrows.Scan(&qid, &qt, &text, &img, &req, &ord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orows, err := s.Pool.Query(ctx, `SELECT id, option_text, order_index FROM consult_options WHERE question_id=$1 ORDER BY order_index, id`, qid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := []map[string]any{}
|
||||
for orows.Next() {
|
||||
var oid, oord int64
|
||||
var ot string
|
||||
if err := orows.Scan(&oid, &ot, &oord); err != nil {
|
||||
orows.Close()
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, map[string]any{"id": oid, "optionText": ot, "orderIndex": oord})
|
||||
}
|
||||
orows.Close()
|
||||
questions = append(questions, map[string]any{
|
||||
"id": qid, "questionType": qt, "questionText": text, "questionImgae": img,
|
||||
"required": req, "orderIndex": ord, "questionOptionVOList": opts,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": studyID, "testName": name, "subTitle": sub, "testPic": pic,
|
||||
"testIntroduction": intro, "totalNum": total, "testNotice": notice,
|
||||
"questionDetailVOList": questions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ChoiceIn struct {
|
||||
ID int64 `json:"id"`
|
||||
TotalTime int `json:"totalTime"`
|
||||
UserChoiceOptionVOList []struct {
|
||||
QuestionID int64 `json:"questionId"`
|
||||
OptionID int64 `json:"optionId"`
|
||||
} `json:"userChoiceOptionVOList"`
|
||||
}
|
||||
|
||||
func (s *Service) SaveChoice(ctx context.Context, userID uuid.UUID, in ChoiceIn) (int64, error) {
|
||||
if in.ID == 0 {
|
||||
return 0, errors.New("缺少测评")
|
||||
}
|
||||
score := 0
|
||||
for _, it := range in.UserChoiceOptionVOList {
|
||||
var sc int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT option_score FROM consult_options WHERE id=$1`, it.OptionID).Scan(&sc)
|
||||
score += sc
|
||||
}
|
||||
var resultID int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM consult_test_results
|
||||
WHERE test_id=$1 AND $2 BETWEEN min_score AND max_score
|
||||
ORDER BY id LIMIT 1`, in.ID, score).Scan(&resultID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = s.Pool.QueryRow(ctx, `SELECT id FROM consult_test_results WHERE test_id=$1 ORDER BY min_score LIMIT 1`, in.ID).Scan(&resultID)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.New("暂无匹配结果")
|
||||
}
|
||||
raw, _ := json.Marshal(in.UserChoiceOptionVOList)
|
||||
var choiceID int64
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO consult_user_choices(user_id, test_id, result_id, total_time, choice_info)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING id`, userID, in.ID, resultID, in.TotalTime, raw).Scan(&choiceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE consult_tests SET total_num=total_num+1, actual_num=actual_num+1 WHERE id=$1`, in.ID)
|
||||
return resultID, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetResult(ctx context.Context, resultID int64) (map[string]any, error) {
|
||||
var testID int64
|
||||
var desc, analysis, plan, name, sub, pic string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT r.test_id, r.result_desc, r.result_analysis, r.treat_plan, t.test_name, t.sub_title, t.test_pic
|
||||
FROM consult_test_results r JOIN consult_tests t ON t.id=r.test_id
|
||||
WHERE r.id=$1`, resultID).Scan(&testID, &desc, &analysis, &plan, &name, &sub, &pic)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("结果不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"id": resultID, "testId": testID, "testName": name, "subTitle": sub, "testPic": pic,
|
||||
"resultDesc": desc, "resultAnalysis": analysis, "treatPlan": plan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MyTests(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) {
|
||||
if pageNo <= 0 {
|
||||
pageNo = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
var total int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_user_choices WHERE user_id=$1`, userID).Scan(&total)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id, t.test_name, t.test_pic, t.sub_title, c.result_id, to_char(c.created_at,'YYYY-MM-DD HH24:MI')
|
||||
FROM consult_user_choices c JOIN consult_tests t ON t.id=c.test_id
|
||||
WHERE c.user_id=$1 ORDER BY c.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, rid int64
|
||||
var name, pic, sub, start string
|
||||
if err := rows.Scan(&id, &name, &pic, &sub, &rid, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, map[string]any{"id": id, "testName": name, "testPic": pic, "subTitle": sub, "testResultId": rid, "startTime": start})
|
||||
}
|
||||
return map[string]any{"list": list, "total": total}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Scopes(ctx context.Context) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `SELECT code, name FROM consult_business_scopes ORDER BY code`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var code, name string
|
||||
if err := rows.Scan(&code, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{"code": code, "name": name})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) DoctorPage(ctx context.Context, scope string, isTop, pageNo, pageSize int) (map[string]any, error) {
|
||||
if pageNo <= 0 {
|
||||
pageNo = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
scope = strings.TrimSpace(scope)
|
||||
var total int
|
||||
_ = s.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM consult_doctors
|
||||
WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2)`, scope, isTop).Scan(&total)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, avatar, business_scope, tags, consultation_method, introduction, price
|
||||
FROM consult_doctors
|
||||
WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2)
|
||||
ORDER BY is_top DESC, id OFFSET $3 LIMIT $4`, scope, isTop, (pageNo-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, price int64
|
||||
var name, avatar, bs, tags, method, intro string
|
||||
if err := rows.Scan(&id, &name, &avatar, &bs, &tags, &method, &intro, &price); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"id": id, "name": name, "avatar": avatar, "businessScope": bs, "tags": tags,
|
||||
"consultationMethod": method, "introduction": intro, "price": price, "availableDate": "",
|
||||
})
|
||||
}
|
||||
return map[string]any{"list": list, "total": total}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) DoctorGet(ctx context.Context, id int64, userID uuid.UUID) (map[string]any, error) {
|
||||
var name, avatar, bs, cover, method, edu, intro, resume, notice, tags, exp, addr, addrD string
|
||||
var price, status, showN, top int64
|
||||
var workStart *time.Time
|
||||
var created time.Time
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT name, avatar, business_scope, cover_url, consultation_method, education, introduction, resume, notice, tags,
|
||||
work_experience, work_start_time, price, status, address, address_detail, show_service_num, is_top, created_at
|
||||
FROM consult_doctors WHERE id=$1`, id).Scan(
|
||||
&name, &avatar, &bs, &cover, &method, &edu, &intro, &resume, ¬ice, &tags,
|
||||
&exp, &workStart, &price, &status, &addr, &addrD, &showN, &top, &created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("咨询师不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
focus := false
|
||||
if userID != uuid.Nil {
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consult_focus WHERE user_id=$1 AND doctor_id=$2 AND status=1)`, userID, id).Scan(&focus)
|
||||
}
|
||||
ws := ""
|
||||
if workStart != nil {
|
||||
ws = workStart.Format("2006-01-02")
|
||||
}
|
||||
if strings.TrimSpace(cover) == "" {
|
||||
cover = avatar
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id, "userId": 0, "name": name, "avatar": avatar, "businessScope": bs, "coverUrl": cover,
|
||||
"consultationMethod": method, "education": edu, "introduction": intro, "resume": resume,
|
||||
"notice": notice, "tags": tags, "workExperience": exp, "workStartTime": ws, "price": price,
|
||||
"createTime": created.Format(time.RFC3339), "focus": focus, "address": addr, "addressDetail": addrD,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetFocus(ctx context.Context, userID uuid.UUID, doctorID int64, on bool) error {
|
||||
st := 0
|
||||
if on {
|
||||
st = 1
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO consult_focus(user_id, doctor_id, status, read_status)
|
||||
VALUES ($1,$2,$3,0)
|
||||
ON CONFLICT (user_id, doctor_id) DO UPDATE SET status=$3, read_status=0`, userID, doctorID, st)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ShowInfo(ctx context.Context, doctorID int64, userID uuid.UUID) (map[string]any, error) {
|
||||
d, err := s.DoctorGet(ctx, doctorID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": d["id"], "name": d["name"], "avatar": d["avatar"],
|
||||
"consultationMethod": d["consultationMethod"], "price": d["price"], "address": d["address"],
|
||||
}
|
||||
if userID != uuid.Nil {
|
||||
var name, phone string
|
||||
var sex int
|
||||
var bday *time.Time
|
||||
var em string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT appointment_name, birthday, phone, sex, emergency_contact_info
|
||||
FROM consult_orders WHERE user_id=$1 AND user_deleted=0 ORDER BY id DESC LIMIT 1`, userID).
|
||||
Scan(&name, &bday, &phone, &sex, &em)
|
||||
if err == nil {
|
||||
bs := ""
|
||||
if bday != nil {
|
||||
bs = bday.Format("2006-01-02")
|
||||
}
|
||||
emPhone, emType := "", 1
|
||||
var obj map[string]any
|
||||
if json.Unmarshal([]byte(em), &obj) == nil {
|
||||
if v, ok := obj["phone"].(string); ok {
|
||||
emPhone = v
|
||||
}
|
||||
switch v := obj["type"].(type) {
|
||||
case float64:
|
||||
emType = int(v)
|
||||
}
|
||||
}
|
||||
out["appointmentInfoAppRespVO"] = map[string]any{
|
||||
"appointmentName": name, "birthday": bs, "phone": phone, "sex": sex,
|
||||
"emergencyContactPhone": emPhone, "emergencyContactType": emType,
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemainList(ctx context.Context, doctorID int64) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT d.schedule_date, d.id,
|
||||
(SELECT COUNT(*) FROM consult_slots s WHERE s.schedule_id=d.id AND s.status=0)
|
||||
FROM consult_schedule_days d
|
||||
WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+30
|
||||
ORDER BY d.schedule_date`, doctorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var day time.Time
|
||||
var sid, remain int64
|
||||
if err := rows.Scan(&day, &sid, &remain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{"scheduleDate": day.Format("2006-01-02"), "remainNum": remain, "scheduleDateId": sid})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) DateDetail(ctx context.Context, doctorID int64) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT d.id, d.schedule_date FROM consult_schedule_days d
|
||||
WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+13
|
||||
ORDER BY d.schedule_date`, doctorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var sid int64
|
||||
var day time.Time
|
||||
if err := rows.Scan(&sid, &day); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, start_time, end_time, consultation_method FROM consult_slots
|
||||
WHERE schedule_id=$1 AND status=0 ORDER BY start_time`, sid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slots := []map[string]any{}
|
||||
for srows.Next() {
|
||||
var id int64
|
||||
var st, et time.Time
|
||||
var method string
|
||||
if err := srows.Scan(&id, &st, &et, &method); err != nil {
|
||||
srows.Close()
|
||||
return nil, err
|
||||
}
|
||||
slots = append(slots, map[string]any{
|
||||
"slotId": id, "startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"),
|
||||
"consultationMethod": method,
|
||||
})
|
||||
}
|
||||
srows.Close()
|
||||
out = append(out, map[string]any{
|
||||
"scheduleId": sid, "scheduleDate": day.Format("2006-01-02"),
|
||||
"weekStr": weekdayCN(day), "slotVOList": slots,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type OrderIn struct {
|
||||
SlotID int64 `json:"slotId"`
|
||||
AppointmentName string `json:"appointmentName"`
|
||||
Birthday string `json:"birthday"`
|
||||
Phone string `json:"phone"`
|
||||
Sex int `json:"sex"`
|
||||
EmergencyContactInfo string `json:"emergencyContactInfo"`
|
||||
TotalAmount int `json:"totalAmount"`
|
||||
ConsultationMethod string `json:"consultationMethod"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in OrderIn) (wechatpay.JSAPIParams, error) {
|
||||
var doctorID int64
|
||||
var day time.Time
|
||||
var st, et time.Time
|
||||
var status int
|
||||
var price int
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT s.doctor_id, d.schedule_date, s.start_time, s.end_time, s.status, doc.price
|
||||
FROM consult_slots s
|
||||
JOIN consult_schedule_days d ON d.id=s.schedule_id
|
||||
JOIN consult_doctors doc ON doc.id=s.doctor_id
|
||||
WHERE s.id=$1`, in.SlotID).Scan(&doctorID, &day, &st, &et, &status, &price)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return wechatpay.JSAPIParams{}, errors.New("时段不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
if status != 0 {
|
||||
return wechatpay.JSAPIParams{}, errors.New("该时段已被预约")
|
||||
}
|
||||
if in.TotalAmount > 0 && in.TotalAmount != price {
|
||||
return wechatpay.JSAPIParams{}, errors.New("价格已变化,请刷新")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `UPDATE consult_slots SET status=1 WHERE id=$1 AND status=0`, in.SlotID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return wechatpay.JSAPIParams{}, errors.New("该时段已被预约")
|
||||
}
|
||||
sn := fmt.Sprintf("YG%s%06d", time.Now().Format("20060102150405"), in.SlotID%1000000)
|
||||
method := in.ConsultationMethod
|
||||
if method == "" {
|
||||
method = "online"
|
||||
}
|
||||
var bday *time.Time
|
||||
if in.Birthday != "" {
|
||||
if t, e := time.Parse("2006-01-02", in.Birthday); e == nil {
|
||||
bday = &t
|
||||
}
|
||||
}
|
||||
valid := time.Now().Add(15 * time.Minute)
|
||||
var oid int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO consult_orders(order_sn, slot_id, doctor_id, user_id, appointment_date, start_time, end_time, status,
|
||||
appointment_name, birthday, phone, sex, emergency_contact_info, total_amount, valid_time, consultation_method)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,0,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`,
|
||||
sn, in.SlotID, doctorID, userID, day, st, et, in.AppointmentName, bday, in.Phone, in.Sex,
|
||||
in.EmergencyContactInfo, price, valid, method).Scan(&oid)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
openid := ""
|
||||
_ = tx.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid)
|
||||
pay, err := wechatpay.UnifiedOrder(s.Pay, openid, sn, price)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
raw, _ := json.Marshal(pay)
|
||||
_, _ = tx.Exec(ctx, `UPDATE consult_orders SET pay_param=$2 WHERE id=$1`, oid, raw)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
return pay, nil
|
||||
}
|
||||
|
||||
func (s *Service) PayParam(ctx context.Context, userID uuid.UUID, orderSn string) (wechatpay.JSAPIParams, error) {
|
||||
var raw []byte
|
||||
var owner uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `SELECT user_id, pay_param FROM consult_orders WHERE order_sn=$1 AND user_deleted=0`, orderSn).
|
||||
Scan(&owner, &raw)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, errors.New("订单不存在")
|
||||
}
|
||||
if owner != userID {
|
||||
return wechatpay.JSAPIParams{}, errors.New("无权查看")
|
||||
}
|
||||
var p wechatpay.JSAPIParams
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
}
|
||||
if p.PaySign == "" {
|
||||
p = wechatpay.MockJSAPI()
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) OrderList(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) {
|
||||
if pageNo <= 0 {
|
||||
pageNo = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
var total int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0`, userID).Scan(&total)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT o.id, o.doctor_id, o.order_sn, d.name, d.avatar, o.consultation_method, o.status, o.total_amount,
|
||||
to_char(o.start_time,'HH24:MI:SS'), to_char(o.end_time,'HH24:MI:SS'), o.valid_time, o.created_at, o.cancel_time, o.cancel_flag
|
||||
FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id
|
||||
WHERE o.user_id=$1 AND o.user_deleted=0
|
||||
ORDER BY o.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, did, status, amount, cancelFlag int64
|
||||
var sn, name, avatar, method, st, et string
|
||||
var valid, created *time.Time
|
||||
var cancel *time.Time
|
||||
if err := rows.Scan(&id, &did, &sn, &name, &avatar, &method, &status, &amount, &st, &et, &valid, &created, &cancel, &cancelFlag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": id, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar,
|
||||
"consultationMethod": method, "status": status, "totalAmount": amount,
|
||||
"startTime": st, "endTime": et, "cancelFlag": cancelFlag,
|
||||
}
|
||||
if valid != nil {
|
||||
item["validTime"] = valid.Format(time.RFC3339)
|
||||
}
|
||||
if created != nil {
|
||||
item["createTime"] = created.Format(time.RFC3339)
|
||||
}
|
||||
if cancel != nil {
|
||||
item["cancelTime"] = cancel.Format(time.RFC3339)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return map[string]any{"list": list, "total": total}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) OrderDetail(ctx context.Context, userID uuid.UUID, orderID int64) (map[string]any, error) {
|
||||
var did int64
|
||||
var sn, aname, phone, method, em, addrD string
|
||||
var status, sex, amount, cancelFlag int64
|
||||
var bday *time.Time
|
||||
var st, et time.Time
|
||||
var day time.Time
|
||||
var name, avatar string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT o.doctor_id, o.order_sn, o.appointment_name, o.birthday, o.phone, o.sex, o.emergency_contact_info,
|
||||
o.status, o.total_amount, o.consultation_method, o.cancel_flag, o.appointment_date, o.start_time, o.end_time,
|
||||
d.name, d.avatar, d.address_detail
|
||||
FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id
|
||||
WHERE o.id=$1 AND o.user_id=$2 AND o.user_deleted=0`, orderID, userID).
|
||||
Scan(&did, &sn, &aname, &bday, &phone, &sex, &em, &status, &amount, &method, &cancelFlag, &day, &st, &et, &name, &avatar, &addrD)
|
||||
if err != nil {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
bs := ""
|
||||
if bday != nil {
|
||||
bs = bday.Format("2006-01-02")
|
||||
}
|
||||
return map[string]any{
|
||||
"id": orderID, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar,
|
||||
"appointmentName": aname, "birthday": bs, "phone": phone, "sex": sex,
|
||||
"emergencyContactInfo": em, "status": status, "totalAmount": amount,
|
||||
"consultationMethod": method, "cancelFlag": cancelFlag, "addressDetail": addrD,
|
||||
"appointmentDate": day.Format("2006-01-02"),
|
||||
"startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CancelOrder(ctx context.Context, userID uuid.UUID, orderID int64) error {
|
||||
var slotID int64
|
||||
var status int
|
||||
err := s.Pool.QueryRow(ctx, `SELECT slot_id, status FROM consult_orders WHERE id=$1 AND user_id=$2 AND user_deleted=0`, orderID, userID).
|
||||
Scan(&slotID, &status)
|
||||
if err != nil {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
if status != 0 {
|
||||
return errors.New("当前状态不可取消")
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE consult_slots SET status=0 WHERE id=$1`, slotID)
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE consult_orders SET status=9, cancel_time=now(), cancel_flag=2 WHERE id=$1`, orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DeleteOrder(ctx context.Context, userID uuid.UUID, orderID int64) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_deleted=1 WHERE id=$1 AND user_id=$2`, orderID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkPaid(ctx context.Context, orderSn string) error {
|
||||
_, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET status=1, pay_time=now() WHERE order_sn=$1 AND status=0`, orderSn)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DemoPay(ctx context.Context, userID uuid.UUID) (wechatpay.JSAPIParams, error) {
|
||||
openid := ""
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid)
|
||||
return wechatpay.UnifiedOrder(s.Pay, openid, fmt.Sprintf("DEMO%d", time.Now().Unix()), 1)
|
||||
}
|
||||
|
||||
func (s *Service) SelfInfo(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
|
||||
var nick, avatar, phone, status string
|
||||
var created time.Time
|
||||
err := s.Pool.QueryRow(ctx, `SELECT COALESCE(nickname,''), COALESCE(avatar_url,''), COALESCE(phone,''), status, created_at FROM users WHERE id=$1`, userID).
|
||||
Scan(&nick, &avatar, &phone, &status, &created)
|
||||
if err != nil {
|
||||
return nil, errors.New("请先登录")
|
||||
}
|
||||
var stay, idCard, realName string
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(stay_period,''), COALESCE(id_card,''), COALESCE(real_name,'') FROM consult_user_ext WHERE user_id=$1`, userID).
|
||||
Scan(&stay, &idCard, &realName)
|
||||
st := 1
|
||||
if status != "active" {
|
||||
st = 0
|
||||
}
|
||||
return map[string]any{
|
||||
"id": userID.String(), "status": st, "idCard": idCard, "realName": realName,
|
||||
"createTime": created.Format(time.RFC3339), "nickName": nick, "avatarUrl": avatar, "stayPeriod": stay, "phone": phone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateSelf(ctx context.Context, userID uuid.UUID, nick, avatar, stay string) error {
|
||||
if nick != "" {
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE users SET nickname=$2, updated_at=now() WHERE id=$1`, userID, nick)
|
||||
}
|
||||
if avatar != "" {
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE users SET avatar_url=$2, updated_at=now() WHERE id=$1`, userID, avatar)
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO consult_user_ext(user_id, stay_period) VALUES ($1,$2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET stay_period=COALESCE(NULLIF($2,''), consult_user_ext.stay_period), updated_at=now()`,
|
||||
userID, stay)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) NotRead(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
|
||||
var focus, order int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_focus WHERE user_id=$1 AND status=1 AND read_status=0`, userID).Scan(&focus)
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0 AND user_read=0`, userID).Scan(&order)
|
||||
return map[string]any{"focusNum": focus, "orderNum": order}, nil
|
||||
}
|
||||
|
||||
func (s *Service) FocusToRead(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := s.Pool.Exec(ctx, `UPDATE consult_focus SET read_status=1 WHERE user_id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) OrderToRead(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_read=1 WHERE user_id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) FocusAll(ctx context.Context, userID uuid.UUID) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT d.id, d.name, d.avatar, d.show_service_num, d.introduction, d.price, d.work_start_time
|
||||
FROM consult_focus f JOIN consult_doctors d ON d.id=f.doctor_id
|
||||
WHERE f.user_id=$1 AND f.status=1 ORDER BY f.id DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, show, price int64
|
||||
var name, avatar, intro string
|
||||
var ws *time.Time
|
||||
if err := rows.Scan(&id, &name, &avatar, &show, &intro, &price, &ws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workNum := 0
|
||||
if ws != nil {
|
||||
workNum = time.Now().Year() - ws.Year()
|
||||
if workNum < 0 {
|
||||
workNum = 0
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "name": name, "avatar": avatar, "workNum": workNum,
|
||||
"showServiceNum": show, "introduction": intro, "price": price,
|
||||
"workStartTime": timeOrEmpty(ws),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Feedback(ctx context.Context, userID uuid.UUID, text string) error {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return errors.New("请填写内容")
|
||||
}
|
||||
phone := ""
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(phone,'') FROM users WHERE id=$1`, userID).Scan(&phone)
|
||||
_, err := s.Pool.Exec(ctx, `INSERT INTO consult_feedback(user_id, content_text, contact) VALUES ($1,$2,$3)`, userID, text, phone)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) FeedbackFlag(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_feedback WHERE user_id=$1 AND created_at > now()-interval '5 minutes'`, userID).Scan(&n)
|
||||
return n == 0, nil
|
||||
}
|
||||
|
||||
func weekdayCN(t time.Time) string {
|
||||
names := []string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"}
|
||||
return names[int(t.Weekday())]
|
||||
}
|
||||
|
||||
func timeOrEmpty(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session is the jscode2session result.
|
||||
type Session struct {
|
||||
OpenID string
|
||||
UnionID string
|
||||
SessionKey string
|
||||
}
|
||||
|
||||
// Client exchanges a mini-program js_code for a session.
|
||||
type Client interface {
|
||||
Code2Session(ctx context.Context, code string) (Session, error)
|
||||
}
|
||||
|
||||
// APIClient calls api.weixin.qq.com.
|
||||
type APIClient struct {
|
||||
AppID string
|
||||
Secret string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
type wxSessionResp struct {
|
||||
OpenID string `json:"openid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// Enabled reports whether WeChat credentials are configured.
|
||||
func (c *APIClient) Enabled() bool {
|
||||
return c != nil && strings.TrimSpace(c.AppID) != "" && strings.TrimSpace(c.Secret) != ""
|
||||
}
|
||||
|
||||
// Code2Session implements Client.
|
||||
func (c *APIClient) Code2Session(ctx context.Context, code string) (Session, error) {
|
||||
if !c.Enabled() {
|
||||
return Session{}, errors.New("微信登录未配置")
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return Session{}, errors.New("缺少微信登录码")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("appid", c.AppID)
|
||||
q.Set("secret", c.Secret)
|
||||
q.Set("js_code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
cli := c.HTTP
|
||||
if cli == nil {
|
||||
cli = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
return Session{}, errors.New("微信服务暂不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body wxSessionResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return Session{}, errors.New("微信服务暂不可用")
|
||||
}
|
||||
if body.ErrCode != 0 {
|
||||
return Session{}, fmt.Errorf("微信登录失败(%d)", body.ErrCode)
|
||||
}
|
||||
if body.OpenID == "" || body.SessionKey == "" {
|
||||
return Session{}, errors.New("微信登录失败")
|
||||
}
|
||||
return Session{OpenID: body.OpenID, UnionID: body.UnionID, SessionKey: body.SessionKey}, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type phonePayload struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
PurePhoneNumber string `json:"purePhoneNumber"`
|
||||
Watermark struct {
|
||||
AppID string `json:"appid"`
|
||||
} `json:"watermark"`
|
||||
}
|
||||
|
||||
// DecryptPhone decrypts WeChat getPhoneNumber encryptedData (AES-128-CBC).
|
||||
func DecryptPhone(sessionKey, encryptedData, iv, expectAppID string) (string, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sessionKey))
|
||||
if err != nil || len(key) != 16 {
|
||||
return "", errors.New("无效的 session_key")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encryptedData))
|
||||
if err != nil || len(raw) == 0 || len(raw)%aes.BlockSize != 0 {
|
||||
return "", errors.New("无效的加密数据")
|
||||
}
|
||||
ivb, err := base64.StdEncoding.DecodeString(strings.TrimSpace(iv))
|
||||
if err != nil || len(ivb) != aes.BlockSize {
|
||||
return "", errors.New("无效的 iv")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain := make([]byte, len(raw))
|
||||
cipher.NewCBCDecrypter(block, ivb).CryptBlocks(plain, raw)
|
||||
plain, err = pkcs7Unpad(plain)
|
||||
if err != nil {
|
||||
return "", errors.New("解密失败")
|
||||
}
|
||||
var p phonePayload
|
||||
if err := json.Unmarshal(plain, &p); err != nil {
|
||||
return "", errors.New("解密失败")
|
||||
}
|
||||
if expectAppID != "" && p.Watermark.AppID != "" && p.Watermark.AppID != expectAppID {
|
||||
return "", errors.New("微信数据校验失败")
|
||||
}
|
||||
phone := strings.TrimSpace(p.PhoneNumber)
|
||||
if phone == "" {
|
||||
phone = strings.TrimSpace(p.PurePhoneNumber)
|
||||
}
|
||||
if phone == "" {
|
||||
return "", errors.New("未获取到手机号")
|
||||
}
|
||||
return phone, nil
|
||||
}
|
||||
|
||||
func pkcs7Unpad(b []byte) ([]byte, error) {
|
||||
if len(b) == 0 {
|
||||
return nil, errors.New("empty")
|
||||
}
|
||||
n := int(b[len(b)-1])
|
||||
if n == 0 || n > len(b) {
|
||||
return nil, errors.New("pad")
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if b[len(b)-1-i] != byte(n) {
|
||||
return nil, errors.New("pad")
|
||||
}
|
||||
}
|
||||
return b[:len(b)-n], nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecryptPhone(t *testing.T) {
|
||||
key := make([]byte, 16)
|
||||
iv := make([]byte, 16)
|
||||
_, _ = rand.Read(key)
|
||||
_, _ = rand.Read(iv)
|
||||
plain := []byte(`{"phoneNumber":"13800138000","purePhoneNumber":"13800138000","watermark":{"appid":"wxapp"}}`)
|
||||
padded := pkcs7Pad(plain, aes.BlockSize)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enc := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(enc, padded)
|
||||
|
||||
phone, err := DecryptPhone(
|
||||
base64.StdEncoding.EncodeToString(key),
|
||||
base64.StdEncoding.EncodeToString(enc),
|
||||
base64.StdEncoding.EncodeToString(iv),
|
||||
"wxapp",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if phone != "13800138000" {
|
||||
t.Fatalf("got %s", phone)
|
||||
}
|
||||
}
|
||||
|
||||
func pkcs7Pad(b []byte, block int) []byte {
|
||||
n := block - (len(b) % block)
|
||||
out := make([]byte, len(b)+n)
|
||||
copy(out, b)
|
||||
for i := 0; i < n; i++ {
|
||||
out[len(b)+i] = byte(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package wechatpay
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is WeChat JSAPI merchant config.
|
||||
type Config struct {
|
||||
AppID string
|
||||
MchID string
|
||||
APIKey string
|
||||
NotifyURL string
|
||||
}
|
||||
|
||||
// Enabled reports merchant credentials present.
|
||||
func (c Config) Enabled() bool {
|
||||
return strings.TrimSpace(c.AppID) != "" && strings.TrimSpace(c.MchID) != "" && strings.TrimSpace(c.APIKey) != ""
|
||||
}
|
||||
|
||||
// JSAPIParams is returned to uni.requestPayment.
|
||||
type JSAPIParams struct {
|
||||
TimeStamp string `json:"timeStamp"`
|
||||
NonceStr string `json:"nonceStr"`
|
||||
PackageStr string `json:"packageStr"`
|
||||
SignType string `json:"signType"`
|
||||
PaySign string `json:"paySign"`
|
||||
}
|
||||
|
||||
// SignMD5 builds WeChat v2 sign.
|
||||
func SignMD5(params map[string]string, apiKey string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k == "sign" || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(params[k])
|
||||
}
|
||||
b.WriteString("&key=")
|
||||
b.WriteString(apiKey)
|
||||
sum := md5.Sum([]byte(b.String()))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// BuildJSAPI signs prepay_id for the miniprogram.
|
||||
func BuildJSAPI(appID, prepayID, apiKey string) JSAPIParams {
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := nonceStr()
|
||||
pkg := "prepay_id=" + prepayID
|
||||
m := map[string]string{
|
||||
"appId": appID, "timeStamp": ts, "nonceStr": nonce,
|
||||
"package": pkg, "signType": "MD5",
|
||||
}
|
||||
return JSAPIParams{
|
||||
TimeStamp: ts, NonceStr: nonce, PackageStr: pkg, SignType: "MD5",
|
||||
PaySign: SignMD5(m, apiKey),
|
||||
}
|
||||
}
|
||||
|
||||
// MockJSAPI is used when merchant keys are absent (dev).
|
||||
func MockJSAPI() JSAPIParams {
|
||||
return JSAPIParams{
|
||||
TimeStamp: strconv.FormatInt(time.Now().Unix(), 10), NonceStr: nonceStr(),
|
||||
PackageStr: "prepay_id=mock", SignType: "MD5", PaySign: "MOCK",
|
||||
}
|
||||
}
|
||||
|
||||
type unifiedXML struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
AppID string `xml:"appid"`
|
||||
MchID string `xml:"mch_id"`
|
||||
Nonce string `xml:"nonce_str"`
|
||||
Body string `xml:"body"`
|
||||
OutNo string `xml:"out_trade_no"`
|
||||
Fee string `xml:"total_fee"`
|
||||
IP string `xml:"spbill_create_ip"`
|
||||
Notify string `xml:"notify_url"`
|
||||
Trade string `xml:"trade_type"`
|
||||
OpenID string `xml:"openid"`
|
||||
Sign string `xml:"sign"`
|
||||
}
|
||||
|
||||
type unifiedResp struct {
|
||||
ReturnCode string `xml:"return_code"`
|
||||
ResultCode string `xml:"result_code"`
|
||||
PrepayID string `xml:"prepay_id"`
|
||||
ErrCodeDes string `xml:"err_code_des"`
|
||||
ReturnMsg string `xml:"return_msg"`
|
||||
}
|
||||
|
||||
// UnifiedOrder calls WeChat v2 JSAPI unifiedorder.
|
||||
func UnifiedOrder(cfg Config, openid, orderNo string, amountFen int) (JSAPIParams, error) {
|
||||
if !cfg.Enabled() {
|
||||
return MockJSAPI(), nil
|
||||
}
|
||||
nonce := nonceStr()
|
||||
params := map[string]string{
|
||||
"appid": cfg.AppID, "mch_id": cfg.MchID, "nonce_str": nonce,
|
||||
"body": "课程预约", "out_trade_no": orderNo, "total_fee": strconv.Itoa(amountFen),
|
||||
"spbill_create_ip": "127.0.0.1", "notify_url": cfg.NotifyURL,
|
||||
"trade_type": "JSAPI", "openid": openid,
|
||||
}
|
||||
params["sign"] = SignMD5(params, cfg.APIKey)
|
||||
raw, err := xml.Marshal(unifiedXML{
|
||||
AppID: cfg.AppID, MchID: cfg.MchID, Nonce: nonce, Body: "课程预约",
|
||||
OutNo: orderNo, Fee: params["total_fee"], IP: "127.0.0.1",
|
||||
Notify: cfg.NotifyURL, Trade: "JSAPI", OpenID: openid, Sign: params["sign"],
|
||||
})
|
||||
if err != nil {
|
||||
return JSAPIParams{}, err
|
||||
}
|
||||
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/unifiedorder", "application/xml", strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return JSAPIParams{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var out unifiedResp
|
||||
if err := xml.Unmarshal(b, &out); err != nil {
|
||||
return JSAPIParams{}, err
|
||||
}
|
||||
if out.ReturnCode != "SUCCESS" || out.ResultCode != "SUCCESS" || out.PrepayID == "" {
|
||||
msg := out.ErrCodeDes
|
||||
if msg == "" {
|
||||
msg = out.ReturnMsg
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "创建订单失败"
|
||||
}
|
||||
return JSAPIParams{}, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return BuildJSAPI(cfg.AppID, out.PrepayID, cfg.APIKey), nil
|
||||
}
|
||||
|
||||
func nonceStr() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return strings.ToUpper(hex.EncodeToString(b[:]))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package wechatpay
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignMD5(t *testing.T) {
|
||||
got := SignMD5(map[string]string{"appid": "wx123", "mch_id": "foo", "sign": "x"}, "k")
|
||||
sum := md5.Sum([]byte("appid=wx123&mch_id=foo&key=k"))
|
||||
want := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
if got != want {
|
||||
t.Fatalf("got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE user_sessions DROP COLUMN IF EXISTS java_access_token;
|
||||
DROP INDEX IF EXISTS idx_users_wx_openid_unique;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS java_platform_user_id;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS wx_unionid;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS wx_openid;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ECR-049: WeChat identity + Java consult token on session
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS wx_openid varchar(64) NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS wx_unionid varchar(64) NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS java_platform_user_id bigint NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_wx_openid_unique
|
||||
ON users(wx_openid) WHERE wx_openid IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS java_access_token text NULL;
|
||||
@@ -0,0 +1,16 @@
|
||||
DROP TABLE IF EXISTS consult_orders;
|
||||
DROP TABLE IF EXISTS consult_user_choices;
|
||||
DROP TABLE IF EXISTS consult_test_results;
|
||||
DROP TABLE IF EXISTS consult_options;
|
||||
DROP TABLE IF EXISTS consult_questions;
|
||||
DROP TABLE IF EXISTS consult_tests;
|
||||
DROP TABLE IF EXISTS consult_feedback;
|
||||
DROP TABLE IF EXISTS consult_focus;
|
||||
DROP TABLE IF EXISTS consult_slots;
|
||||
DROP TABLE IF EXISTS consult_schedule_days;
|
||||
DROP TABLE IF EXISTS consult_doctors;
|
||||
DROP TABLE IF EXISTS consult_business_scopes;
|
||||
DROP TABLE IF EXISTS consult_protocols;
|
||||
DROP TABLE IF EXISTS consult_news;
|
||||
DROP TABLE IF EXISTS consult_banners;
|
||||
DROP TABLE IF EXISTS consult_user_ext;
|
||||
@@ -0,0 +1,259 @@
|
||||
-- ECR-050: miniprogram consult domain (native PostgreSQL)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_user_ext (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id),
|
||||
stay_period varchar(64) NULL,
|
||||
id_card varchar(32) NULL,
|
||||
real_name varchar(64) NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_banners (
|
||||
id bigserial PRIMARY KEY,
|
||||
banner_name varchar(128) NOT NULL DEFAULT '',
|
||||
banner_image text NOT NULL DEFAULT '',
|
||||
click_url text NOT NULL DEFAULT '',
|
||||
describe text NOT NULL DEFAULT '',
|
||||
order_index int NOT NULL DEFAULT 0,
|
||||
jump_type int NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_news (
|
||||
id bigserial PRIMARY KEY,
|
||||
type int NOT NULL,
|
||||
title varchar(255) NOT NULL DEFAULT '',
|
||||
show_image text NOT NULL DEFAULT '',
|
||||
content text NOT NULL DEFAULT '',
|
||||
show_main int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_protocols (
|
||||
id bigserial PRIMARY KEY,
|
||||
code varchar(128) NOT NULL UNIQUE,
|
||||
title varchar(255) NOT NULL DEFAULT '',
|
||||
context text NOT NULL DEFAULT '',
|
||||
status int NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_business_scopes (
|
||||
code varchar(64) PRIMARY KEY,
|
||||
name varchar(64) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_doctors (
|
||||
id bigserial PRIMARY KEY,
|
||||
name varchar(64) NOT NULL,
|
||||
avatar text NOT NULL DEFAULT '',
|
||||
business_scope varchar(255) NOT NULL DEFAULT '',
|
||||
cover_url text NOT NULL DEFAULT '',
|
||||
consultation_method varchar(128) NOT NULL DEFAULT 'online',
|
||||
education varchar(128) NOT NULL DEFAULT '',
|
||||
introduction text NOT NULL DEFAULT '',
|
||||
resume text NOT NULL DEFAULT '',
|
||||
notice text NOT NULL DEFAULT '',
|
||||
tags varchar(255) NOT NULL DEFAULT '',
|
||||
work_experience varchar(128) NOT NULL DEFAULT '',
|
||||
work_start_time date NULL,
|
||||
price int NOT NULL DEFAULT 0,
|
||||
status int NOT NULL DEFAULT 1,
|
||||
address varchar(255) NOT NULL DEFAULT '',
|
||||
address_detail varchar(255) NOT NULL DEFAULT '',
|
||||
show_service_num int NOT NULL DEFAULT 0,
|
||||
is_top int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_schedule_days (
|
||||
id bigserial PRIMARY KEY,
|
||||
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
|
||||
schedule_date date NOT NULL,
|
||||
UNIQUE (doctor_id, schedule_date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_slots (
|
||||
id bigserial PRIMARY KEY,
|
||||
schedule_id bigint NOT NULL REFERENCES consult_schedule_days(id) ON DELETE CASCADE,
|
||||
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
|
||||
start_time time NOT NULL,
|
||||
end_time time NOT NULL,
|
||||
consultation_method varchar(64) NOT NULL DEFAULT 'online',
|
||||
status int NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_consult_slots_doctor_date ON consult_slots(doctor_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_focus (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
|
||||
status int NOT NULL DEFAULT 1,
|
||||
read_status int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, doctor_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_feedback (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
content_text text NOT NULL,
|
||||
contact varchar(32) NOT NULL DEFAULT '',
|
||||
status int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_tests (
|
||||
id bigserial PRIMARY KEY,
|
||||
test_name varchar(128) NOT NULL,
|
||||
sub_title varchar(255) NOT NULL DEFAULT '',
|
||||
test_pic text NOT NULL DEFAULT '',
|
||||
test_introduction text NOT NULL DEFAULT '',
|
||||
test_notice text NOT NULL DEFAULT '',
|
||||
total_num int NOT NULL DEFAULT 0,
|
||||
actual_num int NOT NULL DEFAULT 0,
|
||||
show_main int NOT NULL DEFAULT 0,
|
||||
status int NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_questions (
|
||||
id bigserial PRIMARY KEY,
|
||||
test_id bigint NOT NULL REFERENCES consult_tests(id) ON DELETE CASCADE,
|
||||
question_type int NOT NULL DEFAULT 1,
|
||||
question_text text NOT NULL,
|
||||
question_image text NOT NULL DEFAULT '',
|
||||
required int NOT NULL DEFAULT 1,
|
||||
order_index int NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_options (
|
||||
id bigserial PRIMARY KEY,
|
||||
question_id bigint NOT NULL REFERENCES consult_questions(id) ON DELETE CASCADE,
|
||||
option_text text NOT NULL,
|
||||
option_score int NOT NULL DEFAULT 0,
|
||||
order_index int NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_test_results (
|
||||
id bigserial PRIMARY KEY,
|
||||
test_id bigint NOT NULL REFERENCES consult_tests(id) ON DELETE CASCADE,
|
||||
min_score int NOT NULL DEFAULT 0,
|
||||
max_score int NOT NULL DEFAULT 0,
|
||||
result_desc text NOT NULL DEFAULT '',
|
||||
result_analysis text NOT NULL DEFAULT '',
|
||||
treat_plan text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_user_choices (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
test_id bigint NOT NULL REFERENCES consult_tests(id),
|
||||
result_id bigint NOT NULL REFERENCES consult_test_results(id),
|
||||
total_time int NOT NULL DEFAULT 0,
|
||||
choice_info jsonb NOT NULL DEFAULT '[]',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consult_orders (
|
||||
id bigserial PRIMARY KEY,
|
||||
order_sn varchar(64) NOT NULL UNIQUE,
|
||||
slot_id bigint NOT NULL REFERENCES consult_slots(id),
|
||||
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
appointment_date date NOT NULL,
|
||||
start_time time NOT NULL,
|
||||
end_time time NOT NULL,
|
||||
status int NOT NULL DEFAULT 0,
|
||||
user_deleted int NOT NULL DEFAULT 0,
|
||||
appointment_name varchar(64) NOT NULL DEFAULT '',
|
||||
birthday date NULL,
|
||||
phone varchar(20) NOT NULL DEFAULT '',
|
||||
sex int NOT NULL DEFAULT 1,
|
||||
emergency_contact_info text NOT NULL DEFAULT '',
|
||||
user_read int NOT NULL DEFAULT 0,
|
||||
total_amount int NOT NULL DEFAULT 0,
|
||||
valid_time timestamptz NULL,
|
||||
pay_time timestamptz NULL,
|
||||
cancel_time timestamptz NULL,
|
||||
cancel_flag int NOT NULL DEFAULT 0,
|
||||
consultation_method varchar(64) NOT NULL DEFAULT '',
|
||||
pay_param jsonb NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_consult_orders_user ON consult_orders(user_id, user_deleted);
|
||||
|
||||
-- seed: scopes, protocol, banner, news, doctor, test, 14-day slots
|
||||
INSERT INTO consult_business_scopes(code, name) VALUES
|
||||
('emotion', '情绪压力'),
|
||||
('relation', '亲密关系'),
|
||||
('career', '职业发展')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO consult_protocols(code, title, context, status) VALUES
|
||||
('consultation_appointment_agreement', '咨询预约协议', '<p>预约即表示你已阅读并同意本协议。本服务不替代危机干预或医疗诊断。</p>', 1),
|
||||
('psychological_show_appointment_agreement', '心理咨询展示与预约说明', '<p>咨询师信息仅供预约参考。如需紧急帮助请联系当地心理援助热线。</p>', 1),
|
||||
('privacy_protection_agreement', '愈心谷咨询小程序隐私保护指引', '<p>我们仅在提供服务所必需的范围内处理你的手机号与预约信息。</p>', 1)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO consult_banners(banner_name, banner_image, click_url, describe, order_index, jump_type)
|
||||
SELECT '愈心谷', '/static/n-main/yxg-brand-logo.png', '', '欢迎来到愈心谷', 1, 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consult_banners);
|
||||
|
||||
INSERT INTO consult_news(type, title, show_image, content, show_main)
|
||||
SELECT 1, '艺术疗愈入门', '/static/n-main/yxg-brand-logo.png', '<p>用创作看见自己的情绪。</p>', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consult_news WHERE type=1);
|
||||
|
||||
INSERT INTO consult_news(type, title, show_image, content, show_main)
|
||||
SELECT 2, '本周活动', '/static/n-main/yxg-brand-logo.png', '<p>线上主题分享,欢迎预约咨询师一对一。</p>', 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consult_news WHERE type=2);
|
||||
|
||||
INSERT INTO consult_doctors(name, avatar, business_scope, consultation_method, education, introduction, resume, notice, tags, work_experience, work_start_time, price, status, address, address_detail, show_service_num, is_top)
|
||||
SELECT '林安', '/static/n-main/yxg-brand-logo.png', 'emotion,relation', 'online,face_to_face', '应用心理硕士',
|
||||
'关注情绪调节与关系议题', '国家二级心理咨询师', '请提前10分钟进入会谈。', '情绪,关系',
|
||||
'8年', DATE '2018-03-01', 22600, 1, '线上/线下', '预约成功后发送地址', 128, 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consult_doctors);
|
||||
|
||||
INSERT INTO consult_schedule_days(doctor_id, schedule_date)
|
||||
SELECT d.id, (CURRENT_DATE + g.n)
|
||||
FROM consult_doctors d
|
||||
CROSS JOIN generate_series(1, 14) AS g(n)
|
||||
ON CONFLICT (doctor_id, schedule_date) DO NOTHING;
|
||||
|
||||
INSERT INTO consult_slots(schedule_id, doctor_id, start_time, end_time, consultation_method, status)
|
||||
SELECT s.id, s.doctor_id, t.st, t.et, 'online', 0
|
||||
FROM consult_schedule_days s
|
||||
CROSS JOIN (VALUES (TIME '09:00', TIME '10:00'), (TIME '14:00', TIME '15:00')) AS t(st, et)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM consult_slots x WHERE x.schedule_id=s.id AND x.start_time=t.st
|
||||
);
|
||||
|
||||
INSERT INTO consult_tests(test_name, sub_title, test_pic, test_introduction, test_notice, total_num, show_main, status)
|
||||
SELECT '情绪小测', '用两分钟看看最近的状态', '/static/n-main/yxg-brand-logo.png',
|
||||
'本测评仅供自我觉察,不是诊断。', '请按第一直觉作答。', 1280, 1, 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consult_tests);
|
||||
|
||||
INSERT INTO consult_questions(test_id, question_type, question_text, required, order_index)
|
||||
SELECT t.id, 1, '最近两周,我感到紧张或坐立不安。', 1, 1
|
||||
FROM consult_tests t
|
||||
WHERE t.test_name='情绪小测' AND NOT EXISTS (SELECT 1 FROM consult_questions q WHERE q.test_id=t.id);
|
||||
|
||||
INSERT INTO consult_questions(test_id, question_type, question_text, required, order_index)
|
||||
SELECT t.id, 1, '最近两周,我仍然能享受日常小事。', 1, 2
|
||||
FROM consult_tests t
|
||||
WHERE t.test_name='情绪小测' AND (SELECT COUNT(*) FROM consult_questions q WHERE q.test_id=t.id) < 2;
|
||||
|
||||
INSERT INTO consult_options(question_id, option_text, option_score, order_index)
|
||||
SELECT q.id, o.txt, o.sc, o.ord
|
||||
FROM consult_questions q
|
||||
JOIN consult_tests t ON t.id=q.test_id AND t.test_name='情绪小测'
|
||||
CROSS JOIN (VALUES ('很少', 0, 1), ('有时', 1, 2), ('经常', 2, 3)) AS o(txt, sc, ord)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consult_options x WHERE x.question_id=q.id);
|
||||
|
||||
INSERT INTO consult_test_results(test_id, min_score, max_score, result_desc, result_analysis, treat_plan)
|
||||
SELECT t.id, 0, 2, '状态平稳', '你最近的情绪波动在可调节范围内。', '保持作息,需要时可以和咨询师聊聊。'
|
||||
FROM consult_tests t WHERE t.test_name='情绪小测'
|
||||
AND NOT EXISTS (SELECT 1 FROM consult_test_results r WHERE r.test_id=t.id AND r.min_score=0);
|
||||
|
||||
INSERT INTO consult_test_results(test_id, min_score, max_score, result_desc, result_analysis, treat_plan)
|
||||
SELECT t.id, 3, 8, '需要被看见', '最近的紧绷感偏高,适合做一次梳理。', '建议预约咨询,或先做呼吸放松。'
|
||||
FROM consult_tests t WHERE t.test_name='情绪小测'
|
||||
AND NOT EXISTS (SELECT 1 FROM consult_test_results r WHERE r.test_id=t.id AND r.min_score=3);
|
||||
Reference in New Issue
Block a user