webui: Add user birthday settings

- Store birth month and day in user_pref with default 10/18
- Migrate existing user_pref rows to fill missing birthday fields
- Expose birthday editing in the webui profile page
- Replace hardcoded birthday values in user info, top info, and product list responses
- Fix birthday month/day field alignment in the profile form

Signed-off-by: Sean Du <do4suki@gmail.com>
This commit is contained in:
2026-06-05 05:48:36 +08:00
parent 101f7e8a3f
commit 947da92a66
14 changed files with 143 additions and 19 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ func api(ctx *gin.Context) {
case "notice": case "notice":
result, err = notice.NoticeApi(v.Action) result, err = notice.NoticeApi(v.Action)
case "payment": case "payment":
result, err = payment.PaymentApi(v.Action) result, err = payment.PaymentApi(ctx, v.Action)
case "profile": case "profile":
result, err = profile.ProfileApi(ctx, v.Action, v.UserID) result, err = profile.ProfileApi(ctx, v.Action, v.UserID)
case "scenario": case "scenario":
+1 -1
View File
@@ -11,7 +11,7 @@ func LoginApi(ctx *gin.Context, action string) (res any, err error) {
case "topInfo": case "topInfo":
res, err = loginTopInfo(ctx) res, err = loginTopInfo(ctx)
case "topInfoOnce": case "topInfoOnce":
res, err = loginTopInfoOnce() res, err = loginTopInfoOnce(ctx)
default: default:
err = fmt.Errorf("unimplemented action: login: %s", action) err = fmt.Errorf("unimplemented action: login: %s", action)
} }
+1 -1
View File
@@ -57,7 +57,7 @@ func loginTopInfo(ctx *gin.Context) (res any, err error) {
NoticeMailDatetime: now.Format("2006-01-02 15:04:05"), NoticeMailDatetime: now.Format("2006-01-02 15:04:05"),
FriendsApprovalWaitCnt: friendsApprovalWaitCnt, FriendsApprovalWaitCnt: friendsApprovalWaitCnt,
FriendsRequestCnt: friendsRequestCnt, FriendsRequestCnt: friendsRequestCnt,
IsTodayBirthday: false, IsTodayBirthday: ss.UserPref.IsBirthdayToday(now),
LicenseInfo: loginapischema.LicenseInfo{ LicenseInfo: loginapischema.LicenseInfo{
LicenseList: []any{}, LicenseList: []any{},
LicensedInfo: []any{}, LicensedInfo: []any{},
+7 -2
View File
@@ -2,10 +2,15 @@ package login
import ( import (
loginapischema "honoka-chan/internal/schema/api/login" loginapischema "honoka-chan/internal/schema/api/login"
"honoka-chan/internal/session"
"time" "time"
"github.com/gin-gonic/gin"
) )
func loginTopInfoOnce() (res any, err error) { func loginTopInfoOnce(ctx *gin.Context) (res any, err error) {
ss := session.Get(ctx)
res = loginapischema.TopInfoOnceResp{ res = loginapischema.TopInfoOnceResp{
Result: loginapischema.TopInfoOnceData{ Result: loginapischema.TopInfoOnceData{
NewAchievementCnt: 0, NewAchievementCnt: 0,
@@ -22,7 +27,7 @@ func loginTopInfoOnce() (res any, err error) {
Lbonus: false, Lbonus: false,
Event: false, Event: false,
Secretbox: false, Secretbox: false,
Birthday: true, Birthday: ss.UserPref.HasBirthDate(),
}, },
OpenArena: true, OpenArena: true,
CostumeStatus: true, CostumeStatus: true,
+7 -3
View File
@@ -1,11 +1,15 @@
package payment package payment
import "fmt" import (
"fmt"
func PaymentApi(action string) (res any, err error) { "github.com/gin-gonic/gin"
)
func PaymentApi(ctx *gin.Context, action string) (res any, err error) {
switch action { switch action {
case "productList": case "productList":
res, err = productList() res, err = productList(ctx)
default: default:
err = fmt.Errorf("unimplemented action: payment: %s", action) err = fmt.Errorf("unimplemented action: payment: %s", action)
} }
+7 -2
View File
@@ -2,17 +2,22 @@ package payment
import ( import (
paymentapischema "honoka-chan/internal/schema/api/payment" paymentapischema "honoka-chan/internal/schema/api/payment"
"honoka-chan/internal/session"
"time" "time"
"github.com/gin-gonic/gin"
) )
func productList() (res any, err error) { func productList(ctx *gin.Context) (res any, err error) {
ss := session.Get(ctx)
res = paymentapischema.ProductListResp{ res = paymentapischema.ProductListResp{
Result: paymentapischema.ProductListData{ Result: paymentapischema.ProductListData{
RestrictionInfo: paymentapischema.RestrictionInfo{ RestrictionInfo: paymentapischema.RestrictionInfo{
Restricted: false, Restricted: false,
}, },
UnderAgeInfo: paymentapischema.UnderAgeInfo{ UnderAgeInfo: paymentapischema.UnderAgeInfo{
BirthSet: false, BirthSet: ss.UserPref.HasBirthDate(),
HasLimit: false, HasLimit: false,
LimitAmount: nil, LimitAmount: nil,
MonthUsed: 0, MonthUsed: 0,
+8 -1
View File
@@ -2,6 +2,7 @@ package user
import ( import (
userapischema "honoka-chan/internal/schema/api/user" userapischema "honoka-chan/internal/schema/api/user"
userschema "honoka-chan/internal/schema/user"
"honoka-chan/internal/session" "honoka-chan/internal/session"
"time" "time"
@@ -12,7 +13,13 @@ func userInfo(ctx *gin.Context) (res any, err error) {
ss := session.Get(ctx) ss := session.Get(ctx)
res = userapischema.InfoResp{ res = userapischema.InfoResp{
Result: ss.GetUserInfo(), Result: userschema.UserInfoData{
User: ss.GetUserInfo(),
Birth: userschema.Birth{
BirthMonth: ss.UserPref.EffectiveBirthMonth(),
BirthDay: ss.UserPref.EffectiveBirthDay(),
},
},
Status: 200, Status: 200,
CommandNum: false, CommandNum: false,
TimeStamp: time.Now().Unix(), TimeStamp: time.Now().Unix(),
+2
View File
@@ -278,6 +278,8 @@ func addUser(dbSession *xorm.Session, phone, password string, isDefault bool) (g
SnsCoin: usermodel.DefaultUserSnsCoin, SnsCoin: usermodel.DefaultUserSnsCoin,
EnergyMax: usermodel.DefaultUserEnergyMax, EnergyMax: usermodel.DefaultUserEnergyMax,
OverMaxEnergy: usermodel.DefaultUserOverMaxEnergy, OverMaxEnergy: usermodel.DefaultUserOverMaxEnergy,
BirthMonth: usermodel.DefaultBirthMonth,
BirthDay: usermodel.DefaultBirthDay,
ProfileVersion: usermodel.CurrentUserPrefProfileVersion, ProfileVersion: usermodel.CurrentUserPrefProfileVersion,
UpdateTime: time.Now().Unix(), UpdateTime: time.Now().Unix(),
} }
+1 -1
View File
@@ -21,7 +21,7 @@ func productList(ctx *gin.Context) {
Restricted: false, Restricted: false,
}, },
UnderAgeInfo: paymentapischema.UnderAgeInfo{ UnderAgeInfo: paymentapischema.UnderAgeInfo{
BirthSet: true, BirthSet: ss.UserPref.HasBirthDate(),
HasLimit: false, HasLimit: false,
LimitAmount: nil, LimitAmount: nil,
MonthUsed: 0, MonthUsed: 0,
+2 -2
View File
@@ -18,8 +18,8 @@ func userInfo(ctx *gin.Context) {
ResponseData: userschema.UserInfoData{ ResponseData: userschema.UserInfoData{
User: ss.GetUserInfo(), User: ss.GetUserInfo(),
Birth: userschema.Birth{ Birth: userschema.Birth{
BirthMonth: 10, BirthMonth: ss.UserPref.EffectiveBirthMonth(),
BirthDay: 18, BirthDay: ss.UserPref.EffectiveBirthDay(),
}, },
ServerTimestamp: time.Now().Unix(), ServerTimestamp: time.Now().Unix(),
}, },
+20
View File
@@ -89,6 +89,20 @@ func updateProfile(ctx *gin.Context) {
ctx.JSON(http.StatusOK, webuischema.Msg{Code: 1, Message: "当前体力格式有误!"}) ctx.JSON(http.StatusOK, webuischema.Msg{Code: 1, Message: "当前体力格式有误!"})
return return
} }
birthMonth, err := parseInt("birth_month", 1)
if err != nil {
ctx.JSON(http.StatusOK, webuischema.Msg{Code: 1, Message: "生日月份格式有误!"})
return
}
birthDay, err := parseInt("birth_day", 1)
if err != nil {
ctx.JSON(http.StatusOK, webuischema.Msg{Code: 1, Message: "生日日期格式有误!"})
return
}
if !usermodel.IsValidBirthDate(birthMonth, birthDay) {
ctx.JSON(http.StatusOK, webuischema.Msg{Code: 1, Message: "生日日期无效!"})
return
}
pref := usermodel.UserPref{ pref := usermodel.UserPref{
UserName: userName, UserName: userName,
@@ -101,6 +115,8 @@ func updateProfile(ctx *gin.Context) {
SnsCoin: snsCoin, SnsCoin: snsCoin,
EnergyMax: energyMax, EnergyMax: energyMax,
OverMaxEnergy: overMaxEnergy, OverMaxEnergy: overMaxEnergy,
BirthMonth: birthMonth,
BirthDay: birthDay,
ProfileVersion: usermodel.CurrentUserPrefProfileVersion, ProfileVersion: usermodel.CurrentUserPrefProfileVersion,
UpdateTime: time.Now().Unix(), UpdateTime: time.Now().Unix(),
} }
@@ -117,6 +133,8 @@ func updateProfile(ctx *gin.Context) {
"sns_coin", "sns_coin",
"energy_max", "energy_max",
"over_max_energy", "over_max_energy",
"birth_month",
"birth_day",
"profile_version", "profile_version",
"update_time", "update_time",
). ).
@@ -163,6 +181,8 @@ func resetProfile(ctx *gin.Context) {
"sns_coin", "sns_coin",
"energy_max", "energy_max",
"over_max_energy", "over_max_energy",
"birth_month",
"birth_day",
"profile_version", "profile_version",
"update_time", "update_time",
). ).
+44 -1
View File
@@ -3,9 +3,10 @@ package usermodel
import ( import (
"strconv" "strconv"
"strings" "strings"
"time"
) )
const CurrentUserPrefProfileVersion = 1 const CurrentUserPrefProfileVersion = 2
const ( const (
DefaultAutoUserName = "音乃木坂学生" DefaultAutoUserName = "音乃木坂学生"
@@ -17,6 +18,8 @@ const (
DefaultUserSnsCoin = 10000 DefaultUserSnsCoin = 10000
DefaultUserEnergyMax = 417 DefaultUserEnergyMax = 417
DefaultUserOverMaxEnergy = 0 DefaultUserOverMaxEnergy = 0
DefaultBirthMonth = 10
DefaultBirthDay = 18
) )
type UserPref struct { type UserPref struct {
@@ -35,6 +38,8 @@ type UserPref struct {
SnsCoin int `xorm:"sns_coin"` SnsCoin int `xorm:"sns_coin"`
EnergyMax int `xorm:"energy_max"` EnergyMax int `xorm:"energy_max"`
OverMaxEnergy int `xorm:"over_max_energy"` OverMaxEnergy int `xorm:"over_max_energy"`
BirthMonth int `xorm:"birth_month"`
BirthDay int `xorm:"birth_day"`
ProfileVersion int `xorm:"profile_version"` ProfileVersion int `xorm:"profile_version"`
UpdateTime int64 `xorm:"update_time"` UpdateTime int64 `xorm:"update_time"`
} }
@@ -64,6 +69,10 @@ func (u *UserPref) ApplyProfileDefaults() {
if u.OverMaxEnergy < 0 { if u.OverMaxEnergy < 0 {
u.OverMaxEnergy = DefaultUserOverMaxEnergy u.OverMaxEnergy = DefaultUserOverMaxEnergy
} }
if !IsValidBirthDate(u.BirthMonth, u.BirthDay) {
u.BirthMonth = DefaultBirthMonth
u.BirthDay = DefaultBirthDay
}
u.ProfileVersion = CurrentUserPrefProfileVersion u.ProfileVersion = CurrentUserPrefProfileVersion
} }
@@ -77,6 +86,8 @@ func (u *UserPref) ResetProfileDefaults() {
u.SnsCoin = DefaultUserSnsCoin u.SnsCoin = DefaultUserSnsCoin
u.EnergyMax = DefaultUserEnergyMax u.EnergyMax = DefaultUserEnergyMax
u.OverMaxEnergy = DefaultUserOverMaxEnergy u.OverMaxEnergy = DefaultUserOverMaxEnergy
u.BirthMonth = DefaultBirthMonth
u.BirthDay = DefaultBirthDay
if u.UserID > 0 { if u.UserID > 0 {
u.InviteCode = strconv.Itoa(u.UserID) u.InviteCode = strconv.Itoa(u.UserID)
} }
@@ -101,6 +112,36 @@ func (u UserPref) EffectiveCurrentEnergy() int {
return u.EffectiveEnergyMax() return u.EffectiveEnergyMax()
} }
func (u UserPref) EffectiveBirthMonth() int {
if IsValidBirthDate(u.BirthMonth, u.BirthDay) {
return u.BirthMonth
}
return DefaultBirthMonth
}
func (u UserPref) EffectiveBirthDay() int {
if IsValidBirthDate(u.BirthMonth, u.BirthDay) {
return u.BirthDay
}
return DefaultBirthDay
}
func (u UserPref) HasBirthDate() bool {
return IsValidBirthDate(u.BirthMonth, u.BirthDay)
}
func (u UserPref) IsBirthdayToday(now time.Time) bool {
return int(now.Month()) == u.EffectiveBirthMonth() && now.Day() == u.EffectiveBirthDay()
}
func IsValidBirthDate(month, day int) bool {
if month < 1 || month > 12 || day < 1 {
return false
}
maxDay := time.Date(2000, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()
return day <= maxDay
}
func UserPrefProfileColumns() []string { func UserPrefProfileColumns() []string {
return []string{ return []string{
"user_level", "user_level",
@@ -111,6 +152,8 @@ func UserPrefProfileColumns() []string {
"sns_coin", "sns_coin",
"energy_max", "energy_max",
"over_max_energy", "over_max_energy",
"birth_month",
"birth_day",
"profile_version", "profile_version",
} }
} }
+4 -4
View File
@@ -3,8 +3,8 @@ package userapischema
import userschema "honoka-chan/internal/schema/user" import userschema "honoka-chan/internal/schema/user"
type InfoResp struct { type InfoResp struct {
Result userschema.UserInfo `json:"result"` Result userschema.UserInfoData `json:"result"`
Status int `json:"status"` Status int `json:"status"`
CommandNum bool `json:"commandNum"` CommandNum bool `json:"commandNum"`
TimeStamp int64 `json:"timeStamp"` TimeStamp int64 `json:"timeStamp"`
} }
+38
View File
@@ -15,6 +15,22 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.profile-birth-row {
display: flex;
gap: 10px;
align-items: center;
}
.profile-birth-field {
flex: 1 1 0;
min-width: 0;
}
.profile-birth-sep {
color: #666;
white-space: nowrap;
}
.profile-action-row .layui-btn+.layui-btn { .profile-action-row .layui-btn+.layui-btn {
margin-left: 0; margin-left: 0;
} }
@@ -24,6 +40,10 @@
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.profile-birth-row {
gap: 8px;
}
} }
</style> </style>
</head> </head>
@@ -93,6 +113,24 @@
<div class="layui-form-mid layui-word-aux">填 0 时按体力上限返回给客户端。</div> <div class="layui-form-mid layui-word-aux">填 0 时按体力上限返回给客户端。</div>
</div> </div>
</div> </div>
<div class="layui-form-item">
<label class="layui-form-label">生日</label>
<div class="layui-input-block">
<div class="profile-birth-row">
<div class="profile-birth-field">
<input type="number" name="birth_month" min="1" max="12" required lay-verify="required"
autocomplete="off" class="layui-input" value="{{ .pref.EffectiveBirthMonth }}">
</div>
<div class="profile-birth-sep"></div>
<div class="profile-birth-field">
<input type="number" name="birth_day" min="1" max="31" required lay-verify="required"
autocomplete="off" class="layui-input" value="{{ .pref.EffectiveBirthDay }}">
</div>
<div class="profile-birth-sep"></div>
</div>
<div class="layui-form-mid layui-word-aux">格式为 月 / 日,默认 10 / 18。</div>
</div>
</div>
<div class="layui-form-item"> <div class="layui-form-item">
<label class="layui-form-label">用户 ID</label> <label class="layui-form-label">用户 ID</label>
<div class="layui-input-block"> <div class="layui-input-block">