diff --git a/bench/isupipe/client_stats.go b/bench/isupipe/client_stats.go index ce94b9111..f1281db07 100644 --- a/bench/isupipe/client_stats.go +++ b/bench/isupipe/client_stats.go @@ -11,22 +11,19 @@ import ( ) type LivestreamStatistics struct { - MostTipRanking []TipRank `json:"most_tip_ranking"` - MostPostedReactionRanking []ReactionRank `json:"most_posted_reaction_ranking"` + Rank int64 `json:"rank"` + ViewersCount int64 `json:"viewers_count"` + TotalReactions int64 `json:"total_reactions"` + TotalReports int64 `json:"total_reports"` + MaxTip int64 `json:"max_tip"` } type UserStatistics struct { - TipRankPerLivestreams map[int]TipRank `json:"tip_rank_by_livestream"` -} - -type TipRank struct { - Rank int `json:"tip_rank"` - TotalTip int `json:"total_tip"` -} - -type ReactionRank struct { - Rank int `json:"reaction_rank"` - EmojiName string `json:"emoji_name"` + Rank int64 `json:"rank"` + ViewersCount int64 `json:"viewers_count"` + TotalReactions int64 `json:"total_reactions"` + TotalLivecomments int64 `json:"total_livecomments"` + TotalTip int64 `json:"total_tip"` } func (c *Client) GetUserStatistics(ctx context.Context, username string, opts ...ClientOption) (*UserStatistics, error) { diff --git a/bench/isupipe/client_user_test.go b/bench/isupipe/client_user_test.go index 5fea886f6..3ff8f2661 100644 --- a/bench/isupipe/client_user_test.go +++ b/bench/isupipe/client_user_test.go @@ -20,9 +20,6 @@ func TestClientUser_Login(t *testing.T) { ) assert.NoError(t, err) - _, err = client.Initialize(ctx) - assert.NoError(t, err) - streamer := scheduler.UserScheduler.GetRandomStreamer() assert.NoError(t, err) diff --git a/bench/isupipe/main_test.go b/bench/isupipe/main_test.go new file mode 100644 index 000000000..623d56d4f --- /dev/null +++ b/bench/isupipe/main_test.go @@ -0,0 +1,27 @@ +package isupipe + +import ( + "context" + "log" + "testing" + "time" + + "github.com/isucon/isucandar/agent" + "github.com/isucon/isucon13/bench/internal/config" +) + +func TestMain(m *testing.M) { + client, err := NewClient( + agent.WithBaseURL(config.TargetBaseURL), + agent.WithTimeout(1*time.Minute), + ) + if err != nil { + log.Fatalln(err) + } + + if _, err := client.Initialize(context.Background()); err != nil { + log.Fatalln(err) + } + + m.Run() +} diff --git a/docs/reservation.md b/docs/reservation.md deleted file mode 100644 index 246a19fe3..000000000 --- a/docs/reservation.md +++ /dev/null @@ -1,7 +0,0 @@ -# 配信予約 - -## ベンチマーカーが予約する時間の粒度について - -最小単位は1h。 - -のちほどこれでは不足するようならより細かい粒度も検討する \ No newline at end of file diff --git a/docs/specification.md b/docs/specification.md index d5864204d..d1fe47348 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -116,3 +116,28 @@ ISUPipeでは、治安維持のため、配信者が個人個人でスパム対 * シーズン4 + +# 配信予約 + +## ベンチマーカーが予約する時間の粒度について + +最小単位は1h。 + +のちほどこれでは不足するようならより細かい粒度も検討する + +# チップの概念について + +isupipeでは、行われている配信に対してライブコメントを投稿することができ、 +ライブコメントにはチップ(tips)が含まれます。 +これは、一般的に投げ銭システムと呼ばれるものです。 + +isupipeでは、このtipsについて、以下のようなレベル分けを行っております。 + +|金額帯|色| +|:--:|:--:| +|1 ~ 499| 青 | +|500 ~ 999 | 緑 | +|1000 ~ 4999| 黄色 | +|5000 ~ 9999 | オレンジ | +|10000 ~ 20000 | 赤 | + diff --git a/docs/tips.md b/docs/tips.md deleted file mode 100644 index 6b494cbc7..000000000 --- a/docs/tips.md +++ /dev/null @@ -1,16 +0,0 @@ -# チップの概念について - -isupipeでは、行われている配信に対してライブコメントを投稿することができ、 -ライブコメントにはチップ(tips)が含まれます。 -これは、一般的に投げ銭システムと呼ばれるものです。 - -isupipeでは、このtipsについて、以下のようなレベル分けを行っております。 - -|金額帯|色| -|:--:|:--:| -|1 ~ 499| 青 | -|500 ~ 999 | 緑 | -|1000 ~ 4999| 黄色 | -|5000 ~ 9999 | オレンジ | -|10000 ~ 20000 | 赤 | - diff --git a/docs/transplant.md b/docs/transplant.md new file mode 100644 index 000000000..f446033ee --- /dev/null +++ b/docs/transplant.md @@ -0,0 +1,28 @@ +# 移植マニュアル + + +## Webappについて + + + + +## self-hosted runnerについて + + + + +## 関連するミドルウェアについて + + + + +## CI実行方法について + + + + +## ベンチマーカーで発生するエラーについて + +エラーには以下のような種類があります + +うち、移植失敗の可能性を示唆するものは以下のとおりです \ No newline at end of file diff --git a/webapp/go/stats_handler.go b/webapp/go/stats_handler.go index aa2f246dc..2f3686d61 100644 --- a/webapp/go/stats_handler.go +++ b/webapp/go/stats_handler.go @@ -1,46 +1,61 @@ package main import ( - "context" - "database/sql" - "errors" "net/http" + "sort" "strconv" "github.com/labstack/echo/v4" ) -// FIXME: 配信毎、ユーザごとのリアクション種別ごとの数などもだす - type LivestreamStatistics struct { - MostTipRanking []TipRank `json:"most_tip_ranking"` - MostPostedReactionRanking []ReactionRank `json:"most_posted_reaction_ranking"` + Rank int64 `json:"rank"` + ViewersCount int64 `json:"viewers_count"` + TotalReactions int64 `json:"total_reactions"` + TotalReports int64 `json:"total_reports"` + MaxTip int64 `json:"max_tip"` } -type UserStatistics struct { - TipRankPerLivestreams map[int64]TipRank `json:"tip_rank_by_livestream"` +type LivestreamRankingEntry struct { + LivestreamID int64 + Title string + Score int64 } - -type TipRank struct { - Rank int64 `json:"tip_rank"` - TotalTip int64 `json:"total_tip"` +type LivestreamRanking []LivestreamRankingEntry + +func (r LivestreamRanking) Len() int { return len(r) } +func (r LivestreamRanking) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r LivestreamRanking) Less(i, j int) bool { + if r[i].Score == r[j].Score { + return r[i].Title < r[j].Title + } else { + return r[i].Score < r[j].Score + } } -type TipRankModel struct { - Rank int64 `db:"tip_rank"` - TotalTip int64 `db:"total_tip"` +type UserStatistics struct { + Rank int64 `json:"rank"` + ViewersCount int64 `json:"viewers_count"` + TotalReactions int64 `json:"total_reactions"` + TotalLivecomments int64 `json:"total_livecomments"` + TotalTip int64 `json:"total_tip"` } -type ReactionRank struct { - Rank int64 `json:"reaction_rank"` - TotalReaction int64 `json:"total_reaction"` - EmojiName string `json:"emoji_name"` +type UserRankingEntry struct { + UserID int64 + UserName string + Score int64 } - -type ReactionRankModel struct { - Rank int64 `db:"reaction_rank"` - TotalReaction int64 `db:"total_reaction"` - EmojiName string `db:"emoji_name"` +type UserRanking []UserRankingEntry + +func (r UserRanking) Len() int { return len(r) } +func (r UserRanking) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r UserRanking) Less(i, j int) bool { + if r[i].Score == r[j].Score { + return r[i].UserName < r[j].UserName + } else { + return r[i].Score < r[j].Score + } } func getUserStatisticsHandler(c echo.Context) error { @@ -52,37 +67,118 @@ func getUserStatisticsHandler(c echo.Context) error { } username := c.Param("username") + // ユーザごとに、紐づく配信について、累計リアクション数、累計ライブコメント数、累計売上金額を算出 + // また、現在の合計視聴者数もだす - userModel := UserModel{} - err := dbConn.GetContext(ctx, &userModel, "SELECT * FROM users where name = ?", username) - if errors.Is(err, sql.ErrNoRows) { - return echo.NewHTTPError(http.StatusNotFound, err.Error()) - } + tx, err := dbConn.BeginTxx(ctx, nil) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + defer tx.Rollback() + + // 配信を区別せず、すべての合計を取る必要がある - // FIXME: livestream_viewers_historyのレコードがDELETEされるようになるので、統計情報見直し - var viewedLivestreams []*LivestreamViewerModel - if err := dbConn.SelectContext(ctx, &viewedLivestreams, "SELECT user_id, livestream_id FROM livestream_viewers_history WHERE user_id = ?", userModel.ID); err != nil { + // ランク算出 + var users []*UserModel + if err := tx.SelectContext(ctx, &users, "SELECT * FROM users"); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - tipRankModelPerLivestreams, err := queryTotalTipRankPerViewedLivestream(ctx, viewedLivestreams) - if err != nil { + var ranking UserRanking + for _, user := range users { + var reactions int64 + query := ` + SELECT COUNT(*) FROM users u + INNER JOIN livestreams l ON l.user_id = u.id + INNER JOIN reactions r ON r.livestream_id = l.id + WHERE u.id = ?` + if err := tx.GetContext(ctx, &reactions, query, user.ID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + var tips int64 + query = ` + SELECT IFNULL(SUM(l2.tip), 0) FROM users u + INNER JOIN livestreams l ON l.user_id = u.id + INNER JOIN livecomments l2 ON l2.livestream_id = l.id + WHERE u.id = ?` + if err := tx.GetContext(ctx, &tips, query, user.ID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + score := reactions + tips + ranking = append(ranking, UserRankingEntry{ + UserID: user.ID, + UserName: user.Name, + Score: score, + }) + } + sort.Sort(ranking) + + var rank int64 + for i := len(ranking) - 1; i >= 0; i-- { + entry := ranking[i] + if entry.UserName == username { + break + } + rank++ + } + + // リアクション数 + var totalReactions int64 + query := `SELECT COUNT(*) FROM users u + INNER JOIN livestreams l ON l.user_id = u.id + INNER JOIN reactions r ON r.livestream_id = l.id + WHERE u.name = ? + ` + if err := tx.GetContext(ctx, &totalReactions, query, username); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - tipRankPerLivestream := make(map[int64]TipRank) - for livestreamID, tipRankModel := range tipRankModelPerLivestreams { - tipRankPerLivestream[livestreamID] = TipRank{ - Rank: tipRankModel.Rank, - TotalTip: tipRankModel.TotalTip, + // ライブコメント数、チップ合計 + var totalLivecomments int64 + var totalTip int64 + for _, user := range users { + var livestreams []*LivestreamModel + if err := tx.SelectContext(ctx, &livestreams, "SELECT * FROM livestreams WHERE user_id = ?", user.ID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + for _, livestream := range livestreams { + var livecomments []*LivecommentModel + if err := tx.SelectContext(ctx, &livecomments, "SELECT * FROM livecomments WHERE livestream_id = ?", livestream.ID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + for _, livecomment := range livecomments { + totalTip += livecomment.Tip + totalLivecomments++ + } + } + } + + // 合計視聴者数 + var viewersCount int64 + for _, user := range users { + var livestreams []*LivestreamModel + if err := tx.SelectContext(ctx, &livestreams, "SELECT * FROM livestreams WHERE user_id = ?", user.ID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + for _, livestream := range livestreams { + var cnt int64 + if err := tx.GetContext(ctx, &cnt, "SELECT COUNT(*) FROM livestream_viewers_history WHERE livestream_id = ?", livestream.ID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + viewersCount += cnt } } stats := UserStatistics{ - TipRankPerLivestreams: tipRankPerLivestream, + Rank: rank, + ViewersCount: viewersCount, + TotalReactions: totalReactions, + TotalLivecomments: totalLivecomments, + TotalTip: totalTip, } return c.JSON(http.StatusOK, stats) } @@ -94,81 +190,88 @@ func getLivestreamStatisticsHandler(c echo.Context) error { return err } - livestreamID, err := strconv.Atoi(c.Param("livestream_id")) + id, err := strconv.Atoi(c.Param("livestream_id")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + livestreamID := int64(id) - tipRankModels := []TipRankModel{} - query := "SELECT SUM(tip) AS total_tip, RANK() OVER(ORDER BY SUM(tip) DESC) AS tip_rank " + - "FROM livecomments GROUP BY livestream_id " + - "HAVING livestream_id = ? ORDER BY total_tip DESC LIMIT 3" - if err := dbConn.SelectContext(ctx, &tipRankModels, query, livestreamID); err != nil { + tx, err := dbConn.BeginTxx(ctx, nil) + if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + defer tx.Rollback() - reactionRankModels := []ReactionRankModel{} - query = "SELECT COUNT(*) AS total_reaction, emoji_name, RANK() OVER(ORDER BY COUNT(*) DESC) AS reaction_rank " + - "FROM reactions GROUP BY livestream_id, emoji_name " + - "HAVING livestream_id = ? ORDER BY total_reaction DESC LIMIT 3" - if err := dbConn.SelectContext(ctx, &reactionRankModels, query, livestreamID); err != nil { + var livestreams []*LivestreamModel + if err := tx.SelectContext(ctx, &livestreams, "SELECT * FROM livestreams"); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - tipRanks := make([]TipRank, len(tipRankModels)) - for i := range tipRankModels { - tipRanks[i] = TipRank{ - Rank: tipRankModels[i].Rank, - TotalTip: tipRankModels[i].TotalTip, + // ランク算出 + var ranking LivestreamRanking + for _, livestream := range livestreams { + var reactions int64 + if err := tx.GetContext(ctx, &reactions, "SELECT COUNT(*) FROM livestreams l INNER JOIN reactions r ON l.id = r.livestream_id WHERE l.id = ?", livestreamID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - } - reactionRanks := make([]ReactionRank, len(reactionRankModels)) - for i := range reactionRankModels { - reactionRanks[i] = ReactionRank{ - Rank: reactionRankModels[i].Rank, - TotalReaction: reactionRankModels[i].TotalReaction, - EmojiName: reactionRankModels[i].EmojiName, + var totalTips int64 + if err := tx.GetContext(ctx, &totalTips, "SELECT IFNULL(SUM(l2.tip), 0) FROM livestreams l INNER JOIN livecomments l2 ON l.id = l2.livestream_id WHERE l.id = ?", livestreamID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - } - stats := LivestreamStatistics{ - MostTipRanking: tipRanks, - MostPostedReactionRanking: reactionRanks, + score := reactions + totalTips + ranking = append(ranking, LivestreamRankingEntry{ + LivestreamID: livestream.ID, + Title: livestream.Title, + Score: score, + }) } - return c.JSON(http.StatusOK, stats) -} + sort.Sort(ranking) -func queryTotalTipRankPerViewedLivestream( - ctx context.Context, - viewedLivestreams []*LivestreamViewerModel, -) (map[int64]TipRankModel, error) { - totalTipRankPerLivestream := make(map[int64]TipRankModel) - // get total tip per viewed livestream - for _, viewedLivestream := range viewedLivestreams { - totalTip := int64(0) - if err := dbConn.GetContext(ctx, &totalTip, "SELECT SUM(tip) FROM livecomments WHERE user_id = ? AND livestream_id = ?", viewedLivestream.UserID, viewedLivestream.LivestreamID); err != nil { - return totalTipRankPerLivestream, err + var rank int64 = 1 + for i := len(ranking) - 1; i >= 0; i-- { + entry := ranking[i] + if entry.LivestreamID == livestreamID { + break } + rank++ + } - totalTipRankPerLivestream[viewedLivestream.LivestreamID] = TipRankModel{ - TotalTip: totalTip, - } + var livestream LivestreamModel + if err := tx.GetContext(ctx, &livestream, "SELECT * FROM livestreams WHERE id = ?", livestreamID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - for livestreamID, stat := range totalTipRankPerLivestream { - query := "SELECT SUM(tip) AS total_tip, RANK() OVER(ORDER BY SUM(tip) DESC) AS tip_rank " + - "FROM livecomments GROUP BY livestream_id " + - "HAVING livestream_id = ? AND total_tip = ?" + // 視聴者数算出 + var viewersCount int64 + if err := tx.GetContext(ctx, &viewersCount, `SELECT COUNT(*) FROM livestreams l INNER JOIN livestream_viewers_history h ON h.livestream_id = l.id WHERE l.id = ?`, livestreamID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } - rank := TipRankModel{} - if err := dbConn.GetContext(ctx, &rank, query, livestreamID, stat.TotalTip); err != nil { - return totalTipRankPerLivestream, err - } + // 最大チップ額 + var maxTip int64 + if err := tx.GetContext(ctx, &maxTip, `SELECT IFNULL(MAX(tip), 0) FROM livestreams l INNER JOIN livecomments l2 ON l2.livestream_id = l.id WHERE l.id = ?`, livestreamID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } - totalTipRankPerLivestream[livestreamID] = rank + // リアクション数 + var totalReactions int64 + if err := tx.GetContext(ctx, &totalReactions, "SELECT COUNT(*) FROM livestreams l INNER JOIN reactions r ON r.livestream_id = l.id WHERE l.id = ?", livestreamID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + // スパム報告数 + var totalReports int64 + if err := tx.GetContext(ctx, &totalReports, `SELECT COUNT(*) FROM livestreams l INNER JOIN livecomment_reports r ON r.livestream_id = l.id WHERE l.id = ?`, livestreamID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return totalTipRankPerLivestream, nil + return c.JSON(http.StatusOK, LivestreamStatistics{ + Rank: rank, + ViewersCount: viewersCount, + MaxTip: maxTip, + TotalReactions: totalReactions, + TotalReports: totalReports, + }) }